diff --git a/README.md b/README.md index 2fd887bf..c58fe6e5 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,32 @@ FlutterForegroundTask.init( For a full list of persisted keys and architecture details, see [android_shared_preferences_usage.md](documentation/android_shared_preferences_usage.md). +**Multiple Services (Android)**: + +By default the library provides a single foreground service. If your app needs **multiple independent services** running simultaneously (e.g. a location tracker and a media player), you can register additional service classes and control each one independently from Dart using `FlutterForegroundTaskController`: + +```dart +final locationCtrl = FlutterForegroundTaskController.of('locationTracker'); +locationCtrl.init( + androidNotificationOptions: AndroidNotificationOptions( + channelId: 'location_channel', + channelName: 'Location Tracker', + ), + iosNotificationOptions: const IOSNotificationOptions(), + foregroundTaskOptions: ForegroundTaskOptions( + eventAction: ForegroundTaskEventAction.repeat(5000), + ), +); + +await locationCtrl.startService( + notificationTitle: 'Tracking', + notificationText: 'Running...', + callback: locationCallback, +); +``` + +This requires a small Kotlin subclass and `AndroidManifest.xml` entry per service. The existing single-service API is fully unchanged. For the complete setup guide, see [multiple_services.md](documentation/multiple_services.md). + ### :baby_chick: iOS You can also run `flutter_foreground_task` on the iOS platform. However, it has the following limitations. diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/FlutterForegroundTaskPlugin.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/FlutterForegroundTaskPlugin.kt index af10d832..81d3c655 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/FlutterForegroundTaskPlugin.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/FlutterForegroundTaskPlugin.kt @@ -1,8 +1,10 @@ package com.pravera.flutter_foreground_task import android.content.Intent -import com.pravera.flutter_foreground_task.service.ForegroundService +import com.pravera.flutter_foreground_task.service.FlutterForegroundServiceBase import com.pravera.flutter_foreground_task.service.ForegroundServiceManager +import com.pravera.flutter_foreground_task.service.ForegroundServiceRuntime +import com.pravera.flutter_foreground_task.service.FlutterForegroundServiceRegistry import com.pravera.flutter_foreground_task.service.NotificationPermissionManager import com.pravera.flutter_foreground_task.service.ServiceProvider import io.flutter.embedding.engine.plugins.FlutterPlugin @@ -13,12 +15,24 @@ import io.flutter.plugin.common.PluginRegistry.NewIntentListener /** FlutterForegroundTaskPlugin */ class FlutterForegroundTaskPlugin : FlutterPlugin, ActivityAware, ServiceProvider, NewIntentListener { companion object { + /** Add a lifecycle listener for the default service. */ fun addTaskLifecycleListener(listener: FlutterForegroundTaskLifecycleListener) { - ForegroundService.addTaskLifecycleListener(listener) + addTaskLifecycleListener(FlutterForegroundServiceRegistry.DEFAULT_ID, listener) } + /** Add a lifecycle listener for a specific service id. */ + fun addTaskLifecycleListener(serviceId: String, listener: FlutterForegroundTaskLifecycleListener) { + ForegroundServiceRuntime.addTaskLifecycleListener(serviceId, listener) + } + + /** Remove a lifecycle listener from the default service. */ fun removeTaskLifecycleListener(listener: FlutterForegroundTaskLifecycleListener) { - ForegroundService.removeTaskLifecycleListener(listener) + removeTaskLifecycleListener(FlutterForegroundServiceRegistry.DEFAULT_ID, listener) + } + + /** Remove a lifecycle listener from a specific service id. */ + fun removeTaskLifecycleListener(serviceId: String, listener: FlutterForegroundTaskLifecycleListener) { + ForegroundServiceRuntime.removeTaskLifecycleListener(serviceId, listener) } } @@ -29,6 +43,11 @@ class FlutterForegroundTaskPlugin : FlutterPlugin, ActivityAware, ServiceProvide private lateinit var methodCallHandler: MethodCallHandlerImpl override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + // The default service is registered inside FlutterForegroundServiceRegistry's + // static initializer so that it is available at cold boot, before any Flutter + // engine has attached. We just need to make sure the registry class is loaded. + FlutterForegroundServiceRegistry.registeredIds() + notificationPermissionManager = NotificationPermissionManager() foregroundServiceManager = ForegroundServiceManager() @@ -50,7 +69,7 @@ class FlutterForegroundTaskPlugin : FlutterPlugin, ActivityAware, ServiceProvide activityBinding = binding val intent = binding.activity.intent - ForegroundService.handleNotificationContentIntent(intent) + FlutterForegroundServiceBase.handleNotificationContentIntent(intent) } override fun onDetachedFromActivityForConfigChanges() { @@ -70,7 +89,7 @@ class FlutterForegroundTaskPlugin : FlutterPlugin, ActivityAware, ServiceProvide } override fun onNewIntent(intent: Intent): Boolean { - ForegroundService.handleNotificationContentIntent(intent) + FlutterForegroundServiceBase.handleNotificationContentIntent(intent) return true } diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/MethodCallHandlerImpl.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/MethodCallHandlerImpl.kt index f8e8efd8..177c3247 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/MethodCallHandlerImpl.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/MethodCallHandlerImpl.kt @@ -6,6 +6,7 @@ import android.content.Intent import com.pravera.flutter_foreground_task.errors.ActivityNotAttachedException import com.pravera.flutter_foreground_task.models.NotificationPermission +import com.pravera.flutter_foreground_task.service.FlutterForegroundServiceRegistry import com.pravera.flutter_foreground_task.service.NotificationPermissionCallback import com.pravera.flutter_foreground_task.service.ServiceProvider import com.pravera.flutter_foreground_task.utils.ErrorHandleUtils @@ -29,6 +30,12 @@ class MethodCallHandlerImpl(private val context: Context, private val provider: private var methodCodes: MutableMap = mutableMapOf() private var methodResults: MutableMap = mutableMapOf() + private fun extractServiceId(args: Any?): String { + val map = args as? Map<*, *> + return map?.get("serviceId") as? String + ?: FlutterForegroundServiceRegistry.DEFAULT_ID + } + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { val args = call.arguments try { @@ -56,29 +63,46 @@ class MethodCallHandlerImpl(private val context: Context, private val provider: } "startService" -> { - provider.getForegroundServiceManager().start(context, args) + val serviceId = extractServiceId(args) + provider.getForegroundServiceManager().start(context, serviceId, args) result.success(true) } "restartService" -> { - provider.getForegroundServiceManager().restart(context) + val serviceId = extractServiceId(args) + provider.getForegroundServiceManager().restart(context, serviceId) result.success(true) } "updateService" -> { - provider.getForegroundServiceManager().update(context, args) + val serviceId = extractServiceId(args) + provider.getForegroundServiceManager().update(context, serviceId, args) result.success(true) } "stopService" -> { - provider.getForegroundServiceManager().stop(context) + val serviceId = extractServiceId(args) + provider.getForegroundServiceManager().stop(context, serviceId) result.success(true) } - "sendData" -> provider.getForegroundServiceManager().sendData(args) + "sendData" -> { + val map = args as? Map<*, *> + if (map != null) { + val serviceId = map["serviceId"] as? String + ?: FlutterForegroundServiceRegistry.DEFAULT_ID + val data = map["data"] + provider.getForegroundServiceManager().sendData(serviceId, data) + } else { + provider.getForegroundServiceManager().sendData( + FlutterForegroundServiceRegistry.DEFAULT_ID, args) + } + } - "isRunningService" -> - result.success(provider.getForegroundServiceManager().isRunningService()) + "isRunningService" -> { + val serviceId = extractServiceId(args) + result.success(provider.getForegroundServiceManager().isRunningService(serviceId)) + } "attachedActivity" -> result.success(activity != null) diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/ForegroundServiceStatus.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/ForegroundServiceStatus.kt index 183baa30..983f36a5 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/ForegroundServiceStatus.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/ForegroundServiceStatus.kt @@ -2,13 +2,14 @@ package com.pravera.flutter_foreground_task.models import android.content.Context import com.pravera.flutter_foreground_task.PreferencesKey as PrefsKey +import com.pravera.flutter_foreground_task.service.FlutterForegroundServiceRegistry import com.pravera.flutter_foreground_task.storage.ForegroundTaskStorageProvider data class ForegroundServiceStatus(val action: String) { companion object { - fun getData(context: Context): ForegroundServiceStatus { + fun getData(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID): ForegroundServiceStatus { val prefs = ForegroundTaskStorageProvider.getPreferences( - context, PrefsKey.FOREGROUND_SERVICE_STATUS_PREFS) + context, serviceId, PrefsKey.FOREGROUND_SERVICE_STATUS_PREFS) val action = prefs.getString(PrefsKey.FOREGROUND_SERVICE_ACTION, null) ?: ForegroundServiceAction.API_STOP @@ -16,9 +17,9 @@ data class ForegroundServiceStatus(val action: String) { return ForegroundServiceStatus(action = action) } - fun setData(context: Context, action: String) { + fun setData(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID, action: String) { val prefs = ForegroundTaskStorageProvider.getPreferences( - context, PrefsKey.FOREGROUND_SERVICE_STATUS_PREFS) + context, serviceId, PrefsKey.FOREGROUND_SERVICE_STATUS_PREFS) with(prefs.edit()) { putString(PrefsKey.FOREGROUND_SERVICE_ACTION, action) diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/ForegroundServiceTypes.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/ForegroundServiceTypes.kt index 51d1e6a3..253477c5 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/ForegroundServiceTypes.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/ForegroundServiceTypes.kt @@ -4,13 +4,14 @@ import android.content.Context import android.content.pm.ServiceInfo import android.os.Build import com.pravera.flutter_foreground_task.PreferencesKey +import com.pravera.flutter_foreground_task.service.FlutterForegroundServiceRegistry import com.pravera.flutter_foreground_task.storage.ForegroundTaskStorageProvider data class ForegroundServiceTypes(val value: Int) { companion object { - fun getData(context: Context): ForegroundServiceTypes { + fun getData(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID): ForegroundServiceTypes { val prefs = ForegroundTaskStorageProvider.getPreferences( - context, PreferencesKey.FOREGROUND_SERVICE_TYPES_PREFS) + context, serviceId, PreferencesKey.FOREGROUND_SERVICE_TYPES_PREFS) val value = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { prefs.getInt(PreferencesKey.FOREGROUND_SERVICE_TYPES, ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST) @@ -21,9 +22,9 @@ data class ForegroundServiceTypes(val value: Int) { return ForegroundServiceTypes(value = value) } - fun setData(context: Context, map: Map<*, *>?) { + fun setData(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID, map: Map<*, *>?) { val prefs = ForegroundTaskStorageProvider.getPreferences( - context, PreferencesKey.FOREGROUND_SERVICE_TYPES_PREFS) + context, serviceId, PreferencesKey.FOREGROUND_SERVICE_TYPES_PREFS) var value = 0 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -46,9 +47,9 @@ data class ForegroundServiceTypes(val value: Int) { } } - fun clearData(context: Context) { + fun clearData(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID) { val prefs = ForegroundTaskStorageProvider.getPreferences( - context, PreferencesKey.FOREGROUND_SERVICE_TYPES_PREFS) + context, serviceId, PreferencesKey.FOREGROUND_SERVICE_TYPES_PREFS) with(prefs.edit()) { clear() diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/ForegroundTaskData.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/ForegroundTaskData.kt index 1abfad39..547d040d 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/ForegroundTaskData.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/ForegroundTaskData.kt @@ -2,13 +2,14 @@ package com.pravera.flutter_foreground_task.models import android.content.Context import com.pravera.flutter_foreground_task.PreferencesKey as PrefsKey +import com.pravera.flutter_foreground_task.service.FlutterForegroundServiceRegistry import com.pravera.flutter_foreground_task.storage.ForegroundTaskStorageProvider data class ForegroundTaskData(val callbackHandle: Long?) { companion object { - fun getData(context: Context): ForegroundTaskData { + fun getData(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID): ForegroundTaskData { val prefs = ForegroundTaskStorageProvider.getPreferences( - context, PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS) + context, serviceId, PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS) val callbackHandle = if (prefs.contains(PrefsKey.CALLBACK_HANDLE)) { prefs.getLong(PrefsKey.CALLBACK_HANDLE, 0L) @@ -19,9 +20,9 @@ data class ForegroundTaskData(val callbackHandle: Long?) { return ForegroundTaskData(callbackHandle = callbackHandle) } - fun setData(context: Context, map: Map<*, *>?) { + fun setData(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID, map: Map<*, *>?) { val prefs = ForegroundTaskStorageProvider.getPreferences( - context, PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS) + context, serviceId, PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS) val callbackHandle = "${map?.get(PrefsKey.CALLBACK_HANDLE)}".toLongOrNull() @@ -32,9 +33,9 @@ data class ForegroundTaskData(val callbackHandle: Long?) { } } - fun updateData(context: Context, map: Map<*, *>?) { + fun updateData(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID, map: Map<*, *>?) { val prefs = ForegroundTaskStorageProvider.getPreferences( - context, PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS) + context, serviceId, PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS) val callbackHandle = "${map?.get(PrefsKey.CALLBACK_HANDLE)}".toLongOrNull() @@ -44,9 +45,9 @@ data class ForegroundTaskData(val callbackHandle: Long?) { } } - fun clearData(context: Context) { + fun clearData(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID) { val prefs = ForegroundTaskStorageProvider.getPreferences( - context, PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS) + context, serviceId, PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS) with(prefs.edit()) { clear() diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/ForegroundTaskOptions.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/ForegroundTaskOptions.kt index ef3d8e59..f5a57b4a 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/ForegroundTaskOptions.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/ForegroundTaskOptions.kt @@ -3,6 +3,7 @@ package com.pravera.flutter_foreground_task.models import android.content.Context import org.json.JSONObject import com.pravera.flutter_foreground_task.PreferencesKey as PrefsKey +import com.pravera.flutter_foreground_task.service.FlutterForegroundServiceRegistry import com.pravera.flutter_foreground_task.storage.ForegroundTaskStorageProvider data class ForegroundTaskOptions( @@ -15,9 +16,9 @@ data class ForegroundTaskOptions( val stopWithTask: Boolean? ) { companion object { - fun getData(context: Context): ForegroundTaskOptions { + fun getData(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID): ForegroundTaskOptions { val prefs = ForegroundTaskStorageProvider.getPreferences( - context, PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS) + context, serviceId, PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS) val eventActionJsonString = prefs.getString(PrefsKey.TASK_EVENT_ACTION, null) val eventAction: ForegroundTaskEventAction = if (eventActionJsonString != null) { @@ -55,9 +56,9 @@ data class ForegroundTaskOptions( ) } - fun setData(context: Context, map: Map<*, *>?) { + fun setData(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID, map: Map<*, *>?) { val prefs = ForegroundTaskStorageProvider.getPreferences( - context, PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS) + context, serviceId, PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS) val eventActionJson = map?.get(PrefsKey.TASK_EVENT_ACTION) as? Map<*, *> var eventActionJsonString: String? = null @@ -84,9 +85,9 @@ data class ForegroundTaskOptions( } } - fun updateData(context: Context, map: Map<*, *>?) { + fun updateData(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID, map: Map<*, *>?) { val prefs = ForegroundTaskStorageProvider.getPreferences( - context, PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS) + context, serviceId, PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS) val eventActionJson = map?.get(PrefsKey.TASK_EVENT_ACTION) as? Map<*, *> var eventActionJsonString: String? = null @@ -113,9 +114,9 @@ data class ForegroundTaskOptions( } } - fun clearData(context: Context) { + fun clearData(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID) { val prefs = ForegroundTaskStorageProvider.getPreferences( - context, PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS) + context, serviceId, PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS) with(prefs.edit()) { clear() @@ -123,4 +124,4 @@ data class ForegroundTaskOptions( } } } -} \ No newline at end of file +} diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationContent.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationContent.kt index 76c23a65..bf5e6831 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationContent.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationContent.kt @@ -2,6 +2,7 @@ package com.pravera.flutter_foreground_task.models import android.content.Context import com.pravera.flutter_foreground_task.PreferencesKey as PrefsKey +import com.pravera.flutter_foreground_task.service.FlutterForegroundServiceRegistry import com.pravera.flutter_foreground_task.storage.ForegroundTaskStorageProvider import org.json.JSONArray import org.json.JSONObject @@ -14,9 +15,9 @@ data class NotificationContent( val initialRoute: String? ) { companion object { - fun getData(context: Context): NotificationContent { + fun getData(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID): NotificationContent { val prefs = ForegroundTaskStorageProvider.getPreferences( - context, PrefsKey.NOTIFICATION_OPTIONS_PREFS) + context, serviceId, PrefsKey.NOTIFICATION_OPTIONS_PREFS) val title = prefs.getString(PrefsKey.NOTIFICATION_CONTENT_TITLE, null) ?: "" val text = prefs.getString(PrefsKey.NOTIFICATION_CONTENT_TEXT, null) ?: "" @@ -48,9 +49,9 @@ data class NotificationContent( ) } - fun setData(context: Context, map: Map<*, *>?) { + fun setData(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID, map: Map<*, *>?) { val prefs = ForegroundTaskStorageProvider.getPreferences( - context, PrefsKey.NOTIFICATION_OPTIONS_PREFS) + context, serviceId, PrefsKey.NOTIFICATION_OPTIONS_PREFS) val title = map?.get(PrefsKey.NOTIFICATION_CONTENT_TITLE) as? String ?: "" val text = map?.get(PrefsKey.NOTIFICATION_CONTENT_TEXT) as? String ?: "" @@ -79,9 +80,9 @@ data class NotificationContent( } } - fun updateData(context: Context, map: Map<*, *>?) { + fun updateData(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID, map: Map<*, *>?) { val prefs = ForegroundTaskStorageProvider.getPreferences( - context, PrefsKey.NOTIFICATION_OPTIONS_PREFS) + context, serviceId, PrefsKey.NOTIFICATION_OPTIONS_PREFS) val title = map?.get(PrefsKey.NOTIFICATION_CONTENT_TITLE) as? String val text = map?.get(PrefsKey.NOTIFICATION_CONTENT_TEXT) as? String @@ -110,9 +111,9 @@ data class NotificationContent( } } - fun clearData(context: Context) { + fun clearData(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID) { val prefs = ForegroundTaskStorageProvider.getPreferences( - context, PrefsKey.NOTIFICATION_OPTIONS_PREFS) + context, serviceId, PrefsKey.NOTIFICATION_OPTIONS_PREFS) with(prefs.edit()) { clear() @@ -120,4 +121,4 @@ data class NotificationContent( } } } -} \ No newline at end of file +} diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationOptions.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationOptions.kt index 42f64acc..fd1093a3 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationOptions.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationOptions.kt @@ -2,6 +2,7 @@ package com.pravera.flutter_foreground_task.models import android.content.Context import com.pravera.flutter_foreground_task.PreferencesKey as PrefsKey +import com.pravera.flutter_foreground_task.service.FlutterForegroundServiceRegistry import com.pravera.flutter_foreground_task.storage.ForegroundTaskStorageProvider data class NotificationOptions( @@ -19,11 +20,11 @@ data class NotificationOptions( val visibility: Int ) { companion object { - fun getData(context: Context): NotificationOptions { + fun getData(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID): NotificationOptions { val prefs = ForegroundTaskStorageProvider.getPreferences( - context, PrefsKey.NOTIFICATION_OPTIONS_PREFS) + context, serviceId, PrefsKey.NOTIFICATION_OPTIONS_PREFS) - val serviceId = prefs.getInt(PrefsKey.SERVICE_ID, prefs.getInt(PrefsKey.NOTIFICATION_ID, 1000)) + val svcId = prefs.getInt(PrefsKey.SERVICE_ID, prefs.getInt(PrefsKey.NOTIFICATION_ID, 1000)) val channelId = prefs.getString(PrefsKey.NOTIFICATION_CHANNEL_ID, null) ?: "foreground_service" val channelName = prefs.getString(PrefsKey.NOTIFICATION_CHANNEL_NAME, null) ?: "Foreground Service" val channelDesc = prefs.getString(PrefsKey.NOTIFICATION_CHANNEL_DESC, null) @@ -37,7 +38,7 @@ data class NotificationOptions( val visibility = prefs.getInt(PrefsKey.VISIBILITY, 1) return NotificationOptions( - serviceId = serviceId, + serviceId = svcId, channelId = channelId, channelName = channelName, channelDescription = channelDesc, @@ -52,11 +53,11 @@ data class NotificationOptions( ) } - fun setData(context: Context, map: Map<*, *>?) { + fun setData(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID, map: Map<*, *>?) { val prefs = ForegroundTaskStorageProvider.getPreferences( - context, PrefsKey.NOTIFICATION_OPTIONS_PREFS) + context, serviceId, PrefsKey.NOTIFICATION_OPTIONS_PREFS) - val serviceId = map?.get(PrefsKey.SERVICE_ID) as? Int + val svcId = map?.get(PrefsKey.SERVICE_ID) as? Int ?: map?.get(PrefsKey.NOTIFICATION_ID) as? Int ?: 1000 val channelId = map?.get(PrefsKey.NOTIFICATION_CHANNEL_ID) as? String @@ -72,7 +73,7 @@ data class NotificationOptions( val visibility = map?.get(PrefsKey.VISIBILITY) as? Int ?: 1 with(prefs.edit()) { - putInt(PrefsKey.SERVICE_ID, serviceId) + putInt(PrefsKey.SERVICE_ID, svcId) putString(PrefsKey.NOTIFICATION_CHANNEL_ID, channelId) putString(PrefsKey.NOTIFICATION_CHANNEL_NAME, channelName) putString(PrefsKey.NOTIFICATION_CHANNEL_DESC, channelDesc) @@ -88,9 +89,9 @@ data class NotificationOptions( } } - fun clearData(context: Context) { + fun clearData(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID) { val prefs = ForegroundTaskStorageProvider.getPreferences( - context, PrefsKey.NOTIFICATION_OPTIONS_PREFS) + context, serviceId, PrefsKey.NOTIFICATION_OPTIONS_PREFS) with(prefs.edit()) { clear() diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/FlutterForegroundServiceBase.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/FlutterForegroundServiceBase.kt new file mode 100644 index 00000000..8561b41c --- /dev/null +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/FlutterForegroundServiceBase.kt @@ -0,0 +1,602 @@ +package com.pravera.flutter_foreground_task.service + +import android.annotation.SuppressLint +import android.app.* +import android.content.* +import android.content.pm.PackageManager +import android.graphics.Color +import android.net.wifi.WifiManager +import android.os.* +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.pravera.flutter_foreground_task.RequestCode +import com.pravera.flutter_foreground_task.models.* +import com.pravera.flutter_foreground_task.utils.* +import com.pravera.flutter_foreground_task.PreferencesKey as PrefsKey +import com.pravera.flutter_foreground_task.storage.ForegroundTaskStorageProvider +import kotlinx.coroutines.flow.update + +/** + * Abstract base class for foreground services managed by the plugin. + * + * Subclasses must override [serviceId] with a unique identifier that + * matches the id registered in [FlutterForegroundServiceRegistry]. + * + * The built-in [ForegroundService] uses `"default"` and is automatically + * registered by the plugin. + */ +abstract class FlutterForegroundServiceBase : Service() { + companion object { + private val TAG = FlutterForegroundServiceBase::class.java.simpleName + + private const val ACTION_NOTIFICATION_PRESSED = "onNotificationPressed" + private const val ACTION_NOTIFICATION_DISMISSED = "onNotificationDismissed" + private const val ACTION_NOTIFICATION_BUTTON_PRESSED = "onNotificationButtonPressed" + private const val ACTION_RECEIVE_DATA = "onReceiveData" + private const val INTENT_DATA_NAME = "intentData" + const val EXTRA_SERVICE_ID = "com.pravera.flutter_foreground_task.SERVICE_ID" + + fun handleNotificationContentIntent(intent: Intent?) { + if (intent == null) return + + try { + val isLaunchIntent = (intent.action == Intent.ACTION_MAIN) && + (intent.categories?.contains(Intent.CATEGORY_LAUNCHER) == true) + if (!isLaunchIntent) return + + val data = intent.getStringExtra(INTENT_DATA_NAME) + val sid = intent.getStringExtra(EXTRA_SERVICE_ID) + ?: FlutterForegroundServiceRegistry.DEFAULT_ID + if (data == ACTION_NOTIFICATION_PRESSED) { + ForegroundServiceRuntime.task(sid)?.invokeMethod(data, null) + } + } catch (e: Exception) { + Log.e(TAG, e.message, e) + } + } + + fun sendData(serviceId: String, data: Any?) { + if (ForegroundServiceRuntime.isRunning(serviceId)) { + ForegroundServiceRuntime.task(serviceId)?.invokeMethod(ACTION_RECEIVE_DATA, data) + } + } + } + + abstract val serviceId: String + + private lateinit var foregroundServiceStatus: ForegroundServiceStatus + private lateinit var foregroundServiceTypes: ForegroundServiceTypes + private lateinit var foregroundTaskOptions: ForegroundTaskOptions + private lateinit var foregroundTaskData: ForegroundTaskData + private lateinit var notificationOptions: NotificationOptions + private lateinit var notificationContent: NotificationContent + private var prevForegroundTaskOptions: ForegroundTaskOptions? = null + private var prevForegroundTaskData: ForegroundTaskData? = null + private var prevNotificationOptions: NotificationOptions? = null + private var prevNotificationContent: NotificationContent? = null + + private var wakeLock: PowerManager.WakeLock? = null + private var wifiLock: WifiManager.WifiLock? = null + + private var isTimeout: Boolean = false + + private var broadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent == null) return + + try { + val iPackageName = intent.`package` + val cPackageName = packageName + if (iPackageName != cPackageName) { + Log.d(TAG, "This intent has not sent from the current package. ($iPackageName != $cPackageName)") + return + } + + val action = intent.action ?: return + val data = intent.getStringExtra(INTENT_DATA_NAME) + ForegroundServiceRuntime.task(serviceId)?.invokeMethod(action, data) + } catch (e: Exception) { + Log.e(TAG, e.message, e) + } + } + } + + override fun onCreate() { + super.onCreate() + registerBroadcastReceiver() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + isTimeout = false + loadDataFromPreferences() + + val prefs = ForegroundTaskStorageProvider.getPreferences(this, serviceId, PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS) + if (prefs.contains(PrefsKey.STOP_WITH_TASK) && prefs.getBoolean(PrefsKey.STOP_WITH_TASK, false)) { + (application as? Application)?.let { + TrackVisibilityUtils.install(it) { + stopForegroundService() + } + } + } + + var action = foregroundServiceStatus.action + val isSetStopWithTaskFlag = ForegroundServiceUtils.isSetStopWithTaskFlag(this, serviceId) + + if (action == ForegroundServiceAction.API_STOP) { + stopForegroundService() + return START_NOT_STICKY + } + + try { + if (intent == null) { + ForegroundServiceStatus.setData(this, serviceId, ForegroundServiceAction.RESTART) + foregroundServiceStatus = ForegroundServiceStatus.getData(this, serviceId) + action = foregroundServiceStatus.action + } + + when (action) { + ForegroundServiceAction.API_START, + ForegroundServiceAction.API_RESTART -> { + startForegroundService() + createForegroundTask() + } + ForegroundServiceAction.API_UPDATE -> { + updateNotification() + val prevCallbackHandle = prevForegroundTaskData?.callbackHandle + val currCallbackHandle = foregroundTaskData.callbackHandle + if (prevCallbackHandle != currCallbackHandle) { + createForegroundTask() + } else { + val prevEventAction = prevForegroundTaskOptions?.eventAction + val currEventAction = foregroundTaskOptions.eventAction + if (prevEventAction != currEventAction) { + updateForegroundTask() + } + } + } + ForegroundServiceAction.REBOOT, + ForegroundServiceAction.RESTART -> { + startForegroundService() + createForegroundTask() + Log.d(TAG, "The service($serviceId) has been restarted by Android OS.") + } + } + } catch (e: Exception) { + Log.e(TAG, e.message, e) + stopForegroundService() + } + + return if (isSetStopWithTaskFlag) { + START_NOT_STICKY + } else { + START_STICKY + } + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + override fun onDestroy() { + super.onDestroy() + val isTimeout = this.isTimeout + destroyForegroundTask(isTimeout) + stopForegroundService() + unregisterBroadcastReceiver() + + var isCorrectlyStopped = false + if (::foregroundServiceStatus.isInitialized) { + isCorrectlyStopped = foregroundServiceStatus.isCorrectlyStopped() + } + + if (::foregroundTaskOptions.isInitialized) { + val allowAutoRestart = foregroundTaskOptions.allowAutoRestart + if (allowAutoRestart && !isCorrectlyStopped && !ForegroundServiceUtils.isSetStopWithTaskFlag(this, serviceId)) { + Log.e(TAG, "The service($serviceId) will be restarted after 5 seconds because it wasn't properly stopped.") + RestartReceiver.setRestartAlarm(this, serviceId, 5000) + } + } + } + + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + if (ForegroundServiceUtils.isSetStopWithTaskFlag(this, serviceId)) { + stopSelf() + } else { + RestartReceiver.setRestartAlarm(this, serviceId, 1000) + } + } + + override fun onTimeout(startId: Int) { + super.onTimeout(startId) + isTimeout = true + stopForegroundService() + Log.e(TAG, "The service(id: $startId, serviceId: $serviceId) timed out and was terminated by the system.") + } + + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + override fun onTimeout(startId: Int, fgsType: Int) { + super.onTimeout(startId, fgsType) + isTimeout = true + stopForegroundService() + Log.e(TAG, "The service(id: $startId, serviceId: $serviceId) timed out and was terminated by the system.") + } + + private fun loadDataFromPreferences() { + foregroundServiceStatus = ForegroundServiceStatus.getData(applicationContext, serviceId) + foregroundServiceTypes = ForegroundServiceTypes.getData(applicationContext, serviceId) + if (::foregroundTaskOptions.isInitialized) { prevForegroundTaskOptions = foregroundTaskOptions } + foregroundTaskOptions = ForegroundTaskOptions.getData(applicationContext, serviceId) + if (::foregroundTaskData.isInitialized) { prevForegroundTaskData = foregroundTaskData } + foregroundTaskData = ForegroundTaskData.getData(applicationContext, serviceId) + if (::notificationOptions.isInitialized) { prevNotificationOptions = notificationOptions } + notificationOptions = NotificationOptions.getData(applicationContext, serviceId) + if (::notificationContent.isInitialized) { prevNotificationContent = notificationContent } + notificationContent = NotificationContent.getData(applicationContext, serviceId) + } + + private fun registerBroadcastReceiver() { + val intentFilter = IntentFilter().apply { + addAction(ACTION_NOTIFICATION_BUTTON_PRESSED) + addAction(ACTION_NOTIFICATION_PRESSED) + addAction(ACTION_NOTIFICATION_DISMISSED) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(broadcastReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(broadcastReceiver, intentFilter) + } + } + + private fun unregisterBroadcastReceiver() { + unregisterReceiver(broadcastReceiver) + } + + @SuppressLint("WrongConstant", "SuspiciousIndentation") + private fun startForegroundService() { + RestartReceiver.cancelRestartAlarm(this, serviceId) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNotificationChannel() + } + + val svcId = notificationOptions.serviceId + val notification = createNotification() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(svcId, notification, foregroundServiceTypes.value) + } else { + startForeground(svcId, notification) + } + + releaseLockMode() + acquireLockMode() + + ForegroundServiceRuntime.stateFlow(serviceId).update { true } + } + + private fun stopForegroundService() { + RestartReceiver.cancelRestartAlarm(this, serviceId) + + releaseLockMode() + stopForeground(true) + stopSelf() + + ForegroundServiceRuntime.stateFlow(serviceId).update { false } + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createNotificationChannel() { + val channelId = notificationOptions.channelId + val channelName = notificationOptions.channelName + val channelDesc = notificationOptions.channelDescription + val channelImportance = notificationOptions.channelImportance + + val nm = getSystemService(NotificationManager::class.java) + if (nm.getNotificationChannel(channelId) == null) { + val channel = NotificationChannel(channelId, channelName, channelImportance).apply { + if (channelDesc != null) { + description = channelDesc + } + enableVibration(notificationOptions.enableVibration) + if (!notificationOptions.playSound) { + setSound(null, null) + } + setShowBadge(notificationOptions.showBadge) + } + nm.createNotificationChannel(channel) + } + } + + private fun createNotification(): Notification { + val icon = notificationContent.icon + val iconResId = getIconResId(icon) + val iconBackgroundColor = icon?.backgroundColorRgb?.let(::getRgbColor) + + val contentIntent = getContentIntent() + val deleteIntent = getDeleteIntent() + + var needsRebuildButtons = false + val prevButtons = prevNotificationContent?.buttons + val currButtons = notificationContent.buttons + if (prevButtons != null) { + if (prevButtons.size != currButtons.size) { + needsRebuildButtons = true + } else { + for (i in currButtons.indices) { + if (prevButtons[i] != currButtons[i]) { + needsRebuildButtons = true + break + } + } + } + } else { + needsRebuildButtons = true + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val builder = Notification.Builder(this, notificationOptions.channelId) + builder.setOngoing(true) + builder.setShowWhen(notificationOptions.showWhen) + builder.setSmallIcon(iconResId) + builder.setContentIntent(contentIntent) + builder.setContentTitle(notificationContent.title) + builder.setContentText(notificationContent.text) + builder.style = Notification.BigTextStyle() + builder.setVisibility(notificationOptions.visibility) + builder.setOnlyAlertOnce(notificationOptions.onlyAlertOnce) + if (iconBackgroundColor != null) { + builder.setColor(iconBackgroundColor) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + builder.setDeleteIntent(deleteIntent) + } + + val actions = buildNotificationActions(currButtons, needsRebuildButtons) + for (action in actions) { + builder.addAction(action) + } + + return builder.build() + } else { + val builder = NotificationCompat.Builder(this, notificationOptions.channelId) + builder.setOngoing(true) + builder.setShowWhen(notificationOptions.showWhen) + builder.setSmallIcon(iconResId) + builder.setContentIntent(contentIntent) + builder.setContentTitle(notificationContent.title) + builder.setContentText(notificationContent.text) + builder.setStyle(NotificationCompat.BigTextStyle().bigText(notificationContent.text)) + builder.setVisibility(notificationOptions.visibility) + builder.setOnlyAlertOnce(notificationOptions.onlyAlertOnce) + if (iconBackgroundColor != null) { + builder.color = iconBackgroundColor + } + if (!notificationOptions.enableVibration) { + builder.setVibrate(longArrayOf(0L)) + } + if (!notificationOptions.playSound) { + builder.setSound(null) + } + builder.priority = notificationOptions.priority + + val actions = buildNotificationCompatActions(currButtons, needsRebuildButtons) + for (action in actions) { + builder.addAction(action) + } + + return builder.build() + } + } + + private fun updateNotification() { + val svcId = notificationOptions.serviceId + val notification = createNotification() + val nm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + getSystemService(NotificationManager::class.java) + } else { + ContextCompat.getSystemService(this, NotificationManager::class.java) + } + nm?.notify(svcId, notification) + } + + @SuppressLint("WakelockTimeout") + private fun acquireLockMode() { + if (foregroundTaskOptions.allowWakeLock && (wakeLock == null || wakeLock?.isHeld == false)) { + wakeLock = + (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager).run { + newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "ForegroundService:WakeLock:$serviceId").apply { + setReferenceCounted(false) + acquire() + } + } + } + + if (foregroundTaskOptions.allowWifiLock && (wifiLock == null || wifiLock?.isHeld == false)) { + wifiLock = + (applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager).run { + createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "ForegroundService:WifiLock:$serviceId").apply { + setReferenceCounted(false) + acquire() + } + } + } + } + + private fun releaseLockMode() { + wakeLock?.let { + if (it.isHeld) { + it.release() + wakeLock = null + } + } + + wifiLock?.let { + if (it.isHeld) { + it.release() + wifiLock = null + } + } + } + + private fun createForegroundTask() { + destroyForegroundTask() + + val task = ForegroundTask( + context = this, + serviceId = serviceId, + serviceStatus = foregroundServiceStatus, + taskData = foregroundTaskData, + taskEventAction = foregroundTaskOptions.eventAction, + taskLifecycleListener = ForegroundServiceRuntime.listeners(serviceId) + ) + ForegroundServiceRuntime.setTask(serviceId, task) + } + + private fun updateForegroundTask() { + ForegroundServiceRuntime.task(serviceId)?.update(taskEventAction = foregroundTaskOptions.eventAction) + } + + private fun destroyForegroundTask(isTimeout: Boolean = false) { + ForegroundServiceRuntime.task(serviceId)?.destroy(isTimeout) + ForegroundServiceRuntime.setTask(serviceId, null) + } + + private fun getIconResId(icon: NotificationIcon?): Int { + try { + val packageManager = applicationContext.packageManager + val packageName = applicationContext.packageName + val appInfo = packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA) + + if (icon == null) { + return appInfo.icon + } + + val metaData = appInfo.metaData + if (metaData != null) { + return metaData.getInt(icon.metaDataName) + } + + return 0 + } catch (e: Exception) { + Log.e(TAG, "getIconResId($icon)", e) + return 0 + } + } + + private fun getContentIntent(): PendingIntent { + val packageManager = applicationContext.packageManager + val packageName = applicationContext.packageName + val intent = packageManager.getLaunchIntentForPackage(packageName)?.apply { + putExtra(INTENT_DATA_NAME, ACTION_NOTIFICATION_PRESSED) + putExtra(EXTRA_SERVICE_ID, serviceId) + + val initialRoute = notificationContent.initialRoute + if (initialRoute != null) { + putExtra("route", initialRoute) + } + } + + var flags = PendingIntent.FLAG_UPDATE_CURRENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + flags = flags or PendingIntent.FLAG_IMMUTABLE + } + + val requestCode = RequestCode.NOTIFICATION_PRESSED xor serviceId.hashCode() + return PendingIntent.getActivity(this, requestCode, intent, flags) + } + + private fun getDeleteIntent(): PendingIntent { + val intent = Intent(ACTION_NOTIFICATION_DISMISSED).apply { + setPackage(packageName) + } + + var flags = PendingIntent.FLAG_UPDATE_CURRENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + flags = flags or PendingIntent.FLAG_IMMUTABLE + } + + val requestCode = RequestCode.NOTIFICATION_DISMISSED xor serviceId.hashCode() + return PendingIntent.getBroadcast(this, requestCode, intent, flags) + } + + private fun getRgbColor(rgb: String): Int? { + val rgbSet = rgb.split(",") + return if (rgbSet.size == 3) { + Color.rgb(rgbSet[0].toInt(), rgbSet[1].toInt(), rgbSet[2].toInt()) + } else { + null + } + } + + private fun getTextSpan(text: String, color: Int?): Spannable { + return if (color != null) { + SpannableString(text).apply { + setSpan(ForegroundColorSpan(color), 0, length, 0) + } + } else { + SpannableString(text) + } + } + + private fun buildNotificationActions( + buttons: List, + needsRebuild: Boolean = false + ): List { + val actions = mutableListOf() + for (i in buttons.indices) { + val intent = Intent(ACTION_NOTIFICATION_BUTTON_PRESSED).apply { + setPackage(packageName) + putExtra(INTENT_DATA_NAME, buttons[i].id) + } + var flags = PendingIntent.FLAG_IMMUTABLE + if (needsRebuild) { + flags = flags or PendingIntent.FLAG_CANCEL_CURRENT + } + val textColor = buttons[i].textColorRgb?.let(::getRgbColor) + val text = getTextSpan(buttons[i].text, textColor) + val pendingIntent = + PendingIntent.getBroadcast(this, i + 1, intent, flags) + val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Notification.Action.Builder(null, text, pendingIntent).build() + } else { + Notification.Action.Builder(0, text, pendingIntent).build() + } + actions.add(action) + } + + return actions + } + + private fun buildNotificationCompatActions( + buttons: List, + needsRebuild: Boolean = false + ): List { + val actions = mutableListOf() + for (i in buttons.indices) { + val intent = Intent(ACTION_NOTIFICATION_BUTTON_PRESSED).apply { + setPackage(packageName) + putExtra(INTENT_DATA_NAME, buttons[i].id) + } + var flags = PendingIntent.FLAG_IMMUTABLE + if (needsRebuild) { + flags = flags or PendingIntent.FLAG_CANCEL_CURRENT + } + val textColor = buttons[i].textColorRgb?.let(::getRgbColor) + val text = getTextSpan(buttons[i].text, textColor) + val pendingIntent = + PendingIntent.getBroadcast(this, i + 1, intent, flags) + val action = NotificationCompat.Action.Builder(0, text, pendingIntent).build() + actions.add(action) + } + + return actions + } +} diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/FlutterForegroundServiceRegistry.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/FlutterForegroundServiceRegistry.kt new file mode 100644 index 00000000..9f4e7d63 --- /dev/null +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/FlutterForegroundServiceRegistry.kt @@ -0,0 +1,63 @@ +package com.pravera.flutter_foreground_task.service + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import java.util.concurrent.ConcurrentHashMap + +/** + * Global registry that maps a `serviceId` to the concrete [Service] subclass + * that implements it. + * + * The default single-service (`"default"` -> [ForegroundService]) is + * registered in this object's static initializer so it is available as + * soon as the class is loaded. This guarantees [RebootReceiver] and + * [RestartReceiver] can resolve the default service at cold boot, before + * any Flutter engine has attached. + * + * Additional services must be registered from `Application.onCreate()`. + * + * A service will only actually be started if it is both registered here + * **and** declared as a `` in the `AndroidManifest.xml`. Use + * [isServiceDeclaredInManifest] to check at runtime. + */ +object FlutterForegroundServiceRegistry { + const val DEFAULT_ID = "default" + + private val services = ConcurrentHashMap>() + + init { + services[DEFAULT_ID] = ForegroundService::class.java + } + + fun register(id: String, cls: Class) { + services[id] = cls + } + + fun resolveClass(id: String): Class? = services[id] + + fun registeredIds(): Set = services.keys.toSet() + + fun resolveId(cls: Class<*>): String? = services.entries.firstOrNull { it.value == cls }?.key + + /** + * Returns `true` when [serviceId] maps to a service class that is + * declared in the app's `AndroidManifest.xml`. + * + * This lets callers skip services that are registered in code but + * not present in the manifest (e.g. the default [ForegroundService] + * when the app only uses custom service subclasses). + */ + fun isServiceDeclaredInManifest(context: Context, serviceId: String): Boolean { + val cls = resolveClass(serviceId) ?: return false + return try { + context.packageManager.getServiceInfo( + ComponentName(context, cls), + PackageManager.GET_META_DATA + ) + true + } catch (_: PackageManager.NameNotFoundException) { + false + } + } +} diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundService.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundService.kt index d1849913..38353550 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundService.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundService.kt @@ -1,617 +1,13 @@ package com.pravera.flutter_foreground_task.service -import android.annotation.SuppressLint -import android.app.* -import android.content.* -import android.content.pm.PackageManager -import android.graphics.Color -import android.net.wifi.WifiManager -import android.os.* -import android.text.Spannable -import android.text.SpannableString -import android.text.style.ForegroundColorSpan -import android.util.Log -import androidx.annotation.RequiresApi -import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat -import com.pravera.flutter_foreground_task.FlutterForegroundTaskLifecycleListener -import com.pravera.flutter_foreground_task.RequestCode -import com.pravera.flutter_foreground_task.models.* -import com.pravera.flutter_foreground_task.utils.* -import com.pravera.flutter_foreground_task.PreferencesKey as PrefsKey -import com.pravera.flutter_foreground_task.storage.ForegroundTaskStorageProvider -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update - /** - * A service class for implementing foreground service. + * The default foreground service class. + * + * This is the class that existing apps reference in their AndroidManifest: + * `android:name="com.pravera.flutter_foreground_task.service.ForegroundService"`. * - * @author Dev-hwang - * @version 1.0 + * It simply sets `serviceId` to [FlutterForegroundServiceRegistry.DEFAULT_ID]. */ -class ForegroundService : Service() { - companion object { - private val TAG = ForegroundService::class.java.simpleName - - private const val ACTION_NOTIFICATION_PRESSED = "onNotificationPressed" - private const val ACTION_NOTIFICATION_DISMISSED = "onNotificationDismissed" - private const val ACTION_NOTIFICATION_BUTTON_PRESSED = "onNotificationButtonPressed" - private const val ACTION_RECEIVE_DATA = "onReceiveData" - private const val INTENT_DATA_NAME = "intentData" - - private val _isRunningServiceState = MutableStateFlow(false) - val isRunningServiceState = _isRunningServiceState.asStateFlow() - - private var task: ForegroundTask? = null - private var taskLifecycleListeners = ForegroundTaskLifecycleListeners() - - fun addTaskLifecycleListener(listener: FlutterForegroundTaskLifecycleListener) { - taskLifecycleListeners.addListener(listener) - } - - fun removeTaskLifecycleListener(listener: FlutterForegroundTaskLifecycleListener) { - taskLifecycleListeners.removeListener(listener) - } - - fun handleNotificationContentIntent(intent: Intent?) { - if (intent == null) return - - try { - // Check if the given intent is a LaunchIntent. - val isLaunchIntent = (intent.action == Intent.ACTION_MAIN) && - (intent.categories?.contains(Intent.CATEGORY_LAUNCHER) == true) - if (!isLaunchIntent) return - - val data = intent.getStringExtra(INTENT_DATA_NAME) - if (data == ACTION_NOTIFICATION_PRESSED) { - task?.invokeMethod(data, null) - } - } catch (e: Exception) { - Log.e(TAG, e.message, e) - } - } - - fun sendData(data: Any?) { - if (isRunningServiceState.value) { - task?.invokeMethod(ACTION_RECEIVE_DATA, data) - } - } - } - - private lateinit var foregroundServiceStatus: ForegroundServiceStatus - private lateinit var foregroundServiceTypes: ForegroundServiceTypes - private lateinit var foregroundTaskOptions: ForegroundTaskOptions - private lateinit var foregroundTaskData: ForegroundTaskData - private lateinit var notificationOptions: NotificationOptions - private lateinit var notificationContent: NotificationContent - private var prevForegroundTaskOptions: ForegroundTaskOptions? = null - private var prevForegroundTaskData: ForegroundTaskData? = null - private var prevNotificationOptions: NotificationOptions? = null - private var prevNotificationContent: NotificationContent? = null - - private var wakeLock: PowerManager.WakeLock? = null - private var wifiLock: WifiManager.WifiLock? = null - - private var isTimeout: Boolean = false - - // A broadcast receiver that handles intents that occur in the foreground service. - private var broadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent == null) return - - try { - // This intent has not sent from the current package. - val iPackageName = intent.`package` - val cPackageName = packageName - if (iPackageName != cPackageName) { - Log.d(TAG, "This intent has not sent from the current package. ($iPackageName != $cPackageName)") - return - } - - val action = intent.action ?: return - val data = intent.getStringExtra(INTENT_DATA_NAME) - task?.invokeMethod(action, data) - } catch (e: Exception) { - Log.e(TAG, e.message, e) - } - } - } - - override fun onCreate() { - super.onCreate() - registerBroadcastReceiver() - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - isTimeout = false - loadDataFromPreferences() - - val prefs = ForegroundTaskStorageProvider.getPreferences(this, PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS) - if (prefs.contains(PrefsKey.STOP_WITH_TASK) && prefs.getBoolean(PrefsKey.STOP_WITH_TASK, false)) { - (application as? Application)?.let { - TrackVisibilityUtils.install(it) { - stopForegroundService() - } - } - } - - var action = foregroundServiceStatus.action - val isSetStopWithTaskFlag = ForegroundServiceUtils.isSetStopWithTaskFlag(this) - - if (action == ForegroundServiceAction.API_STOP) { - stopForegroundService() - return START_NOT_STICKY - } - - try { - if (intent == null) { - ForegroundServiceStatus.setData(this, ForegroundServiceAction.RESTART) - foregroundServiceStatus = ForegroundServiceStatus.getData(this) - action = foregroundServiceStatus.action - } - - when (action) { - ForegroundServiceAction.API_START, - ForegroundServiceAction.API_RESTART -> { - startForegroundService() - createForegroundTask() - } - ForegroundServiceAction.API_UPDATE -> { - updateNotification() - val prevCallbackHandle = prevForegroundTaskData?.callbackHandle - val currCallbackHandle = foregroundTaskData.callbackHandle - if (prevCallbackHandle != currCallbackHandle) { - createForegroundTask() - } else { - val prevEventAction = prevForegroundTaskOptions?.eventAction - val currEventAction = foregroundTaskOptions.eventAction - if (prevEventAction != currEventAction) { - updateForegroundTask() - } - } - } - ForegroundServiceAction.REBOOT, - ForegroundServiceAction.RESTART -> { - startForegroundService() - createForegroundTask() - Log.d(TAG, "The service has been restarted by Android OS.") - } - } - } catch (e: Exception) { - Log.e(TAG, e.message, e) - stopForegroundService() - } - - return if (isSetStopWithTaskFlag) { - START_NOT_STICKY - } else { - START_STICKY - } - } - - override fun onBind(intent: Intent?): IBinder? { - return null - } - - override fun onDestroy() { - super.onDestroy() - val isTimeout = this.isTimeout - destroyForegroundTask(isTimeout) - stopForegroundService() - unregisterBroadcastReceiver() - - var isCorrectlyStopped = false - if (::foregroundServiceStatus.isInitialized) { - isCorrectlyStopped = foregroundServiceStatus.isCorrectlyStopped() - } - - // Safely handle auto-restart by checking if options are initialized. - if (::foregroundTaskOptions.isInitialized) { - val allowAutoRestart = foregroundTaskOptions.allowAutoRestart - if (allowAutoRestart && !isCorrectlyStopped && !ForegroundServiceUtils.isSetStopWithTaskFlag(this)) { - Log.e(TAG, "The service will be restarted after 5 seconds because it wasn't properly stopped.") - RestartReceiver.setRestartAlarm(this, 5000) - } - } - } - - override fun onTaskRemoved(rootIntent: Intent?) { - super.onTaskRemoved(rootIntent) - if (ForegroundServiceUtils.isSetStopWithTaskFlag(this)) { - stopSelf() - } else { - RestartReceiver.setRestartAlarm(this, 1000) - } - } - - override fun onTimeout(startId: Int) { - super.onTimeout(startId) - isTimeout = true - stopForegroundService() - Log.e(TAG, "The service(id: $startId) timed out and was terminated by the system.") - } - - @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) - override fun onTimeout(startId: Int, fgsType: Int) { - super.onTimeout(startId, fgsType) - isTimeout = true - stopForegroundService() - Log.e(TAG, "The service(id: $startId) timed out and was terminated by the system.") - } - - private fun loadDataFromPreferences() { - foregroundServiceStatus = ForegroundServiceStatus.getData(applicationContext) - foregroundServiceTypes = ForegroundServiceTypes.getData(applicationContext) - if (::foregroundTaskOptions.isInitialized) { prevForegroundTaskOptions = foregroundTaskOptions } - foregroundTaskOptions = ForegroundTaskOptions.getData(applicationContext) - if (::foregroundTaskData.isInitialized) { prevForegroundTaskData = foregroundTaskData } - foregroundTaskData = ForegroundTaskData.getData(applicationContext) - if (::notificationOptions.isInitialized) { prevNotificationOptions = notificationOptions } - notificationOptions = NotificationOptions.getData(applicationContext) - if (::notificationContent.isInitialized) { prevNotificationContent = notificationContent } - notificationContent = NotificationContent.getData(applicationContext) - } - - private fun registerBroadcastReceiver() { - val intentFilter = IntentFilter().apply { - addAction(ACTION_NOTIFICATION_BUTTON_PRESSED) - addAction(ACTION_NOTIFICATION_PRESSED) - addAction(ACTION_NOTIFICATION_DISMISSED) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(broadcastReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED) - } else { - registerReceiver(broadcastReceiver, intentFilter) - } - } - - private fun unregisterBroadcastReceiver() { - unregisterReceiver(broadcastReceiver) - } - - @SuppressLint("WrongConstant", "SuspiciousIndentation") - private fun startForegroundService() { - RestartReceiver.cancelRestartAlarm(this) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createNotificationChannel() - } - - val serviceId = notificationOptions.serviceId - val notification = createNotification() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground(serviceId, notification, foregroundServiceTypes.value) - } else { - startForeground(serviceId, notification) - } - - releaseLockMode() - acquireLockMode() - - _isRunningServiceState.update { true } - } - - private fun stopForegroundService() { - RestartReceiver.cancelRestartAlarm(this) - - releaseLockMode() - stopForeground(true) - stopSelf() - - _isRunningServiceState.update { false } - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun createNotificationChannel() { - val channelId = notificationOptions.channelId - val channelName = notificationOptions.channelName - val channelDesc = notificationOptions.channelDescription - val channelImportance = notificationOptions.channelImportance - - val nm = getSystemService(NotificationManager::class.java) - if (nm.getNotificationChannel(channelId) == null) { - val channel = NotificationChannel(channelId, channelName, channelImportance).apply { - if (channelDesc != null) { - description = channelDesc - } - enableVibration(notificationOptions.enableVibration) - if (!notificationOptions.playSound) { - setSound(null, null) - } - setShowBadge(notificationOptions.showBadge) - } - nm.createNotificationChannel(channel) - } - } - - private fun createNotification(): Notification { - // notification icon - val icon = notificationContent.icon - val iconResId = getIconResId(icon) - val iconBackgroundColor = icon?.backgroundColorRgb?.let(::getRgbColor) - - // notification intent - val contentIntent = getContentIntent() - val deleteIntent = getDeleteIntent() - - // notification actions - var needsRebuildButtons = false - val prevButtons = prevNotificationContent?.buttons - val currButtons = notificationContent.buttons - if (prevButtons != null) { - if (prevButtons.size != currButtons.size) { - needsRebuildButtons = true - } else { - for (i in currButtons.indices) { - if (prevButtons[i] != currButtons[i]) { - needsRebuildButtons = true - break - } - } - } - } else { - needsRebuildButtons = true - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val builder = Notification.Builder(this, notificationOptions.channelId) - builder.setOngoing(true) - builder.setShowWhen(notificationOptions.showWhen) - builder.setSmallIcon(iconResId) - builder.setContentIntent(contentIntent) - builder.setContentTitle(notificationContent.title) - builder.setContentText(notificationContent.text) - builder.style = Notification.BigTextStyle() - builder.setVisibility(notificationOptions.visibility) - builder.setOnlyAlertOnce(notificationOptions.onlyAlertOnce) - if (iconBackgroundColor != null) { - builder.setColor(iconBackgroundColor) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - builder.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - builder.setDeleteIntent(deleteIntent) - } - - val actions = buildNotificationActions(currButtons, needsRebuildButtons) - for (action in actions) { - builder.addAction(action) - } - - return builder.build() - } else { - val builder = NotificationCompat.Builder(this, notificationOptions.channelId) - builder.setOngoing(true) - builder.setShowWhen(notificationOptions.showWhen) - builder.setSmallIcon(iconResId) - builder.setContentIntent(contentIntent) - builder.setContentTitle(notificationContent.title) - builder.setContentText(notificationContent.text) - builder.setStyle(NotificationCompat.BigTextStyle().bigText(notificationContent.text)) - builder.setVisibility(notificationOptions.visibility) - builder.setOnlyAlertOnce(notificationOptions.onlyAlertOnce) - if (iconBackgroundColor != null) { - builder.color = iconBackgroundColor - } - if (!notificationOptions.enableVibration) { - builder.setVibrate(longArrayOf(0L)) - } - if (!notificationOptions.playSound) { - builder.setSound(null) - } - builder.priority = notificationOptions.priority - - val actions = buildNotificationCompatActions(currButtons, needsRebuildButtons) - for (action in actions) { - builder.addAction(action) - } - - return builder.build() - } - } - - private fun updateNotification() { - val serviceId = notificationOptions.serviceId - val notification = createNotification() - val nm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - getSystemService(NotificationManager::class.java) - } else { - // crash 23+ - ContextCompat.getSystemService(this, NotificationManager::class.java) - } - nm?.notify(serviceId, notification) - } - - @SuppressLint("WakelockTimeout") - private fun acquireLockMode() { - if (foregroundTaskOptions.allowWakeLock && (wakeLock == null || wakeLock?.isHeld == false)) { - wakeLock = - (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager).run { - newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "ForegroundService:WakeLock").apply { - setReferenceCounted(false) - acquire() - } - } - } - - if (foregroundTaskOptions.allowWifiLock && (wifiLock == null || wifiLock?.isHeld == false)) { - wifiLock = - (applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager).run { - createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "ForegroundService:WifiLock").apply { - setReferenceCounted(false) - acquire() - } - } - } - } - - private fun releaseLockMode() { - wakeLock?.let { - if (it.isHeld) { - it.release() - wakeLock = null - } - } - - wifiLock?.let { - if (it.isHeld) { - it.release() - wifiLock = null - } - } - } - - private fun createForegroundTask() { - destroyForegroundTask() - - task = ForegroundTask( - context = this, - serviceStatus = foregroundServiceStatus, - taskData = foregroundTaskData, - taskEventAction = foregroundTaskOptions.eventAction, - taskLifecycleListener = taskLifecycleListeners - ) - } - - private fun updateForegroundTask() { - task?.update(taskEventAction = foregroundTaskOptions.eventAction) - } - - private fun destroyForegroundTask(isTimeout: Boolean = false) { - task?.destroy(isTimeout) - task = null - } - - private fun getIconResId(icon: NotificationIcon?): Int { - try { - val packageManager = applicationContext.packageManager - val packageName = applicationContext.packageName - val appInfo = packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA) - - // application icon - if (icon == null) { - return appInfo.icon - } - - // custom icon - val metaData = appInfo.metaData - if (metaData != null) { - return metaData.getInt(icon.metaDataName) - } - - return 0 - } catch (e: Exception) { - Log.e(TAG, "getIconResId($icon)", e) - return 0 - } - } - - private fun getContentIntent(): PendingIntent { - val packageManager = applicationContext.packageManager - val packageName = applicationContext.packageName - val intent = packageManager.getLaunchIntentForPackage(packageName)?.apply { - putExtra(INTENT_DATA_NAME, ACTION_NOTIFICATION_PRESSED) - - // set initialRoute - val initialRoute = notificationContent.initialRoute - if (initialRoute != null) { - putExtra("route", initialRoute) - } - } - - var flags = PendingIntent.FLAG_UPDATE_CURRENT - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - flags = flags or PendingIntent.FLAG_IMMUTABLE - } - - return PendingIntent.getActivity(this, RequestCode.NOTIFICATION_PRESSED, intent, flags) - } - - private fun getDeleteIntent(): PendingIntent { - val intent = Intent(ACTION_NOTIFICATION_DISMISSED).apply { - setPackage(packageName) - } - - var flags = PendingIntent.FLAG_UPDATE_CURRENT - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - flags = flags or PendingIntent.FLAG_IMMUTABLE - } - - return PendingIntent.getBroadcast(this, RequestCode.NOTIFICATION_DISMISSED, intent, flags) - } - - private fun getRgbColor(rgb: String): Int? { - val rgbSet = rgb.split(",") - return if (rgbSet.size == 3) { - Color.rgb(rgbSet[0].toInt(), rgbSet[1].toInt(), rgbSet[2].toInt()) - } else { - null - } - } - - private fun getTextSpan(text: String, color: Int?): Spannable { - return if (color != null) { - SpannableString(text).apply { - setSpan(ForegroundColorSpan(color), 0, length, 0) - } - } else { - SpannableString(text) - } - } - - private fun buildNotificationActions( - buttons: List, - needsRebuild: Boolean = false - ): List { - val actions = mutableListOf() - for (i in buttons.indices) { - val intent = Intent(ACTION_NOTIFICATION_BUTTON_PRESSED).apply { - setPackage(packageName) - putExtra(INTENT_DATA_NAME, buttons[i].id) - } - var flags = PendingIntent.FLAG_IMMUTABLE - if (needsRebuild) { - flags = flags or PendingIntent.FLAG_CANCEL_CURRENT - } - val textColor = buttons[i].textColorRgb?.let(::getRgbColor) - val text = getTextSpan(buttons[i].text, textColor) - val pendingIntent = - PendingIntent.getBroadcast(this, i + 1, intent, flags) - val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - Notification.Action.Builder(null, text, pendingIntent).build() - } else { - Notification.Action.Builder(0, text, pendingIntent).build() - } - actions.add(action) - } - - return actions - } - - private fun buildNotificationCompatActions( - buttons: List, - needsRebuild: Boolean = false - ): List { - val actions = mutableListOf() - for (i in buttons.indices) { - val intent = Intent(ACTION_NOTIFICATION_BUTTON_PRESSED).apply { - setPackage(packageName) - putExtra(INTENT_DATA_NAME, buttons[i].id) - } - var flags = PendingIntent.FLAG_IMMUTABLE - if (needsRebuild) { - flags = flags or PendingIntent.FLAG_CANCEL_CURRENT - } - val textColor = buttons[i].textColorRgb?.let(::getRgbColor) - val text = getTextSpan(buttons[i].text, textColor) - val pendingIntent = - PendingIntent.getBroadcast(this, i + 1, intent, flags) - val action = NotificationCompat.Action.Builder(0, text, pendingIntent).build() - actions.add(action) - } - - return actions - } -} \ No newline at end of file +class ForegroundService : FlutterForegroundServiceBase() { + override val serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID +} diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundServiceManager.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundServiceManager.kt index 3f32db14..ff41e513 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundServiceManager.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundServiceManager.kt @@ -18,51 +18,70 @@ import com.pravera.flutter_foreground_task.storage.ForegroundTaskStorageProvider * A class that provides foreground service control and management functions. * * @author Dev-hwang - * @version 1.0 + * @version 2.0 */ class ForegroundServiceManager { /** Start the foreground service. */ - fun start(context: Context, arguments: Any?) { - if (isRunningService()) { + fun start(context: Context, serviceId: String, arguments: Any?) { + if (isRunningService(serviceId)) { throw ServiceAlreadyStartedException() } - val nIntent = Intent(context, ForegroundService::class.java) + val cls = FlutterForegroundServiceRegistry.resolveClass(serviceId) + ?: throw IllegalArgumentException("No service registered for id: $serviceId") + + if (!FlutterForegroundServiceRegistry.isServiceDeclaredInManifest(context, serviceId)) { + throw IllegalStateException( + "Service class ${cls.name} for id \"$serviceId\" is not declared in AndroidManifest.xml. " + + "Add a entry to your manifest." + ) + } + + val nIntent = Intent(context, cls) + nIntent.putExtra(FlutterForegroundServiceBase.EXTRA_SERVICE_ID, serviceId) val argsMap = arguments as? Map<*, *> val storagePrefix = argsMap?.get("storagePrefix") as? String ForegroundTaskStorageProvider.configure(storagePrefix) - ForegroundServiceStatus.setData(context, ForegroundServiceAction.API_START) - ForegroundServiceTypes.setData(context, argsMap) - NotificationOptions.setData(context, argsMap) - ForegroundTaskOptions.setData(context, argsMap) - ForegroundTaskData.setData(context, argsMap) - NotificationContent.setData(context, argsMap) + ForegroundServiceStatus.setData(context, serviceId, ForegroundServiceAction.API_START) + ForegroundServiceTypes.setData(context, serviceId, argsMap) + NotificationOptions.setData(context, serviceId, argsMap) + ForegroundTaskOptions.setData(context, serviceId, argsMap) + ForegroundTaskData.setData(context, serviceId, argsMap) + NotificationContent.setData(context, serviceId, argsMap) ContextCompat.startForegroundService(context, nIntent) } /** Restart the foreground service. */ - fun restart(context: Context) { - if (!isRunningService()) { + fun restart(context: Context, serviceId: String) { + if (!isRunningService(serviceId)) { throw ServiceNotStartedException() } - val nIntent = Intent(context, ForegroundService::class.java) - ForegroundServiceStatus.setData(context, ForegroundServiceAction.API_RESTART) + val cls = FlutterForegroundServiceRegistry.resolveClass(serviceId) + ?: throw IllegalArgumentException("No service registered for id: $serviceId") + + val nIntent = Intent(context, cls) + nIntent.putExtra(FlutterForegroundServiceBase.EXTRA_SERVICE_ID, serviceId) + ForegroundServiceStatus.setData(context, serviceId, ForegroundServiceAction.API_RESTART) ContextCompat.startForegroundService(context, nIntent) } /** Update the foreground service. */ - fun update(context: Context, arguments: Any?) { - if (!isRunningService()) { + fun update(context: Context, serviceId: String, arguments: Any?) { + if (!isRunningService(serviceId)) { throw ServiceNotStartedException() } - val nIntent = Intent(context, ForegroundService::class.java) + val cls = FlutterForegroundServiceRegistry.resolveClass(serviceId) + ?: throw IllegalArgumentException("No service registered for id: $serviceId") + + val nIntent = Intent(context, cls) + nIntent.putExtra(FlutterForegroundServiceBase.EXTRA_SERVICE_ID, serviceId) val argsMap = arguments as? Map<*, *> - ForegroundServiceStatus.setData(context, ForegroundServiceAction.API_UPDATE) - ForegroundTaskOptions.updateData(context, argsMap) - ForegroundTaskData.updateData(context, argsMap) - NotificationContent.updateData(context, argsMap) + ForegroundServiceStatus.setData(context, serviceId, ForegroundServiceAction.API_UPDATE) + ForegroundTaskOptions.updateData(context, serviceId, argsMap) + ForegroundTaskData.updateData(context, serviceId, argsMap) + NotificationContent.updateData(context, serviceId, argsMap) // Use startService instead of startForegroundService because the service // is already running in the foreground and we only need to deliver the // update command. This avoids the startForeground() contract requirement. @@ -70,18 +89,22 @@ class ForegroundServiceManager { } /** Stop the foreground service. */ - fun stop(context: Context) { - if (!isRunningService()) { + fun stop(context: Context, serviceId: String) { + if (!isRunningService(serviceId)) { throw ServiceNotStartedException() } - val nIntent = Intent(context, ForegroundService::class.java) - ForegroundServiceStatus.setData(context, ForegroundServiceAction.API_STOP) - ForegroundServiceTypes.clearData(context) - NotificationOptions.clearData(context) - ForegroundTaskOptions.clearData(context) - ForegroundTaskData.clearData(context) - NotificationContent.clearData(context) + val cls = FlutterForegroundServiceRegistry.resolveClass(serviceId) + ?: throw IllegalArgumentException("No service registered for id: $serviceId") + + val nIntent = Intent(context, cls) + nIntent.putExtra(FlutterForegroundServiceBase.EXTRA_SERVICE_ID, serviceId) + ForegroundServiceStatus.setData(context, serviceId, ForegroundServiceAction.API_STOP) + ForegroundServiceTypes.clearData(context, serviceId) + NotificationOptions.clearData(context, serviceId) + ForegroundTaskOptions.clearData(context, serviceId) + ForegroundTaskData.clearData(context, serviceId) + NotificationContent.clearData(context, serviceId) // Use startService instead of startForegroundService because the service // is already running in the foreground and we only need to deliver the // stop command. This avoids the startForeground() contract requirement. @@ -89,12 +112,16 @@ class ForegroundServiceManager { } /** Send data to TaskHandler. */ - fun sendData(data: Any?) { + fun sendData(serviceId: String, data: Any?) { if (data != null) { - ForegroundService.sendData(data) + FlutterForegroundServiceBase.sendData(serviceId, data) } } - /** Returns whether the foreground service is running. */ - fun isRunningService(): Boolean = ForegroundService.isRunningServiceState.value + /** Returns whether the foreground service with the given [serviceId] is running. */ + fun isRunningService(serviceId: String): Boolean = + ForegroundServiceRuntime.isRunning(serviceId) + + /** Returns the set of currently running service ids. */ + fun runningServiceIds(): Set = ForegroundServiceRuntime.runningIds() } diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundServiceRuntime.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundServiceRuntime.kt new file mode 100644 index 00000000..949e0439 --- /dev/null +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundServiceRuntime.kt @@ -0,0 +1,45 @@ +package com.pravera.flutter_foreground_task.service + +import com.pravera.flutter_foreground_task.FlutterForegroundTaskLifecycleListener +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.ConcurrentHashMap + +/** + * Per-service runtime state that was previously stored as statics inside + * `ForegroundService.companion`. Moving it here lets multiple service + * instances coexist with isolated state. + */ +object ForegroundServiceRuntime { + private val running = ConcurrentHashMap>() + private val tasks = ConcurrentHashMap() + private val lifecycleListeners = ConcurrentHashMap() + + fun isRunning(id: String): Boolean = running[id]?.value ?: false + + fun stateFlow(id: String): MutableStateFlow = + running.getOrPut(id) { MutableStateFlow(false) } + + fun readOnlyStateFlow(id: String) = stateFlow(id).asStateFlow() + + fun task(id: String): ForegroundTask? = tasks[id] + + fun setTask(id: String, task: ForegroundTask?) { + if (task == null) tasks.remove(id) else tasks[id] = task + } + + fun listeners(id: String): ForegroundTaskLifecycleListeners = + lifecycleListeners.getOrPut(id) { ForegroundTaskLifecycleListeners() } + + fun addTaskLifecycleListener(id: String, listener: FlutterForegroundTaskLifecycleListener) { + listeners(id).addListener(listener) + } + + fun removeTaskLifecycleListener(id: String, listener: FlutterForegroundTaskLifecycleListener) { + listeners(id).removeListener(listener) + } + + fun runningIds(): Set = running.entries.filter { it.value.value }.map { it.key }.toSet() + + fun anyRunning(): Boolean = running.values.any { it.value } +} diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundTask.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundTask.kt index 11a5032b..f9113323 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundTask.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundTask.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.withContext class ForegroundTask( context: Context, + private val serviceId: String, private val serviceStatus: ForegroundServiceStatus, private val taskData: ForegroundTaskData, private var taskEventAction: ForegroundTaskEventAction, @@ -33,6 +34,7 @@ class ForegroundTask( companion object { private val TAG = ForegroundTask::class.java.simpleName + private const val ACTION_SERVICE_ID_SET = "onServiceIdSet" private const val ACTION_TASK_START = "onStart" private const val ACTION_TASK_REPEAT_EVENT = "onRepeatEvent" private const val ACTION_TASK_DESTROY = "onDestroy" @@ -88,6 +90,12 @@ class ForegroundTask( FlutterForegroundTaskStarter.SYSTEM } + // Inform the Dart isolate of this task's service id so that + // FlutterForegroundTask.sendDataToMain routes to the correct + // communication port. Method channel calls are delivered in + // order, so this always arrives before ACTION_TASK_START. + backgroundChannel.invokeMethod(ACTION_SERVICE_ID_SET, serviceId) + backgroundChannel.invokeMethod(ACTION_TASK_START, starter.ordinal) { runIfNotDestroyed { startRepeatTask() diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/RebootReceiver.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/RebootReceiver.kt index eb32d5fb..5c977cdb 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/RebootReceiver.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/RebootReceiver.kt @@ -15,8 +15,11 @@ import com.pravera.flutter_foreground_task.utils.ForegroundServiceUtils /** * The receiver that receives the BOOT_COMPLETED and MY_PACKAGE_REPLACED intent. * + * Iterates all registered service ids so each service can be independently + * restarted on boot or package replacement. + * * @author Dev-hwang - * @version 1.0 + * @version 2.0 */ class RebootReceiver : BroadcastReceiver() { companion object { @@ -26,45 +29,56 @@ class RebootReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (context == null || intent == null) return + for (serviceId in FlutterForegroundServiceRegistry.registeredIds()) { + if (!FlutterForegroundServiceRegistry.isServiceDeclaredInManifest(context, serviceId)) { + continue + } + handleServiceId(context, intent, serviceId) + } + } + + private fun handleServiceId(context: Context, intent: Intent, serviceId: String) { // Ignore autoRunOnBoot option when android:stopWithTask is set to true. - if (ForegroundServiceUtils.isSetStopWithTaskFlag(context)) { + if (ForegroundServiceUtils.isSetStopWithTaskFlag(context, serviceId)) { return } // Ignore autoRunOnBoot option when service is stopped by developer. - val serviceStatus = ForegroundServiceStatus.getData(context) + val serviceStatus = ForegroundServiceStatus.getData(context, serviceId) if (serviceStatus.isCorrectlyStopped()) { return } - val options = ForegroundTaskOptions.getData(context) + val options = ForegroundTaskOptions.getData(context, serviceId) // Check whether to start the service at boot intent. if ((intent.action == Intent.ACTION_BOOT_COMPLETED || intent.action == "android.intent.action.QUICKBOOT_POWERON") && options.autoRunOnBoot) { - return startForegroundService(context) + return startForegroundService(context, serviceId) } // Handle app update: cancel stale restart alarms and either restart the // service or mark it stopped so zombie restarts don't freeze the app. - // See: https://github.com/Dev-hwang/flutter_foreground_task/issues/366 if (intent.action == Intent.ACTION_MY_PACKAGE_REPLACED) { - RestartReceiver.cancelRestartAlarm(context) + RestartReceiver.cancelRestartAlarm(context, serviceId) if (options.autoRunOnMyPackageReplaced) { - return startForegroundService(context) + return startForegroundService(context, serviceId) } else { - ForegroundServiceStatus.setData(context, ForegroundServiceAction.API_STOP) + ForegroundServiceStatus.setData(context, serviceId, ForegroundServiceAction.API_STOP) return } } } - private fun startForegroundService(context: Context) { + private fun startForegroundService(context: Context, serviceId: String) { + val cls = FlutterForegroundServiceRegistry.resolveClass(serviceId) ?: return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { try { - val nIntent = Intent(context, ForegroundService::class.java) - ForegroundServiceStatus.setData(context, ForegroundServiceAction.REBOOT) + val nIntent = Intent(context, cls) + nIntent.putExtra(FlutterForegroundServiceBase.EXTRA_SERVICE_ID, serviceId) + ForegroundServiceStatus.setData(context, serviceId, ForegroundServiceAction.REBOOT) ContextCompat.startForegroundService(context, nIntent) } catch (e: ForegroundServiceStartNotAllowedException) { Log.e(TAG, "Foreground service start not allowed exception: ${e.message}") @@ -73,8 +87,9 @@ class RebootReceiver : BroadcastReceiver() { } } else { try { - val nIntent = Intent(context, ForegroundService::class.java) - ForegroundServiceStatus.setData(context, ForegroundServiceAction.REBOOT) + val nIntent = Intent(context, cls) + nIntent.putExtra(FlutterForegroundServiceBase.EXTRA_SERVICE_ID, serviceId) + ForegroundServiceStatus.setData(context, serviceId, ForegroundServiceAction.REBOOT) ContextCompat.startForegroundService(context, nIntent) } catch (e: Exception) { Log.e(TAG, e.message, e) diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/RestartReceiver.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/RestartReceiver.kt index 3c15a79a..1ec375da 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/RestartReceiver.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/RestartReceiver.kt @@ -19,22 +19,27 @@ import com.pravera.flutter_foreground_task.utils.PluginUtils * The receiver that receives restart alarm event. * * @author Dev-hwang - * @version 1.0 + * @version 2.0 */ class RestartReceiver : BroadcastReceiver() { companion object { private val TAG = RestartReceiver::class.java.simpleName - fun setRestartAlarm(context: Context, millis: Int) { + private fun requestCodeFor(serviceId: String): Int = + RequestCode.SET_RESTART_SERVICE_ALARM xor serviceId.hashCode() + + fun setRestartAlarm(context: Context, serviceId: String, millis: Int) { val triggerTime = System.currentTimeMillis() + millis - val intent = Intent(context, RestartReceiver::class.java) + val intent = Intent(context, RestartReceiver::class.java).apply { + putExtra(FlutterForegroundServiceBase.EXTRA_SERVICE_ID, serviceId) + } var flags = PendingIntent.FLAG_UPDATE_CURRENT if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { flags = flags or PendingIntent.FLAG_MUTABLE } val operation = PendingIntent.getBroadcast( - context, RequestCode.SET_RESTART_SERVICE_ALARM, intent, flags) + context, requestCodeFor(serviceId), intent, flags) val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && @@ -46,14 +51,16 @@ class RestartReceiver : BroadcastReceiver() { } } - fun cancelRestartAlarm(context: Context) { - val intent = Intent(context, RestartReceiver::class.java) + fun cancelRestartAlarm(context: Context, serviceId: String) { + val intent = Intent(context, RestartReceiver::class.java).apply { + putExtra(FlutterForegroundServiceBase.EXTRA_SERVICE_ID, serviceId) + } var flags = PendingIntent.FLAG_CANCEL_CURRENT if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { flags = flags or PendingIntent.FLAG_MUTABLE } val operation = PendingIntent.getBroadcast( - context, RequestCode.SET_RESTART_SERVICE_ALARM, intent, flags) + context, requestCodeFor(serviceId), intent, flags) val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager alarmManager.cancel(operation) @@ -63,14 +70,23 @@ class RestartReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (context == null) return - val serviceStatus = ForegroundServiceStatus.getData(context) + val serviceId = intent?.getStringExtra(FlutterForegroundServiceBase.EXTRA_SERVICE_ID) + ?: FlutterForegroundServiceRegistry.DEFAULT_ID + + if (!FlutterForegroundServiceRegistry.isServiceDeclaredInManifest(context, serviceId)) { + return + } + + val serviceStatus = ForegroundServiceStatus.getData(context, serviceId) if (serviceStatus.isCorrectlyStopped()) { return } + val cls = FlutterForegroundServiceRegistry.resolveClass(serviceId) ?: return + val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager val isRunningService = manager.getRunningServices(Integer.MAX_VALUE) - .any { it.service.className == ForegroundService::class.java.name } + .any { it.service.className == cls.name } if (isRunningService) { return } @@ -82,8 +98,9 @@ class RestartReceiver : BroadcastReceiver() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { try { - val nIntent = Intent(context, ForegroundService::class.java) - ForegroundServiceStatus.setData(context, ForegroundServiceAction.RESTART) + val nIntent = Intent(context, cls) + nIntent.putExtra(FlutterForegroundServiceBase.EXTRA_SERVICE_ID, serviceId) + ForegroundServiceStatus.setData(context, serviceId, ForegroundServiceAction.RESTART) ContextCompat.startForegroundService(context, nIntent) } catch (e: ForegroundServiceStartNotAllowedException) { Log.e(TAG, "Foreground service start not allowed exception: ${e.message}") @@ -92,12 +109,13 @@ class RestartReceiver : BroadcastReceiver() { } } else { try { - val nIntent = Intent(context, ForegroundService::class.java) - ForegroundServiceStatus.setData(context, ForegroundServiceAction.RESTART) + val nIntent = Intent(context, cls) + nIntent.putExtra(FlutterForegroundServiceBase.EXTRA_SERVICE_ID, serviceId) + ForegroundServiceStatus.setData(context, serviceId, ForegroundServiceAction.RESTART) ContextCompat.startForegroundService(context, nIntent) } catch (e: Exception) { Log.e(TAG, e.message, e) } } } -} \ No newline at end of file +} diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/storage/ForegroundTaskStorageProvider.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/storage/ForegroundTaskStorageProvider.kt index 96001b59..4b099f01 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/storage/ForegroundTaskStorageProvider.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/storage/ForegroundTaskStorageProvider.kt @@ -2,6 +2,7 @@ package com.pravera.flutter_foreground_task.storage import android.content.Context import android.content.SharedPreferences +import com.pravera.flutter_foreground_task.service.FlutterForegroundServiceRegistry /** * Provides [SharedPreferences] instances for the library's named preference @@ -28,10 +29,25 @@ object ForegroundTaskStorageProvider { } /** - * Returns a [SharedPreferences] instance for the given logical [name]. - * The actual file name is `prefix + name`. + * Returns a [SharedPreferences] instance for the given logical [name], + * scoped to the given [serviceId]. + * + * For the default service id the on-disk file name is unchanged from the + * legacy single-service layout (`prefix + name`). For any other id it + * becomes `prefix + serviceId + "." + name`, giving each service fully + * isolated storage. + */ + fun getPreferences(context: Context, serviceId: String, name: String): SharedPreferences { + val scoped = if (serviceId == FlutterForegroundServiceRegistry.DEFAULT_ID) name + else "$serviceId.$name" + return context.getSharedPreferences(prefix + scoped, Context.MODE_PRIVATE) + } + + /** + * Legacy overload that targets the default service id. + * Kept for backward compatibility with callers that don't need multi-service support. */ fun getPreferences(context: Context, name: String): SharedPreferences { - return context.getSharedPreferences(prefix + name, Context.MODE_PRIVATE) + return getPreferences(context, FlutterForegroundServiceRegistry.DEFAULT_ID, name) } } diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/utils/ForegroundServiceUtils.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/utils/ForegroundServiceUtils.kt index ce723e7c..3c0cacaa 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/utils/ForegroundServiceUtils.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/utils/ForegroundServiceUtils.kt @@ -6,7 +6,7 @@ import android.content.pm.PackageManager import android.content.pm.PackageManager.NameNotFoundException import android.content.pm.ServiceInfo import android.util.Log -import com.pravera.flutter_foreground_task.service.ForegroundService +import com.pravera.flutter_foreground_task.service.FlutterForegroundServiceRegistry import com.pravera.flutter_foreground_task.PreferencesKey as PrefsKey import com.pravera.flutter_foreground_task.storage.ForegroundTaskStorageProvider @@ -14,14 +14,15 @@ class ForegroundServiceUtils { companion object { private val TAG = ForegroundServiceUtils::class.java.simpleName - fun isSetStopWithTaskFlag(context: Context): Boolean { + fun isSetStopWithTaskFlag(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID): Boolean { try { - val prefs = ForegroundTaskStorageProvider.getPreferences(context, PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS) + val prefs = ForegroundTaskStorageProvider.getPreferences(context, serviceId, PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS) if (prefs.contains(PrefsKey.STOP_WITH_TASK)) return prefs.getBoolean(PrefsKey.STOP_WITH_TASK, false) + val cls = FlutterForegroundServiceRegistry.resolveClass(serviceId) ?: return true val pm = context.packageManager - val cName = ComponentName(context, ForegroundService::class.java) + val cName = ComponentName(context, cls) val flags = pm.getServiceInfo(cName, PackageManager.GET_META_DATA).flags return (flags and ServiceInfo.FLAG_STOP_WITH_TASK) != 0 } catch (e: NameNotFoundException) { @@ -33,4 +34,4 @@ class ForegroundServiceUtils { } } } -} \ No newline at end of file +} diff --git a/documentation/android_shared_preferences_usage.md b/documentation/android_shared_preferences_usage.md index ac73f3f8..744c6e89 100644 --- a/documentation/android_shared_preferences_usage.md +++ b/documentation/android_shared_preferences_usage.md @@ -79,21 +79,30 @@ All key constants are defined in `PreferencesKey.kt`. |---|---|---| | *(permission string)* | `String` | Previous grant/deny result for permanent-denial detection | +## Multi-service scoping + +When using [multiple services](multiple_services.md), each service id gets its own set of preference files. The file name is constructed as: + +- **Default service (`"default"`):** `prefix + name` — e.g. `com.pravera.flutter_foreground_task.prefs.NOTIFICATION_OPTIONS`. This is identical to the legacy single-service layout, so existing installs keep their data with zero migration. +- **Custom service id:** `prefix + serviceId + "." + name` — e.g. `com.pravera.flutter_foreground_task.prefs.locationTracker.NOTIFICATION_OPTIONS`. + +This isolation is handled by `ForegroundTaskStorageProvider.getPreferences(context, serviceId, name)`. The legacy two-argument overload `getPreferences(context, name)` defaults to `"default"` for backward compatibility. + ## Architecture -All persistence goes through model companion objects (`getData`, `setData`, `updateData`, `clearData`) backed by a `ForegroundTaskStorageProvider`. The provider returns `SharedPreferences` instances for each named file and can be reconfigured with a custom prefix from Dart. +All persistence goes through model companion objects (`getData`, `setData`, `updateData`, `clearData`) backed by a `ForegroundTaskStorageProvider`. The provider returns `SharedPreferences` instances for each named file and can be reconfigured with a custom prefix from Dart. All model companions accept an optional `serviceId` parameter (defaulting to `"default"`) to scope reads and writes. ``` Dart (AndroidNotificationOptions) - └─ method channel ──► ForegroundServiceManager.start() - └─ Model.setData(context, args) - └─ ForegroundTaskStorageProvider.getPreferences(context, name) - └─ context.getSharedPreferences(name, MODE_PRIVATE) + └─ method channel ──► ForegroundServiceManager.start(serviceId, ...) + └─ Model.setData(context, serviceId, args) + └─ ForegroundTaskStorageProvider.getPreferences(context, serviceId, name) + └─ context.getSharedPreferences(scopedName, MODE_PRIVATE) ``` Direct `SharedPreferences` reads also occur in: -- `ForegroundService.onStartCommand` -- reads `stopWithTask` to install visibility tracking -- `ForegroundServiceUtils.isSetStopWithTaskFlag` -- reads `stopWithTask` for reboot/restart receivers +- `FlutterForegroundServiceBase.onStartCommand` -- reads `stopWithTask` to install visibility tracking (inherited by `ForegroundService` and any user subclass) +- `ForegroundServiceUtils.isSetStopWithTaskFlag` -- reads `stopWithTask` for reboot/restart receivers (now takes a `serviceId` argument) ## Comparison with iOS diff --git a/documentation/multiple_services.md b/documentation/multiple_services.md new file mode 100644 index 00000000..6608e17e --- /dev/null +++ b/documentation/multiple_services.md @@ -0,0 +1,252 @@ +# Multiple Foreground Services (Android) + +This guide explains how to run more than one foreground service simultaneously using `flutter_foreground_task`. + +## Overview + +By default the library provides a single service class (`ForegroundService`) that is referenced from your `AndroidManifest.xml`. This covers the majority of use cases. When your app needs **multiple independent services** (for example a location tracker *and* a media player running side by side), you can register additional services, each with its own notification, task handler, lifecycle, and isolated Dart engine. + +### How it works + +``` +Dart Android +------ ------- +FlutterForegroundTaskController ──► ForegroundServiceManager + .of("locationTracker") │ + .startService(...) ├─ FlutterForegroundServiceRegistry + │ "default" → ForegroundService.class + │ "locationTracker" → LocationTrackerService.class + │ + └─ Intent(context, LocationTrackerService.class) + └─ each service reads/writes its own + scoped SharedPreferences files +``` + +Each service: +- Runs as a separate Android `Service` component (sharing the app's process by default). +- Has its own notification channel, notification content, and foreground service type. +- Creates its own `FlutterEngine` and Dart isolate (the callback passed to `startService`). +- Stores all configuration in SharedPreferences files namespaced by service id. + +## Setup + +### 1. Create a Kotlin subclass + +Create a Kotlin class in your app that extends `FlutterForegroundServiceBase` and sets a unique `serviceId`: + +```kotlin +// android/app/src/main/kotlin/.../LocationTrackerService.kt +package com.example.myapp + +import com.pravera.flutter_foreground_task.service.FlutterForegroundServiceBase + +class LocationTrackerService : FlutterForegroundServiceBase() { + override val serviceId: String = "locationTracker" +} +``` + +### 2. Declare the service in AndroidManifest.xml + +Add a `` entry for each service class you intend to use. + +The default `ForegroundService` entry is **optional**: if your app only +uses custom service subclasses, you can omit it entirely. The library +validates the manifest before starting any service — if a service class +is not declared, `startService` will throw with a clear error message +instead of crashing, and `RebootReceiver` / `RestartReceiver` will +silently skip it. + +Each foreground service type requires a matching permission. Make sure the +appropriate `FOREGROUND_SERVICE_*` permission is declared in the manifest for +every type you use: + +```xml + + + + + + + + + + + +``` + +Each service can declare its own `android:foregroundServiceType` and `android:stopWithTask` attributes. + +### 3. Register the service in Application.onCreate + +The registration must happen in `Application.onCreate()` because `RebootReceiver` fires before any Activity is created: + +```kotlin +// android/app/src/main/kotlin/.../MyApp.kt +package com.example.myapp + +import android.app.Application +import com.pravera.flutter_foreground_task.service.FlutterForegroundServiceRegistry + +class MyApp : Application() { + override fun onCreate() { + super.onCreate() + FlutterForegroundServiceRegistry.register( + "locationTracker", + LocationTrackerService::class.java + ) + } +} +``` + +Make sure your `AndroidManifest.xml` references this Application class: + +```xml + +``` + +> The default service (`"default"` -> `ForegroundService`) is registered +> automatically inside `FlutterForegroundServiceRegistry`'s static +> initializer, so it is available even at cold boot (before any Flutter +> engine attaches). You do not need to register it yourself. +> +> However, the default service **will only be started** if it is also +> declared as a `` in your `AndroidManifest.xml`. If your app +> exclusively uses custom service subclasses, you can safely omit the +> default entry from the manifest. + +### 4. Use FlutterForegroundTaskController from Dart + +```dart +// Obtain a controller for your service id. +final locationCtrl = FlutterForegroundTaskController.of('locationTracker'); + +// Initialize (call once, e.g. in initState or main). +locationCtrl.init( + androidNotificationOptions: AndroidNotificationOptions( + channelId: 'location_tracker_channel', + channelName: 'Location Tracker', + ), + iosNotificationOptions: const IOSNotificationOptions( + showNotification: false, + ), + foregroundTaskOptions: ForegroundTaskOptions( + eventAction: ForegroundTaskEventAction.repeat(5000), + ), +); + +// Start the service. +await locationCtrl.startService( + notificationTitle: 'Tracking location', + notificationText: 'Running...', + callback: locationCallback, +); + +// Stop it later. +await locationCtrl.stopService(); +``` + +### 5. Task handler & communication + +Each service spawns its own Dart isolate. The callback is a **top-level function** just like the single-service case: + +```dart +@pragma('vm:entry-point') +void locationCallback() { + FlutterForegroundTask.setTaskHandler(LocationTaskHandler()); +} + +class LocationTaskHandler extends TaskHandler { + @override + Future onStart(DateTime timestamp, TaskStarter starter) async { ... } + + @override + void onRepeatEvent(DateTime timestamp) { ... } + + @override + Future onDestroy(DateTime timestamp, bool isTimeout) async { ... } +} +``` + +Communication ports are scoped per controller. Inside the task handler +isolate, `FlutterForegroundTask.sendDataToMain` is service-id aware: the +native side hands the isolate its own service id as soon as the isolate +starts (via the `onServiceIdSet` background channel call), and the static +method routes to the matching controller automatically. You can also +address a specific controller explicitly if you prefer. + +```dart +// In your UI widget: +locationCtrl.initCommunicationPort(); +locationCtrl.addTaskDataCallback((data) { + print('Received from location service: $data'); +}); + +// From inside the task handler (runs in a separate isolate). +// The static method routes to the correct controller automatically: +FlutterForegroundTask.sendDataToMain(someData); + +// You can also address the controller explicitly: +FlutterForegroundTaskController.of(FlutterForegroundTask.currentServiceId) + .sendDataToMain(someData); + +// Send data to the task handler from the UI: +locationCtrl.sendDataToTask(someCommand); +``` + +> `FlutterForegroundTask.currentServiceId` is set by the native layer +> immediately after the task isolate starts. It is always `"default"` on +> the UI isolate. + +## Backward compatibility + +Existing single-service apps require **zero changes**: + +| Aspect | Before | After | +|---|---|---| +| Manifest `` entry | `ForegroundService` | Same, unchanged | +| Dart API | `FlutterForegroundTask.init(...)` | Same, unchanged | +| SharedPreferences files | `com.pravera...prefs.NOTIFICATION_OPTIONS` | Same file names for `"default"` id | +| Lifecycle listeners | `FlutterForegroundTaskPlugin.addTaskLifecycleListener(listener)` | Same signature, targets default | + +The `FlutterForegroundTask` static API delegates to `FlutterForegroundTaskController.of('default')` internally. + +## SharedPreferences scoping + +Each service id gets its own isolated preference files: + +| Service id | File name pattern | +|---|---| +| `"default"` | `com.pravera.flutter_foreground_task.prefs.NOTIFICATION_OPTIONS` (unchanged) | +| `"locationTracker"` | `com.pravera.flutter_foreground_task.prefs.locationTracker.NOTIFICATION_OPTIONS` | + +This means existing installs keep their data with zero migration. + +## Lifecycle listeners (Kotlin) + +You can listen to lifecycle events for a specific service: + +```kotlin +FlutterForegroundTaskPlugin.addTaskLifecycleListener( + "locationTracker", + myListener +) +``` + +The single-argument overload targets the default service for backward compatibility. + +## Limitations + +- **Android only.** iOS does not support multiple simultaneous foreground services. The multi-service controller can still be used on iOS for organizational purposes, but only one service can run at a time. +- Each additional service requires writing a small Kotlin class and registering it in `Application.onCreate()`. +- The `RebootReceiver` iterates all registered service ids on boot/package-replaced but **skips any id whose service class is not declared in the manifest**. Each remaining id is independently evaluated for `autoRunOnBoot` / `autoRunOnMyPackageReplaced`. +- Restart alarms are scoped by service id and use distinct PendingIntent request codes derived from the id's hash code. `RestartReceiver` also validates the manifest before restarting. +- Calling `startService` for a service id whose class is not in the manifest will throw an `IllegalStateException` with a message explaining which `` entry is missing. diff --git a/example/ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift b/example/ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift new file mode 100644 index 00000000..d1429bee --- /dev/null +++ b/example/ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. +// +// Generated file. Do not edit. +// + +import PackageDescription + +let package = Package( + name: "FlutterGeneratedPluginSwiftPackage", + platforms: [ + .iOS("13.0") + ], + products: [ + .library(name: "FlutterGeneratedPluginSwiftPackage", type: .static, targets: ["FlutterGeneratedPluginSwiftPackage"]) + ], + dependencies: [ + .package(name: "shared_preferences_foundation", path: "../.packages/shared_preferences_foundation") + ], + targets: [ + .target( + name: "FlutterGeneratedPluginSwiftPackage", + dependencies: [ + .product(name: "shared-preferences-foundation", package: "shared_preferences_foundation") + ] + ) + ] +) diff --git a/example/ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Sources/FlutterGeneratedPluginSwiftPackage/FlutterGeneratedPluginSwiftPackage.swift b/example/ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Sources/FlutterGeneratedPluginSwiftPackage/FlutterGeneratedPluginSwiftPackage.swift new file mode 100644 index 00000000..62e7b11a --- /dev/null +++ b/example/ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Sources/FlutterGeneratedPluginSwiftPackage/FlutterGeneratedPluginSwiftPackage.swift @@ -0,0 +1,3 @@ +// +// Generated file. Do not edit. +// diff --git a/example/ios/Flutter/ephemeral/flutter_lldb_helper.py b/example/ios/Flutter/ephemeral/flutter_lldb_helper.py new file mode 100644 index 00000000..a88caf99 --- /dev/null +++ b/example/ios/Flutter/ephemeral/flutter_lldb_helper.py @@ -0,0 +1,32 @@ +# +# Generated file, do not edit. +# + +import lldb + +def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict): + """Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages.""" + base = frame.register["x0"].GetValueAsAddress() + page_len = frame.register["x1"].GetValueAsUnsigned() + + # Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the + # first page to see if handled it correctly. This makes diagnosing + # misconfiguration (e.g. missing breakpoint) easier. + data = bytearray(page_len) + data[0:8] = b'IHELPED!' + + error = lldb.SBError() + frame.GetThread().GetProcess().WriteMemory(base, data, error) + if not error.Success(): + print(f'Failed to write into {base}[+{page_len}]', error) + return + +def __lldb_init_module(debugger: lldb.SBDebugger, _): + target = debugger.GetDummyTarget() + # Caveat: must use BreakpointCreateByRegEx here and not + # BreakpointCreateByName. For some reasons callback function does not + # get carried over from dummy target for the later. + bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$") + bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__)) + bp.SetAutoContinue(True) + print("-- LLDB integration loaded --") diff --git a/example/ios/Flutter/ephemeral/flutter_lldbinit b/example/ios/Flutter/ephemeral/flutter_lldbinit new file mode 100644 index 00000000..e3ba6fbe --- /dev/null +++ b/example/ios/Flutter/ephemeral/flutter_lldbinit @@ -0,0 +1,5 @@ +# +# Generated file, do not edit. +# + +command script import --relative-to-command-file flutter_lldb_helper.py diff --git a/lib/flutter_foreground_task.dart b/lib/flutter_foreground_task.dart index 3359c6b5..840d72c7 100644 --- a/lib/flutter_foreground_task.dart +++ b/lib/flutter_foreground_task.dart @@ -1,15 +1,10 @@ import 'dart:async'; import 'dart:isolate'; -import 'dart:ui'; import 'package:flutter/widgets.dart'; -import 'package:shared_preferences/shared_preferences.dart'; +import 'flutter_foreground_task_controller.dart'; import 'flutter_foreground_task_platform_interface.dart'; -import 'errors/service_already_started_exception.dart'; -import 'errors/service_not_initialized_exception.dart'; -import 'errors/service_not_started_exception.dart'; -import 'errors/service_timeout_exception.dart'; import 'models/foreground_service_types.dart'; import 'models/foreground_task_options.dart'; import 'models/notification_button.dart'; @@ -18,12 +13,12 @@ import 'models/notification_options.dart'; import 'models/notification_permission.dart'; import 'models/service_request_result.dart'; import 'task_handler.dart'; -import 'utils/utility.dart'; export 'errors/service_already_started_exception.dart'; export 'errors/service_not_initialized_exception.dart'; export 'errors/service_not_started_exception.dart'; export 'errors/service_timeout_exception.dart'; +export 'flutter_foreground_task_controller.dart'; export 'models/foreground_service_types.dart'; export 'models/foreground_task_event_action.dart'; export 'models/foreground_task_options.dart'; @@ -38,29 +33,70 @@ export 'models/service_request_result.dart'; export 'ui/with_foreground_task.dart'; export 'task_handler.dart'; -const String _kPortName = 'flutter_foreground_task/isolateComPort'; -const String _kPrefsKeyPrefix = 'com.pravera.flutter_foreground_task.prefs.'; - typedef DataCallback = void Function(Object data); /// A class that implements foreground task and provides useful utilities. +/// +/// All service-related static methods delegate to the `"default"` instance +/// of [FlutterForegroundTaskController]. For multi-service usage, obtain a +/// controller with [FlutterForegroundTaskController.of]. class FlutterForegroundTask { + // The default controller backing this static API. + static FlutterForegroundTaskController get _default => + FlutterForegroundTaskController.of('default'); + + /// Returns the service id of the foreground service running in the + /// current isolate. + /// + /// Inside a [TaskHandler] this is set by the native side immediately + /// after the isolate starts (via the background method channel), and + /// reflects the service id passed to + /// [FlutterForegroundTaskController.startService]. + /// + /// Outside of a task isolate (i.e. from the UI isolate), this always + /// returns `"default"` and should not be used. + static String get currentServiceId => + FlutterForegroundTaskController.currentServiceId; + // ====================== Service ====================== @visibleForTesting - static AndroidNotificationOptions? androidNotificationOptions; + static AndroidNotificationOptions? get androidNotificationOptions => + _default.androidNotificationOptions; @visibleForTesting - static IOSNotificationOptions? iosNotificationOptions; + static set androidNotificationOptions(AndroidNotificationOptions? v) => + _default.androidNotificationOptions = v; @visibleForTesting - static ForegroundTaskOptions? foregroundTaskOptions; + static IOSNotificationOptions? get iosNotificationOptions => + _default.iosNotificationOptions; @visibleForTesting - static bool isInitialized = false; + static set iosNotificationOptions(IOSNotificationOptions? v) => + _default.iosNotificationOptions = v; @visibleForTesting - static bool skipServiceResponseCheck = false; + static ForegroundTaskOptions? get foregroundTaskOptions => + _default.foregroundTaskOptions; + + @visibleForTesting + static set foregroundTaskOptions(ForegroundTaskOptions? v) => + _default.foregroundTaskOptions = v; + + @visibleForTesting + static bool get isInitialized => _default.isInitialized; + + @visibleForTesting + static set isInitialized(bool v) => _default.isInitialized = v; + + @visibleForTesting + static bool get skipServiceResponseCheck => + _default.skipServiceResponseCheck; + + @visibleForTesting + static set skipServiceResponseCheck(bool v) => + _default.skipServiceResponseCheck = v; // platform instance: MethodChannelFlutterForegroundTask static FlutterForegroundTaskPlatform get _platform => @@ -69,17 +105,7 @@ class FlutterForegroundTask { /// Resets class's static values to allow for testing of service flow. @visibleForTesting static void resetStatic() { - androidNotificationOptions = null; - iosNotificationOptions = null; - foregroundTaskOptions = null; - isInitialized = false; - skipServiceResponseCheck = false; - - receivePort?.close(); - receivePort = null; - streamSubscription?.cancel(); - streamSubscription = null; - dataCallbacks.clear(); + _default.resetState(); } /// Initialize the [FlutterForegroundTask]. @@ -88,11 +114,11 @@ class FlutterForegroundTask { required IOSNotificationOptions iosNotificationOptions, required ForegroundTaskOptions foregroundTaskOptions, }) { - FlutterForegroundTask.androidNotificationOptions = - androidNotificationOptions; - FlutterForegroundTask.iosNotificationOptions = iosNotificationOptions; - FlutterForegroundTask.foregroundTaskOptions = foregroundTaskOptions; - FlutterForegroundTask.isInitialized = true; + _default.init( + androidNotificationOptions: androidNotificationOptions, + iosNotificationOptions: iosNotificationOptions, + foregroundTaskOptions: foregroundTaskOptions, + ); } /// Start the foreground service. @@ -105,53 +131,22 @@ class FlutterForegroundTask { List? notificationButtons, String? notificationInitialRoute, Function? callback, - }) async { - try { - if (!isInitialized) { - throw ServiceNotInitializedException(); - } - - if (await isRunningService) { - throw ServiceAlreadyStartedException(); - } - - await _platform.startService( - androidNotificationOptions: androidNotificationOptions!, - iosNotificationOptions: iosNotificationOptions!, - foregroundTaskOptions: foregroundTaskOptions!, - serviceId: serviceId, - serviceTypes: serviceTypes, - notificationTitle: notificationTitle, - notificationText: notificationText, - notificationIcon: notificationIcon, - notificationButtons: notificationButtons, - notificationInitialRoute: notificationInitialRoute, - callback: callback, - ); - - if (!skipServiceResponseCheck) { - await checkServiceStateChange(target: true); - } - - return const ServiceRequestSuccess(); - } catch (error) { - return ServiceRequestFailure(error: error); - } + }) { + return _default.startService( + serviceId: serviceId, + serviceTypes: serviceTypes, + notificationTitle: notificationTitle, + notificationText: notificationText, + notificationIcon: notificationIcon, + notificationButtons: notificationButtons, + notificationInitialRoute: notificationInitialRoute, + callback: callback, + ); } /// Restart the foreground service. - static Future restartService() async { - try { - if (!(await isRunningService)) { - throw ServiceNotStartedException(); - } - - await _platform.restartService(); - - return const ServiceRequestSuccess(); - } catch (error) { - return ServiceRequestFailure(error: error); - } + static Future restartService() { + return _default.restartService(); } /// Update the foreground service. @@ -163,65 +158,30 @@ class FlutterForegroundTask { List? notificationButtons, String? notificationInitialRoute, Function? callback, - }) async { - try { - if (!(await isRunningService)) { - throw ServiceNotStartedException(); - } - - await _platform.updateService( - foregroundTaskOptions: foregroundTaskOptions, - notificationText: notificationText, - notificationTitle: notificationTitle, - notificationIcon: notificationIcon, - notificationButtons: notificationButtons, - notificationInitialRoute: notificationInitialRoute, - callback: callback, - ); - - return const ServiceRequestSuccess(); - } catch (error) { - return ServiceRequestFailure(error: error); - } + }) { + return _default.updateService( + foregroundTaskOptions: foregroundTaskOptions, + notificationTitle: notificationTitle, + notificationText: notificationText, + notificationIcon: notificationIcon, + notificationButtons: notificationButtons, + notificationInitialRoute: notificationInitialRoute, + callback: callback, + ); } /// Stop the foreground service. - static Future stopService() async { - try { - if (!(await isRunningService)) { - throw ServiceNotStartedException(); - } - - await _platform.stopService(); - - if (!skipServiceResponseCheck) { - await checkServiceStateChange(target: false); - } - - return const ServiceRequestSuccess(); - } catch (error) { - return ServiceRequestFailure(error: error); - } + static Future stopService() { + return _default.stopService(); } @visibleForTesting - static Future checkServiceStateChange({required bool target}) async { - // official doc: Once the service has been created, the service must call its startForeground() method within 5 seconds. - // ref: https://developer.android.com/guide/components/services#StartingAService - final bool isCompleted = await Utility.instance.completedWithinDeadline( - deadline: const Duration(seconds: 5), - future: () async { - return target == await isRunningService; - }, - ); - - if (!isCompleted) { - throw ServiceTimeoutException(); - } + static Future checkServiceStateChange({required bool target}) { + return _default.checkServiceStateChange(target: target); } /// Returns whether the foreground service is running. - static Future get isRunningService => _platform.isRunningService; + static Future get isRunningService => _default.isRunningService; /// Set up the task handler and start the foreground task. /// @@ -232,128 +192,71 @@ class FlutterForegroundTask { // =================== Communication =================== @visibleForTesting - static ReceivePort? receivePort; + static ReceivePort? get receivePort => _default.receivePort; + + @visibleForTesting + static set receivePort(ReceivePort? v) => _default.receivePort = v; @visibleForTesting - static StreamSubscription? streamSubscription; + static StreamSubscription? get streamSubscription => + _default.streamSubscription; @visibleForTesting - static final List dataCallbacks = []; + static set streamSubscription(StreamSubscription? v) => + _default.streamSubscription = v; + + @visibleForTesting + static List get dataCallbacks => _default.dataCallbacks; /// Initialize port for communication between TaskHandler and UI. static void initCommunicationPort() { - final ReceivePort newReceivePort = ReceivePort(); - final SendPort newSendPort = newReceivePort.sendPort; - - IsolateNameServer.removePortNameMapping(_kPortName); - if (IsolateNameServer.registerPortWithName(newSendPort, _kPortName)) { - streamSubscription?.cancel(); - receivePort?.close(); - - receivePort = newReceivePort; - streamSubscription = receivePort?.listen((data) { - for (final DataCallback callback in dataCallbacks.toList()) { - callback.call(data); - } - }); - } + _default.initCommunicationPort(); } /// Add a callback to receive data sent from the [TaskHandler]. static void addTaskDataCallback(DataCallback callback) { - if (!dataCallbacks.contains(callback)) { - dataCallbacks.add(callback); - } + _default.addTaskDataCallback(callback); } /// Remove a callback to receive data sent from the [TaskHandler]. static void removeTaskDataCallback(DataCallback callback) { - dataCallbacks.remove(callback); + _default.removeTaskDataCallback(callback); } /// Send data to [TaskHandler]. - static void sendDataToTask(Object data) => _platform.sendDataToTask(data); + static void sendDataToTask(Object data) => _default.sendDataToTask(data); - /// Send date to main isolate. + /// Send data to the main isolate. + /// + /// When called from inside a [TaskHandler] this automatically routes to + /// the controller whose service id matches the isolate's own service id, + /// so multi-service setups work without any extra plumbing. static void sendDataToMain(Object data) { - final SendPort? sendPort = IsolateNameServer.lookupPortByName(_kPortName); - sendPort?.send(data); + FlutterForegroundTaskController.of(currentServiceId).sendDataToMain(data); } // ====================== Storage ====================== /// Get the stored data with [key]. - static Future getData({required String key}) async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.reload(); - - final Object? data = prefs.get(_kPrefsKeyPrefix + key); - - return (data is T) ? data : null; - } + static Future getData({required String key}) => + _default.getData(key: key); /// Get all stored data. - static Future> getAllData() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.reload(); - - final Map dataList = {}; - for (final String prefsKey in prefs.getKeys()) { - if (prefsKey.contains(_kPrefsKeyPrefix)) { - final Object? data = prefs.get(prefsKey); - if (data != null) { - final String originKey = prefsKey.replaceAll(_kPrefsKeyPrefix, ''); - dataList[originKey] = data; - } - } - } - - return dataList; - } + static Future> getAllData() => _default.getAllData(); /// Save data with [key]. static Future saveData({ required String key, required Object value, - }) async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.reload(); - - final String prefsKey = _kPrefsKeyPrefix + key; - if (value is int) { - return prefs.setInt(prefsKey, value); - } else if (value is double) { - return prefs.setDouble(prefsKey, value); - } else if (value is String) { - return prefs.setString(prefsKey, value); - } else if (value is bool) { - return prefs.setBool(prefsKey, value); - } else { - return false; - } - } + }) => + _default.saveData(key: key, value: value); /// Remove data with [key]. - static Future removeData({required String key}) async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.reload(); - - return prefs.remove(_kPrefsKeyPrefix + key); - } + static Future removeData({required String key}) => + _default.removeData(key: key); /// Clears all stored data. - static Future clearAllData() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.reload(); - - for (final String prefsKey in prefs.getKeys()) { - if (prefsKey.contains(_kPrefsKeyPrefix)) { - await prefs.remove(prefsKey); - } - } - - return true; - } + static Future clearAllData() => _default.clearAllData(); // ====================== Utility ====================== @@ -429,7 +332,7 @@ class FlutterForegroundTask { /// /// [progress] must be in the range `0.0` (just started) to `1.0` (done) and /// is clamped automatically. Call this method at least once per reporting - /// interval — the system will expire a continued processing task that stops + /// interval -- the system will expire a continued processing task that stops /// reporting progress. /// /// This is a no-op on Android and on iOS versions prior to 26, as well as diff --git a/lib/flutter_foreground_task_controller.dart b/lib/flutter_foreground_task_controller.dart new file mode 100644 index 00000000..a6665d0d --- /dev/null +++ b/lib/flutter_foreground_task_controller.dart @@ -0,0 +1,370 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'dart:ui'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'flutter_foreground_task_platform_interface.dart'; +import 'errors/service_already_started_exception.dart'; +import 'errors/service_not_initialized_exception.dart'; +import 'errors/service_not_started_exception.dart'; +import 'errors/service_timeout_exception.dart'; +import 'models/foreground_service_types.dart'; +import 'models/foreground_task_options.dart'; +import 'models/notification_button.dart'; +import 'models/notification_icon.dart'; +import 'models/notification_options.dart'; +import 'models/service_request_result.dart'; +import 'task_handler.dart'; +import 'utils/utility.dart'; + +typedef DataCallback = void Function(Object data); + +/// A controller for managing a single foreground service instance. +/// +/// Use [FlutterForegroundTaskController.of] to obtain a controller for a +/// given service id. The controller caches instances so the same id always +/// returns the same controller. +/// +/// The `"default"` id corresponds to the built-in [ForegroundService] and +/// is what [FlutterForegroundTask] uses under the hood for backward +/// compatibility. +class FlutterForegroundTaskController { + FlutterForegroundTaskController._(this.id); + + /// The service identifier this controller manages. + final String id; + + static final Map _instances = {}; + + /// Returns the controller for the given [id], creating one if necessary. + factory FlutterForegroundTaskController.of(String id) => + _instances.putIfAbsent(id, () => FlutterForegroundTaskController._(id)); + + /// Returns the service id of the task isolate currently executing this + /// code, or `"default"` when called from the UI isolate / before the + /// service has finished starting. + /// + /// Set by the native side via the `onServiceIdSet` background channel + /// call once the [ForegroundTask] starts. + static String get currentServiceId => _currentServiceId; + static String _currentServiceId = 'default'; + + /// Internal: set by [MethodChannelFlutterForegroundTask.onBackgroundChannel] + /// when the native side notifies this isolate of its service id. + static void setCurrentServiceId(String id) { + _currentServiceId = id; + } + + // platform instance + static FlutterForegroundTaskPlatform get _platform => + FlutterForegroundTaskPlatform.instance; + + // ====================== State ====================== + + AndroidNotificationOptions? androidNotificationOptions; + + IOSNotificationOptions? iosNotificationOptions; + + ForegroundTaskOptions? foregroundTaskOptions; + + bool isInitialized = false; + + bool skipServiceResponseCheck = false; + + /// Resets this controller's state to allow for testing of service flow. + void resetState() { + androidNotificationOptions = null; + iosNotificationOptions = null; + foregroundTaskOptions = null; + isInitialized = false; + skipServiceResponseCheck = false; + + receivePort?.close(); + receivePort = null; + streamSubscription?.cancel(); + streamSubscription = null; + dataCallbacks.clear(); + + // `currentServiceId` is shared process-wide; reset it to the default + // so individual tests don't leak state across each other. + _currentServiceId = 'default'; + } + + /// Initialize this controller's service configuration. + void init({ + required AndroidNotificationOptions androidNotificationOptions, + required IOSNotificationOptions iosNotificationOptions, + required ForegroundTaskOptions foregroundTaskOptions, + }) { + this.androidNotificationOptions = androidNotificationOptions; + this.iosNotificationOptions = iosNotificationOptions; + this.foregroundTaskOptions = foregroundTaskOptions; + isInitialized = true; + } + + /// Start the foreground service. + Future startService({ + int? serviceId, + List? serviceTypes, + required String notificationTitle, + required String notificationText, + NotificationIcon? notificationIcon, + List? notificationButtons, + String? notificationInitialRoute, + Function? callback, + }) async { + try { + if (!isInitialized) { + throw ServiceNotInitializedException(); + } + + if (await isRunningService) { + throw ServiceAlreadyStartedException(); + } + + await _platform.startService( + serviceId: id, + androidNotificationOptions: androidNotificationOptions!, + iosNotificationOptions: iosNotificationOptions!, + foregroundTaskOptions: foregroundTaskOptions!, + notificationId: serviceId, + serviceTypes: serviceTypes, + notificationTitle: notificationTitle, + notificationText: notificationText, + notificationIcon: notificationIcon, + notificationButtons: notificationButtons, + notificationInitialRoute: notificationInitialRoute, + callback: callback, + ); + + if (!skipServiceResponseCheck) { + await checkServiceStateChange(target: true); + } + + return const ServiceRequestSuccess(); + } catch (error) { + return ServiceRequestFailure(error: error); + } + } + + /// Restart the foreground service. + Future restartService() async { + try { + if (!(await isRunningService)) { + throw ServiceNotStartedException(); + } + + await _platform.restartService(serviceId: id); + + return const ServiceRequestSuccess(); + } catch (error) { + return ServiceRequestFailure(error: error); + } + } + + /// Update the foreground service. + Future updateService({ + ForegroundTaskOptions? foregroundTaskOptions, + String? notificationTitle, + String? notificationText, + NotificationIcon? notificationIcon, + List? notificationButtons, + String? notificationInitialRoute, + Function? callback, + }) async { + try { + if (!(await isRunningService)) { + throw ServiceNotStartedException(); + } + + await _platform.updateService( + serviceId: id, + foregroundTaskOptions: foregroundTaskOptions, + notificationText: notificationText, + notificationTitle: notificationTitle, + notificationIcon: notificationIcon, + notificationButtons: notificationButtons, + notificationInitialRoute: notificationInitialRoute, + callback: callback, + ); + + return const ServiceRequestSuccess(); + } catch (error) { + return ServiceRequestFailure(error: error); + } + } + + /// Stop the foreground service. + Future stopService() async { + try { + if (!(await isRunningService)) { + throw ServiceNotStartedException(); + } + + await _platform.stopService(serviceId: id); + + if (!skipServiceResponseCheck) { + await checkServiceStateChange(target: false); + } + + return const ServiceRequestSuccess(); + } catch (error) { + return ServiceRequestFailure(error: error); + } + } + + Future checkServiceStateChange({required bool target}) async { + final bool isCompleted = await Utility.instance.completedWithinDeadline( + deadline: const Duration(seconds: 5), + future: () async { + return target == await isRunningService; + }, + ); + + if (!isCompleted) { + throw ServiceTimeoutException(); + } + } + + /// Returns whether this controller's foreground service is running. + Future get isRunningService => + _platform.isRunningService(serviceId: id); + + /// Set up the task handler and start the foreground task. + /// + /// It must always be called from a top-level function, otherwise + /// foreground task will not work. + void setTaskHandler(TaskHandler handler) => + _platform.setTaskHandler(handler); + + // =================== Communication =================== + + ReceivePort? receivePort; + + StreamSubscription? streamSubscription; + + final List dataCallbacks = []; + + String get _portName => 'flutter_foreground_task/isolateComPort/$id'; + + /// Initialize port for communication between TaskHandler and UI. + void initCommunicationPort() { + final ReceivePort newReceivePort = ReceivePort(); + final SendPort newSendPort = newReceivePort.sendPort; + + IsolateNameServer.removePortNameMapping(_portName); + if (IsolateNameServer.registerPortWithName(newSendPort, _portName)) { + streamSubscription?.cancel(); + receivePort?.close(); + + receivePort = newReceivePort; + streamSubscription = receivePort?.listen((data) { + for (final DataCallback callback in dataCallbacks.toList()) { + callback.call(data); + } + }); + } + } + + /// Add a callback to receive data sent from the [TaskHandler]. + void addTaskDataCallback(DataCallback callback) { + if (!dataCallbacks.contains(callback)) { + dataCallbacks.add(callback); + } + } + + /// Remove a callback to receive data sent from the [TaskHandler]. + void removeTaskDataCallback(DataCallback callback) { + dataCallbacks.remove(callback); + } + + /// Send data to [TaskHandler]. + void sendDataToTask(Object data) => + _platform.sendDataToTask(data, serviceId: id); + + /// Send data to main isolate. + void sendDataToMain(Object data) { + final SendPort? sendPort = + IsolateNameServer.lookupPortByName(_portName); + sendPort?.send(data); + } + + // ====================== Storage ====================== + + static const String _kPrefsKeyPrefix = + 'com.pravera.flutter_foreground_task.prefs.'; + + /// Get the stored data with [key]. + Future getData({required String key}) async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.reload(); + + final Object? data = prefs.get(_kPrefsKeyPrefix + key); + + return (data is T) ? data : null; + } + + /// Get all stored data. + Future> getAllData() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.reload(); + + final Map dataList = {}; + for (final String prefsKey in prefs.getKeys()) { + if (prefsKey.contains(_kPrefsKeyPrefix)) { + final Object? data = prefs.get(prefsKey); + if (data != null) { + final String originKey = prefsKey.replaceAll(_kPrefsKeyPrefix, ''); + dataList[originKey] = data; + } + } + } + + return dataList; + } + + /// Save data with [key]. + Future saveData({ + required String key, + required Object value, + }) async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.reload(); + + final String prefsKey = _kPrefsKeyPrefix + key; + if (value is int) { + return prefs.setInt(prefsKey, value); + } else if (value is double) { + return prefs.setDouble(prefsKey, value); + } else if (value is String) { + return prefs.setString(prefsKey, value); + } else if (value is bool) { + return prefs.setBool(prefsKey, value); + } else { + return false; + } + } + + /// Remove data with [key]. + Future removeData({required String key}) async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.reload(); + + return prefs.remove(_kPrefsKeyPrefix + key); + } + + /// Clears all stored data. + Future clearAllData() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.reload(); + + for (final String prefsKey in prefs.getKeys()) { + if (prefsKey.contains(_kPrefsKeyPrefix)) { + await prefs.remove(prefsKey); + } + } + + return true; + } +} diff --git a/lib/flutter_foreground_task_method_channel.dart b/lib/flutter_foreground_task_method_channel.dart index 0092a686..1135027e 100644 --- a/lib/flutter_foreground_task_method_channel.dart +++ b/lib/flutter_foreground_task_method_channel.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:platform/platform.dart'; +import 'flutter_foreground_task_controller.dart'; import 'flutter_foreground_task_platform_interface.dart'; import 'models/foreground_service_types.dart'; import 'models/foreground_task_options.dart'; @@ -37,7 +38,8 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { required AndroidNotificationOptions androidNotificationOptions, required IOSNotificationOptions iosNotificationOptions, required ForegroundTaskOptions foregroundTaskOptions, - int? serviceId, + String serviceId = 'default', + int? notificationId, List? serviceTypes, required String notificationTitle, required String notificationText, @@ -48,6 +50,7 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { }) async { final Map optionsJson = ServiceStartOptions( serviceId: serviceId, + notificationId: notificationId, serviceTypes: serviceTypes, androidNotificationOptions: androidNotificationOptions, iosNotificationOptions: iosNotificationOptions, @@ -64,12 +67,13 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { } @override - Future restartService() async { - await mMDChannel.invokeMethod('restartService'); + Future restartService({String serviceId = 'default'}) async { + await mMDChannel.invokeMethod('restartService', {'serviceId': serviceId}); } @override Future updateService({ + String serviceId = 'default', ForegroundTaskOptions? foregroundTaskOptions, String? notificationTitle, String? notificationText, @@ -79,6 +83,7 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { Function? callback, }) async { final Map optionsJson = ServiceUpdateOptions( + serviceId: serviceId, foregroundTaskOptions: foregroundTaskOptions, notificationContentTitle: notificationTitle, notificationContentText: notificationText, @@ -92,13 +97,14 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { } @override - Future stopService() async { - await mMDChannel.invokeMethod('stopService'); + Future stopService({String serviceId = 'default'}) async { + await mMDChannel.invokeMethod('stopService', {'serviceId': serviceId}); } @override - Future get isRunningService async { - return await mMDChannel.invokeMethod('isRunningService'); + Future isRunningService({String serviceId = 'default'}) async { + return await mMDChannel + .invokeMethod('isRunningService', {'serviceId': serviceId}); } @override @@ -128,6 +134,12 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { final DateTime timestamp = DateTime.timestamp(); switch (call.method) { + case 'onServiceIdSet': + final Object? arg = call.arguments; + if (arg is String) { + FlutterForegroundTaskController.setCurrentServiceId(arg); + } + break; case 'onStart': final TaskStarter starter = TaskStarter.fromIndex(call.arguments); await handler.onStart(timestamp, starter); @@ -166,8 +178,11 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { // =================== Communication =================== @override - void sendDataToTask(Object data) { - mMDChannel.invokeMethod('sendData', data); + void sendDataToTask(Object data, {String serviceId = 'default'}) { + mMDChannel.invokeMethod('sendData', { + 'serviceId': serviceId, + 'data': data, + }); } // ====================== Utility ====================== diff --git a/lib/flutter_foreground_task_platform_interface.dart b/lib/flutter_foreground_task_platform_interface.dart index ec900419..b0e05960 100644 --- a/lib/flutter_foreground_task_platform_interface.dart +++ b/lib/flutter_foreground_task_platform_interface.dart @@ -37,7 +37,8 @@ abstract class FlutterForegroundTaskPlatform extends PlatformInterface { required AndroidNotificationOptions androidNotificationOptions, required IOSNotificationOptions iosNotificationOptions, required ForegroundTaskOptions foregroundTaskOptions, - int? serviceId, + String serviceId = 'default', + int? notificationId, List? serviceTypes, required String notificationTitle, required String notificationText, @@ -49,11 +50,12 @@ abstract class FlutterForegroundTaskPlatform extends PlatformInterface { throw UnimplementedError('startService() has not been implemented.'); } - Future restartService() { + Future restartService({String serviceId = 'default'}) { throw UnimplementedError('restartService() has not been implemented.'); } Future updateService({ + String serviceId = 'default', ForegroundTaskOptions? foregroundTaskOptions, String? notificationTitle, String? notificationText, @@ -65,11 +67,11 @@ abstract class FlutterForegroundTaskPlatform extends PlatformInterface { throw UnimplementedError('updateService() has not been implemented.'); } - Future stopService() { + Future stopService({String serviceId = 'default'}) { throw UnimplementedError('stopService() has not been implemented.'); } - Future get isRunningService { + Future isRunningService({String serviceId = 'default'}) { throw UnimplementedError('isRunningService has not been implemented.'); } @@ -83,7 +85,7 @@ abstract class FlutterForegroundTaskPlatform extends PlatformInterface { // =================== Communication =================== - void sendDataToTask(Object data) { + void sendDataToTask(Object data, {String serviceId = 'default'}) { throw UnimplementedError('sendDataToTask() has not been implemented.'); } diff --git a/lib/models/service_options.dart b/lib/models/service_options.dart index 4e4f391a..3f6340b2 100644 --- a/lib/models/service_options.dart +++ b/lib/models/service_options.dart @@ -10,7 +10,8 @@ import 'notification_options.dart'; class ServiceStartOptions { const ServiceStartOptions({ - this.serviceId, + this.serviceId = 'default', + this.notificationId, this.serviceTypes, required this.androidNotificationOptions, required this.iosNotificationOptions, @@ -23,7 +24,8 @@ class ServiceStartOptions { this.callback, }); - final int? serviceId; + final String serviceId; + final int? notificationId; final List? serviceTypes; final AndroidNotificationOptions androidNotificationOptions; final IOSNotificationOptions iosNotificationOptions; @@ -47,6 +49,10 @@ class ServiceStartOptions { 'initialRoute': notificationInitialRoute, }; + if (notificationId != null) { + json['notificationId'] = notificationId; + } + if (platform.isAndroid) { json.addAll(androidNotificationOptions.toJson()); } else if (platform.isIOS) { @@ -64,6 +70,7 @@ class ServiceStartOptions { class ServiceUpdateOptions { const ServiceUpdateOptions({ + this.serviceId = 'default', required this.foregroundTaskOptions, required this.notificationContentTitle, required this.notificationContentText, @@ -73,6 +80,7 @@ class ServiceUpdateOptions { this.callback, }); + final String serviceId; final ForegroundTaskOptions? foregroundTaskOptions; final String? notificationContentTitle; final String? notificationContentText; @@ -83,6 +91,7 @@ class ServiceUpdateOptions { Map toJson(Platform platform) { final Map json = { + 'serviceId': serviceId, 'notificationContentTitle': notificationContentTitle, 'notificationContentText': notificationContentText, 'icon': notificationIcon?.toJson(), diff --git a/test/dummy/service_dummy_data.dart b/test/dummy/service_dummy_data.dart index 9e1e7eeb..66c668a7 100644 --- a/test/dummy/service_dummy_data.dart +++ b/test/dummy/service_dummy_data.dart @@ -37,7 +37,7 @@ class ServiceDummyData { allowWakeLock: true, ); - final int serviceId = 200; + final int notificationId = 200; final String notificationTitle = 'title'; @@ -57,7 +57,7 @@ class ServiceDummyData { Map getStartServiceArgs(Platform platform) { return ServiceStartOptions( - serviceId: serviceId, + notificationId: notificationId, androidNotificationOptions: androidNotificationOptions, iosNotificationOptions: iosNotificationOptions, foregroundTaskOptions: foregroundTaskOptions, diff --git a/test/service_api_test.dart b/test/service_api_test.dart index 13a0196a..6be046dd 100644 --- a/test/service_api_test.dart +++ b/test/service_api_test.dart @@ -137,7 +137,7 @@ void main() { expect(result2, isA()); expect( methodCallHandler.log.last, - isMethodCall(ServiceApiMethod.restartService, arguments: null), + isMethodCall(ServiceApiMethod.restartService, arguments: {'serviceId': 'default'}), ); }); @@ -195,7 +195,7 @@ void main() { expect(result2, isA()); expect( methodCallHandler.log.last, - isMethodCall(ServiceApiMethod.stopService, arguments: null), + isMethodCall(ServiceApiMethod.stopService, arguments: {'serviceId': 'default'}), ); }); @@ -236,35 +236,35 @@ void main() { expect(await _isRunningService, false); expect( methodCallHandler.log.last, - isMethodCall(ServiceApiMethod.isRunningService, arguments: null), + isMethodCall(ServiceApiMethod.isRunningService, arguments: {'serviceId': 'default'}), ); await _startService(dummyData); expect(await _isRunningService, true); expect( methodCallHandler.log.last, - isMethodCall(ServiceApiMethod.isRunningService, arguments: null), + isMethodCall(ServiceApiMethod.isRunningService, arguments: {'serviceId': 'default'}), ); await _restartService(); expect(await _isRunningService, true); expect( methodCallHandler.log.last, - isMethodCall(ServiceApiMethod.isRunningService, arguments: null), + isMethodCall(ServiceApiMethod.isRunningService, arguments: {'serviceId': 'default'}), ); await _updateService(dummyData); expect(await _isRunningService, true); expect( methodCallHandler.log.last, - isMethodCall(ServiceApiMethod.isRunningService, arguments: null), + isMethodCall(ServiceApiMethod.isRunningService, arguments: {'serviceId': 'default'}), ); await _stopService(); expect(await _isRunningService, false); expect( methodCallHandler.log.last, - isMethodCall(ServiceApiMethod.isRunningService, arguments: null), + isMethodCall(ServiceApiMethod.isRunningService, arguments: {'serviceId': 'default'}), ); }); }); @@ -369,7 +369,7 @@ void main() { expect(result2, isA()); expect( methodCallHandler.log.last, - isMethodCall(ServiceApiMethod.restartService, arguments: null), + isMethodCall(ServiceApiMethod.restartService, arguments: {'serviceId': 'default'}), ); }); @@ -427,7 +427,7 @@ void main() { expect(result2, isA()); expect( methodCallHandler.log.last, - isMethodCall(ServiceApiMethod.stopService, arguments: null), + isMethodCall(ServiceApiMethod.stopService, arguments: {'serviceId': 'default'}), ); }); @@ -468,35 +468,35 @@ void main() { expect(await _isRunningService, false); expect( methodCallHandler.log.last, - isMethodCall(ServiceApiMethod.isRunningService, arguments: null), + isMethodCall(ServiceApiMethod.isRunningService, arguments: {'serviceId': 'default'}), ); await _startService(dummyData); expect(await _isRunningService, true); expect( methodCallHandler.log.last, - isMethodCall(ServiceApiMethod.isRunningService, arguments: null), + isMethodCall(ServiceApiMethod.isRunningService, arguments: {'serviceId': 'default'}), ); await _restartService(); expect(await _isRunningService, true); expect( methodCallHandler.log.last, - isMethodCall(ServiceApiMethod.isRunningService, arguments: null), + isMethodCall(ServiceApiMethod.isRunningService, arguments: {'serviceId': 'default'}), ); await _updateService(dummyData); expect(await _isRunningService, true); expect( methodCallHandler.log.last, - isMethodCall(ServiceApiMethod.isRunningService, arguments: null), + isMethodCall(ServiceApiMethod.isRunningService, arguments: {'serviceId': 'default'}), ); await _stopService(); expect(await _isRunningService, false); expect( methodCallHandler.log.last, - isMethodCall(ServiceApiMethod.isRunningService, arguments: null), + isMethodCall(ServiceApiMethod.isRunningService, arguments: {'serviceId': 'default'}), ); }); }); @@ -512,7 +512,7 @@ void _init(ServiceDummyData dummyData) { Future _startService(ServiceDummyData dummyData) { return FlutterForegroundTask.startService( - serviceId: dummyData.serviceId, + serviceId: dummyData.notificationId, notificationTitle: dummyData.notificationTitle, notificationText: dummyData.notificationText, notificationIcon: dummyData.notificationIcon, diff --git a/test/task_handler_test.dart b/test/task_handler_test.dart index 4b011317..2a19c09b 100644 --- a/test/task_handler_test.dart +++ b/test/task_handler_test.dart @@ -24,7 +24,9 @@ void main() { (MethodCall methodCall) async { final String method = methodCall.method; if (method == 'sendData') { - final dynamic data = methodCall.arguments; + final args = methodCall.arguments; + final dynamic data = + (args is Map) ? args['data'] : args; platformChannel.mBGChannel .invokeMethod(TaskEventMethod.onReceiveData, data); }