diff --git a/android/app/build.gradle b/android/app/build.gradle index 9b46ac1..cb71a9c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -45,7 +45,7 @@ android { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.illusionware.lyricsgrabbr.lyricsgrabbr" minSdkVersion 23 - targetSdkVersion 31 + targetSdkVersion 30 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } @@ -65,4 +65,5 @@ flutter { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9' } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 00b3eb0..a394b38 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,11 @@ + + + when (call.method) { "plugin.hasPermission" -> { @@ -29,18 +36,19 @@ class MainActivity: FlutterActivity() { "plugin.openPermissionSettings" -> { result.success(openPermissionSettings()) } + "plugin.startService" -> { + result.success(startService()) + } + "plugin.stopService" -> { + result.success(stopService()) + } else -> result.notImplemented() } } - val eventChannel = EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL_NAME) - streamHandler = NotificationStreamHandler(context) - eventChannel.setStreamHandler(streamHandler) - } - - override fun onDestroy() { - super.onDestroy() - streamHandler?.cleanUp() +// val eventChannel = EventChannel(flutterEngine!!.dartExecutor.binaryMessenger, EVENT_CHANNEL_NAME) +// streamHandler = NotificationStreamHandler(context) +// eventChannel.setStreamHandler(streamHandler) } private fun isPermissionGranted(): Boolean { @@ -59,4 +67,21 @@ class MainActivity: FlutterActivity() { context.startActivity(Intent(ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) return true } + + private fun startService(): Boolean { + listenerIntent?.action = NotificationListener.START_FOREGROUND_SERVICE_ACTION + ContextCompat.startForegroundService(applicationContext, listenerIntent!!) + Log.d("startService", "starting") + + return true + } + + private fun stopService(): Boolean { + listenerIntent?.action = NotificationListener.STOP_FOREGROUND_SERVICE_ACTION + ContextCompat.startForegroundService(applicationContext, listenerIntent!!) + Log.d("stopService", "stopping") + //applicationContext.stopService(listenerIntent!!) + + return true + } } diff --git a/android/app/src/main/kotlin/com/illusionware/lyricsgrabbr/lyricsgrabbr/NotificationListener.kt b/android/app/src/main/kotlin/com/illusionware/lyricsgrabbr/lyricsgrabbr/NotificationListener.kt index 2d8f4ac..d2a2679 100644 --- a/android/app/src/main/kotlin/com/illusionware/lyricsgrabbr/lyricsgrabbr/NotificationListener.kt +++ b/android/app/src/main/kotlin/com/illusionware/lyricsgrabbr/lyricsgrabbr/NotificationListener.kt @@ -1,26 +1,58 @@ package com.illusionware.lyricsgrabbr.lyricsgrabbr import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager import android.content.Context import android.content.Intent -import android.graphics.Bitmap import android.media.MediaMetadata import android.media.session.MediaController import android.media.session.MediaSession +import android.os.Build +import android.os.Handler import android.service.notification.NotificationListenerService import android.service.notification.StatusBarNotification -import org.json.JSONException -import org.json.JSONObject -import android.os.Bundle -import java.io.File -import java.io.FileOutputStream +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat + /** * Notification listening service. Intercepts notifications if permission is given to do so. */ class NotificationListener : NotificationListenerService() { + private var notificationManager : NotificationManagerCompat? = null + + override fun onCreate() { + super.onCreate() + Log.d("onCreate Service", "creating service") + + initNotificationChannels(this) + notificationManager = NotificationManagerCompat.from(applicationContext) + } + + override fun onDestroy() { + Log.d("onDestroy Service", "destroying service") + stopForeground(true) + stopSelf() + super.onDestroy() + } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - getCurrentNotifications(applicationContext) + if (intent != null) { + when (intent.action) { + START_FOREGROUND_SERVICE_ACTION -> { + Log.d("START", "starting foreground service") + startForegroundService() + getCurrentNotifications(applicationContext) + } + STOP_FOREGROUND_SERVICE_ACTION -> { + Log.d("STOP", "stopping foreground service") + stopForegroundService() + } + } + } + return super.onStartCommand(intent, flags, startId) } @@ -29,14 +61,46 @@ class NotificationListener : NotificationListenerService() { } override fun onNotificationPosted(sbn: StatusBarNotification) { - sendNotificationBroadcast(sbn, applicationContext) + if (sbn.packageName == SPOTIFY_PACKAGE) { + sendNotificationBroadcast(sbn, applicationContext) + } + } + + private fun startForegroundService() { + val notification: Notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setContentTitle("TITLE") + .setContentText("TEXT") + .setSmallIcon(R.drawable.ic_notification) + .build() + + notification.flags = Notification.FLAG_FOREGROUND_SERVICE + startForeground(NOTIFICATION_ID, notification) + } + + private fun stopForegroundService() { + stopForeground(true) + stopSelf() + } + + private fun initNotificationChannels(context: Context) { + if (Build.VERSION.SDK_INT < 26) { + return + } + val notificationManager = + context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + "Lyrics", + NotificationManager.IMPORTANCE_LOW + ) + + notificationManager.createNotificationChannel(channel) } private fun getCurrentNotifications(context: Context) { val sbns = activeNotifications for (sbn in sbns) { - // TODO: remove this check and figure out a way to send a fucking list instead - if (sbn.packageName == "com.spotify.music") { + if (sbn.packageName == SPOTIFY_PACKAGE) { sendNotificationBroadcast(sbn, context) } } @@ -48,19 +112,7 @@ class NotificationListener : NotificationListenerService() { val mediaCtrl = mediaSessionToken?.let { MediaController(context, it) } val duration = mediaCtrl?.metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.div(1000) val playbackState = mediaCtrl?.playbackState?.state - - var albumArtPath = "" - - if (sbn.packageName == "com.spotify.music") { - val albumArt = mediaCtrl?.metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART) - val tempArtFile = File(cacheDir, "albumart_temp.png") - tempArtFile.createNewFile() - val fileOutputStream = FileOutputStream(tempArtFile) - albumArt?.compress(Bitmap.CompressFormat.PNG, 100, fileOutputStream) - fileOutputStream.flush() - fileOutputStream.close() - albumArtPath = tempArtFile.absolutePath - } + val albumArt = mediaCtrl?.metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART) // Retrieve package name to set as title. val packageName = sbn.packageName @@ -73,10 +125,22 @@ class NotificationListener : NotificationListenerService() { intent.putExtra(NOTIFICATION_PACKAGE_NAME, packageName) intent.putExtra(NOTIFICATION_PACKAGE_MESSAGE, packageMessage) intent.putExtra(NOTIFICATION_PACKAGE_TEXT, packageText) - intent.putExtra(NOTIFICATION_PACKAGE_ART, albumArtPath) intent.putExtra(NOTIFICATION_PACKAGE_DURATION, duration) intent.putExtra(NOTIFICATION_PACKAGE_PLAYBACK_STATE, playbackState) - sendBroadcast(intent) + + val notification: Notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setContentTitle("Playing [${packageText}] By [${packageMessage}]") + .setContentText("Tap to get lyrics") + .setSmallIcon(R.drawable.ic_notification) + .setLargeIcon(albumArt) +// .setContentIntent(pendingIntent) // launch intent when clicking on notification + .build() + + notification.flags = Notification.FLAG_FOREGROUND_SERVICE + + notificationManager?.notify(NOTIFICATION_ID, notification) + + //sendBroadcast(intent) } companion object { @@ -84,22 +148,12 @@ class NotificationListener : NotificationListenerService() { const val NOTIFICATION_PACKAGE_NAME = "package_name" const val NOTIFICATION_PACKAGE_MESSAGE = "package_message" const val NOTIFICATION_PACKAGE_TEXT = "package_text" - const val NOTIFICATION_PACKAGE_EXTRA = "package_extra" - const val NOTIFICATION_PACKAGE_ART = "package_art" const val NOTIFICATION_PACKAGE_PLAYBACK_STATE = "package_playback_state" const val NOTIFICATION_PACKAGE_DURATION = "package_duration" - } - - private fun convertBundleToJsonString(extra: Bundle): String { - val json = JSONObject() - val keys = extra.keySet() - for (key in keys) { - try { - json.put(key, JSONObject.wrap(extra.get(key))) - } catch (e: JSONException) { - //Handle exception here - } - } - return json.toString() + const val NOTIFICATION_ID = 12251999 + const val NOTIFICATION_CHANNEL_ID = "Lyrics" + const val SPOTIFY_PACKAGE = "com.spotify.music" + const val START_FOREGROUND_SERVICE_ACTION = "START_SERVICE" + const val STOP_FOREGROUND_SERVICE_ACTION = "STOP_SERVICE" } } diff --git a/android/app/src/main/kotlin/com/illusionware/lyricsgrabbr/lyricsgrabbr/NotificationStreamHandler.kt b/android/app/src/main/kotlin/com/illusionware/lyricsgrabbr/lyricsgrabbr/NotificationStreamHandler.kt index b719da1..8b6bf0f 100644 --- a/android/app/src/main/kotlin/com/illusionware/lyricsgrabbr/lyricsgrabbr/NotificationStreamHandler.kt +++ b/android/app/src/main/kotlin/com/illusionware/lyricsgrabbr/lyricsgrabbr/NotificationStreamHandler.kt @@ -3,34 +3,38 @@ package com.illusionware.lyricsgrabbr.lyricsgrabbr import io.flutter.plugin.common.EventChannel.StreamHandler import io.flutter.plugin.common.EventChannel.EventSink import android.content.* +import android.util.Log +import androidx.core.content.ContextCompat class NotificationStreamHandler(private val context: Context) : StreamHandler { private var eventSink: EventSink? = null private var receiver: NotificationReceiver? = null - private val listenerIntent = Intent(context, NotificationListener::class.java) // Called whenever the event channel is subscribed to in Flutter override fun onListen(o: Any?, eventSink: EventSink?) { this.eventSink = eventSink - receiver = NotificationReceiver() - val intentFilter = IntentFilter() - intentFilter.addAction(NotificationListener.NOTIFICATION_INTENT) - context.registerReceiver(receiver, intentFilter) + val listenerIntent = Intent(context, NotificationListener::class.java) + listenerIntent.action = NotificationListener.START_FOREGROUND_SERVICE_ACTION - context.startService(listenerIntent) + //receiver = NotificationReceiver() + //val intentFilter = IntentFilter() + //intentFilter.addAction(NotificationListener.NOTIFICATION_INTENT) + //context.registerReceiver(receiver, intentFilter) + + ContextCompat.startForegroundService(context, listenerIntent) } // Called whenever the event channel subscription is cancelled in Flutter override fun onCancel(o: Any?) { eventSink = null + Log.d("onCancel", "subscription cancelled, stopping service") - context.stopService(listenerIntent) - context.unregisterReceiver(receiver) - } + val listenerIntent = Intent(context, NotificationListener::class.java) + listenerIntent.action = NotificationListener.STOP_FOREGROUND_SERVICE_ACTION - fun cleanUp() { - context.unregisterReceiver(receiver) + ContextCompat.startForegroundService(context, listenerIntent) + //context.unregisterReceiver(receiver) } internal inner class NotificationReceiver : BroadcastReceiver() { @@ -38,14 +42,12 @@ class NotificationStreamHandler(private val context: Context) : StreamHandler { val packageName = intent.getStringExtra(NotificationListener.NOTIFICATION_PACKAGE_NAME) val packageMessage = intent.getStringExtra(NotificationListener.NOTIFICATION_PACKAGE_MESSAGE) val packageText = intent.getStringExtra(NotificationListener.NOTIFICATION_PACKAGE_TEXT) - val packageArt = intent.getStringExtra(NotificationListener.NOTIFICATION_PACKAGE_ART) val packagePlaybackState = intent.getIntExtra(NotificationListener.NOTIFICATION_PACKAGE_PLAYBACK_STATE, -1) val packageDuration = intent.getLongExtra(NotificationListener.NOTIFICATION_PACKAGE_DURATION, -1) val map = HashMap() map["packageName"] = packageName!! map["packageMessage"] = packageMessage!! map["packageText"] = packageText!! - map["packageArt"] = packageArt!! map["packagePlaybackState"] = packagePlaybackState map["packageDuration"] = packageDuration eventSink?.success(map) diff --git a/android/gradle.properties b/android/gradle.properties index 94adc3a..c05efd1 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,16 @@ -org.gradle.jvmargs=-Xmx1536M +## For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx1024m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +# +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +#Sun Feb 27 21:16:18 EET 2022 +org.gradle.jvmargs=-Xmx1024M -Dkotlin.daemon.jvm.options\="-Xmx1024M" android.useAndroidX=true android.enableJetifier=true diff --git a/lib/main.dart b/lib/main.dart index 0af1f7e..5fd366e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,34 +2,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'notification_listener.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; - -final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = - FlutterLocalNotificationsPlugin(); - -const spotifyPackage = "com.spotify.music"; -const notificationId = 0; Future main() async { - WidgetsFlutterBinding.ensureInitialized(); - - const AndroidInitializationSettings initializationSettingsAndroid = - AndroidInitializationSettings('@drawable/ic_notification'); - const InitializationSettings initializationSettings = - InitializationSettings(android: initializationSettingsAndroid); - await flutterLocalNotificationsPlugin.initialize(initializationSettings, - onSelectNotification: onNotificationClick); - runApp(const LyricsGrabbr()); } -void onNotificationClick(String? payload) async { - if (payload != null) { - print('notification payload: $payload'); - } - print('notification pressed'); -} - class LyricsGrabbr extends StatelessWidget { const LyricsGrabbr({Key? key}) : super(key: key); @@ -135,39 +112,12 @@ class _HomePageState extends State with WidgetsBindingObserver { } void onData(NotificationEvent event) { - if (event.packageName == spotifyPackage) { - // TODO: if the app is in the background, only update our notif - // if the app is in the foreground, update the notif, and - // fetch the new lyrics and display them. - sendNotification(event); - setState(() { - _notifs.add(event); - }); - } - } - - Future sendNotification(NotificationEvent notification) async { - final AndroidNotificationDetails androidNotification = - AndroidNotificationDetails( - "Lyrics", - "Lyrics", - importance: Importance.low, - priority: Priority.min, - showWhen: false, - ongoing: true, - playSound: false, - largeIcon: FilePathAndroidBitmap(notification.art), - ); - - final NotificationDetails notificationDetails = - NotificationDetails(android: androidNotification); - - await flutterLocalNotificationsPlugin.show( - notificationId, - 'Playing [${notification.songTitle}] By [${notification.artist}]', - 'Tap to get lyrics', - notificationDetails, - ); + // TODO: if the app is in the background, only update our notif + // if the app is in the foreground, update the notif, and + // fetch the new lyrics and display them. + setState(() { + _notifs.add(event); + }); } void startListening() { @@ -175,16 +125,17 @@ class _HomePageState extends State with WidgetsBindingObserver { // that keeps track of which song is playing // and if pressed will fetch the lyrics and display them. - _notifications = AndroidNotificationListener(); + //_notifications = AndroidNotificationListener(); - _subscription = _notifications.notificationStream.listen(onData); + //_subscription = _notifications.notificationStream.listen(onData); + AndroidNotificationListener.startService(); setState(() => running = true); } void stopListening() { setState(() => running = false); - _subscription.cancel(); - flutterLocalNotificationsPlugin.cancel(notificationId); + AndroidNotificationListener.stopService(); + //_subscription.cancel(); } @override diff --git a/lib/notification_listener.dart b/lib/notification_listener.dart index c745704..6948269 100644 --- a/lib/notification_listener.dart +++ b/lib/notification_listener.dart @@ -23,7 +23,6 @@ class NotificationEvent { final String artist; final String packageName; final String songTitle; - final String art; final int playbackState; final int duration; @@ -34,7 +33,6 @@ class NotificationEvent { required this.artist, required this.timeStamp, required this.songTitle, - required this.art, required this.playbackState, required this.duration }); @@ -44,7 +42,6 @@ class NotificationEvent { String name = map['packageName']; String message = map['packageMessage']; String text = map['packageText']; - String icon = map['packageArt']; int playbackState = map['packagePlaybackState']; int duration = map['packageDuration']; @@ -53,7 +50,6 @@ class NotificationEvent { artist: message, timeStamp: time, songTitle: text, - art: icon, playbackState: playbackState, duration: duration ); @@ -89,4 +85,12 @@ class AndroidNotificationListener { static Future openPermissionSettings() async { return await _methodChannel.invokeMethod('plugin.openPermissionSettings'); } + + static Future startService() async { + return await _methodChannel.invokeMethod('plugin.startService'); + } + + static Future stopService() async { + return await _methodChannel.invokeMethod('plugin.stopService'); + } }