diff --git a/CHANGELOG.md b/CHANGELOG.md index 6917c336..f1e6a09b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ ## Unreleased * [**FEAT**] Add iOS 26+ `BGContinuedProcessingTask` support via `IOSContinuedProcessingTaskOptions`. When provided on `IOSNotificationOptions.continuedProcessingTask`, starting the service submits a continued processing task so the system manages progress UI and continues the work while the app is backgrounded. Report progress with `FlutterForegroundTask.updateIOSContinuedProcessingTaskProgress` and the task completes automatically when the service stops. [#349](https://github.com/Dev-hwang/flutter_foreground_task/issues/349). See [ios_continued_processing_task.md](./documentation/ios_continued_processing_task.md) for the setup guide. +* [**FEAT**] Add `TaskExecutionMode` (`dedicatedEngine`, `mergedEngine`, `backgroundIsolate`) on `ForegroundTaskOptions`. Default is `mergedEngine`, which matches Flutter 3.29+ behaviour where the background `FlutterEngine` is multiplexed on the platform thread. Opt into `dedicatedEngine` plus the platform-specific opt-out flag (`DisableMergedPlatformUIThread` on Android, `FLTEnableMergedPlatformUIThread=false` on iOS) to keep using a separate OS thread on engines that still support it; the plugin logs a warning and falls back to `mergedEngine` when the flag is missing. `backgroundIsolate` is reserved for a future pure-Dart isolate implementation and currently falls back to `mergedEngine`. See [threading_model.md](./documentation/threading_model.md). +* [**FEAT**] Add `ForegroundTaskCallbackRelay` in `lib/utils/foreground_task_callback_relay.dart`, a `NativeCallable.listener`-based FFI helper that delivers raw byte payloads from native code into a Dart isolate. Plugin authors that today rely on `MethodChannel.setMessageHandler` for events can use the relay to keep delivering callbacks when running inside a background isolate where `BackgroundIsolateBinaryMessenger` cannot. The relay is self-contained and usable from any isolate. See [§2.5.4 / §7.3 of merged_platform_ui_thread_mitigation.md](./documentation/merged_platform_ui_thread_mitigation.md) for the surrounding design and the buffer-lifetime contract. +* [**REFACTOR**] Internal Dart-side scaffolding for `TaskExecutionMode.backgroundIsolate`: add the bootstrap entrypoint (`foregroundTaskBackgroundIsolateBootstrap`) that runs inside a secondary `FlutterEngine`, a nested-isolate dispatcher that hosts the user's `TaskHandler` on a separate Dart VM thread via `Isolate.spawn`, and a `SendPort`-based lifecycle protocol that forwards `onStart`/`onRepeatEvent`/`onDestroy` across the two isolates. `FlutterForegroundTask.setTaskHandler` now branches to the dispatcher when running in the nested isolate. `ServiceStartOptions` / `ServiceUpdateOptions` include a `bootstrapCallbackHandle` field in the native payload whenever `executionMode == backgroundIsolate`. This is the Dart half of Phase 2 from the mitigation plan; Android / iOS native plumbing still downgrades `backgroundIsolate` to `mergedEngine` until the on-device wiring lands. No runtime behaviour change for existing users. +* [**FEAT**] Add `FlutterForegroundTask.debugThreadId()` returning the OS thread id of the calling Dart isolate. Implemented via `package:universal_ffi/ffi.dart` (`gettid` on Android, `pthread_mach_thread_np(pthread_self())` on iOS/macOS) so the probe runs in-process in whichever isolate invoked it. The earlier prototype routed through a `MethodChannel` and therefore always measured the platform main thread — that implementation has been removed. Useful for verifying which `TaskExecutionMode` is actually in effect at runtime. ## 9.2.2 diff --git a/README.md b/README.md index c58fe6e5..046b008d 100644 --- a/README.md +++ b/README.md @@ -677,6 +677,8 @@ Go [here](./documentation/migration_documentation.md) to `migrate` to the new ve Go [here](./documentation/ios_continued_processing_task.md) to set up iOS 26+ `BGContinuedProcessingTask` support for long-running user-initiated work. +Go [here](./documentation/threading_model.md) to understand the Flutter 3.29+ merged platform/UI thread change, the available `TaskExecutionMode` values (`mergedEngine`, `dedicatedEngine`, `backgroundIsolate`), and how to verify the effective model with `FlutterForegroundTask.debugThreadId()`. + > [!WARNING] > `BGContinuedProcessingTask` requires **Xcode 26+** (Swift 6.2+). If you configure `IOSContinuedProcessingTaskOptions` but build with an older Xcode, the plugin will raise a fatal error at runtime. Either upgrade Xcode or leave the `continuedProcessingTask` option as `null`. See the [continued processing task documentation](./documentation/ios_continued_processing_task.md#xcode-version-requirement) for details. diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/PreferencesKey.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/PreferencesKey.kt index 8556df75..3fbdd49a 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/PreferencesKey.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/PreferencesKey.kt @@ -52,6 +52,7 @@ object PreferencesKey { const val ALLOW_WIFI_LOCK = "allowWifiLock" const val ALLOW_AUTO_RESTART = "allowAutoRestart" const val STOP_WITH_TASK = "stopWithTask" + const val EXECUTION_MODE = "executionMode" // task data const val CALLBACK_HANDLE = "callbackHandle" 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 f5a57b4a..1eaf5d8b 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 @@ -13,7 +13,8 @@ data class ForegroundTaskOptions( val allowWakeLock: Boolean, val allowWifiLock: Boolean, val allowAutoRestart: Boolean, - val stopWithTask: Boolean? + val stopWithTask: Boolean?, + val executionMode: TaskExecutionMode, ) { companion object { fun getData(context: Context, serviceId: String = FlutterForegroundServiceRegistry.DEFAULT_ID): ForegroundTaskOptions { @@ -44,6 +45,9 @@ data class ForegroundTaskOptions( prefs.getBoolean(PrefsKey.STOP_WITH_TASK, false) else null + val executionMode = TaskExecutionMode.fromRawValue( + prefs.getString(PrefsKey.EXECUTION_MODE, null) + ) return ForegroundTaskOptions( eventAction = eventAction, @@ -53,6 +57,7 @@ data class ForegroundTaskOptions( allowWifiLock = allowWifiLock, allowAutoRestart = allowAutoRestart, stopWithTask = stopWithTask, + executionMode = executionMode, ) } @@ -72,6 +77,8 @@ data class ForegroundTaskOptions( val allowWifiLock = map?.get(PrefsKey.ALLOW_WIFI_LOCK) as? Boolean ?: false val allowAutoRestart = map?.get(PrefsKey.ALLOW_AUTO_RESTART) as? Boolean ?: false val stopWithTask = map?.get(PrefsKey.STOP_WITH_TASK) as? Boolean + val executionMode = map?.get(PrefsKey.EXECUTION_MODE) as? String + ?: TaskExecutionMode.MERGED_ENGINE.rawValue with(prefs.edit()) { putString(PrefsKey.TASK_EVENT_ACTION, eventActionJsonString) @@ -81,6 +88,7 @@ data class ForegroundTaskOptions( putBoolean(PrefsKey.ALLOW_WIFI_LOCK, allowWifiLock) putBoolean(PrefsKey.ALLOW_AUTO_RESTART, allowAutoRestart) stopWithTask?.let { putBoolean(PrefsKey.STOP_WITH_TASK, it) } ?: remove(PrefsKey.STOP_WITH_TASK) + putString(PrefsKey.EXECUTION_MODE, executionMode) commit() } } @@ -101,6 +109,7 @@ data class ForegroundTaskOptions( val allowWifiLock = map?.get(PrefsKey.ALLOW_WIFI_LOCK) as? Boolean val allowAutoRestart = map?.get(PrefsKey.ALLOW_AUTO_RESTART) as? Boolean val stopWithTask = map?.get(PrefsKey.STOP_WITH_TASK) as? Boolean + val executionMode = map?.get(PrefsKey.EXECUTION_MODE) as? String with(prefs.edit()) { eventActionJsonString?.let { putString(PrefsKey.TASK_EVENT_ACTION, it) } @@ -110,6 +119,7 @@ data class ForegroundTaskOptions( allowWifiLock?.let { putBoolean(PrefsKey.ALLOW_WIFI_LOCK, it) } allowAutoRestart?.let { putBoolean(PrefsKey.ALLOW_AUTO_RESTART, it) } stopWithTask?.let { putBoolean(PrefsKey.STOP_WITH_TASK, it) } ?: remove(PrefsKey.STOP_WITH_TASK) + executionMode?.let { putString(PrefsKey.EXECUTION_MODE, it) } commit() } } diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/TaskExecutionMode.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/TaskExecutionMode.kt new file mode 100644 index 00000000..2721773b --- /dev/null +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/TaskExecutionMode.kt @@ -0,0 +1,38 @@ +package com.pravera.flutter_foreground_task.models + +/** + * Controls where the TaskHandler runs when the foreground service starts. + * + * See `documentation/threading_model.md` for full context. The short + * version: + * + * - [DEDICATED_ENGINE]: old pre-3.29 behavior. The plugin creates a + * secondary `FlutterEngine` and relies on the + * `DisableMergedPlatformUIThread` AndroidManifest meta-data being set so + * the engine's UI isolate gets its own OS thread. If the flag is missing + * or ineffective (Flutter 3.38+) the plugin logs a one-shot warning and + * behaves as [MERGED_ENGINE]. + * + * - [MERGED_ENGINE]: secondary `FlutterEngine` multiplexed onto the main + * platform thread. Matches Flutter's own 3.29+ default. **Safe default.** + * + * - [BACKGROUND_ISOLATE]: reserved enum value. End-to-end support is + * implemented in a follow-up release. Today the service falls back to + * [MERGED_ENGINE] with a one-shot log line so apps can prepare for the + * migration. + */ +enum class TaskExecutionMode(val rawValue: String) { + DEDICATED_ENGINE("dedicatedEngine"), + MERGED_ENGINE("mergedEngine"), + BACKGROUND_ISOLATE("backgroundIsolate"); + + companion object { + /** + * Parses the raw string shipped by the Dart side. Unknown values + * fall back to [MERGED_ENGINE]. + */ + fun fromRawValue(value: String?): TaskExecutionMode { + return entries.firstOrNull { it.rawValue == value } ?: MERGED_ENGINE + } + } +} 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 index 8561b41c..d4e860cf 100644 --- 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 @@ -455,6 +455,7 @@ abstract class FlutterForegroundServiceBase : Service() { serviceStatus = foregroundServiceStatus, taskData = foregroundTaskData, taskEventAction = foregroundTaskOptions.eventAction, + requestedExecutionMode = foregroundTaskOptions.executionMode, taskLifecycleListener = ForegroundServiceRuntime.listeners(serviceId) ) ForegroundServiceRuntime.setTask(serviceId, task) 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 f9113323..3c1e3211 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 @@ -9,6 +9,8 @@ import com.pravera.flutter_foreground_task.models.ForegroundServiceStatus import com.pravera.flutter_foreground_task.models.ForegroundTaskData import com.pravera.flutter_foreground_task.models.ForegroundTaskEventAction import com.pravera.flutter_foreground_task.models.ForegroundTaskEventType +import com.pravera.flutter_foreground_task.models.TaskExecutionMode +import com.pravera.flutter_foreground_task.utils.MergedThreadOptOutDetector import io.flutter.FlutterInjector import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.dart.DartExecutor @@ -29,6 +31,7 @@ class ForegroundTask( private val serviceStatus: ForegroundServiceStatus, private val taskData: ForegroundTaskData, private var taskEventAction: ForegroundTaskEventAction, + requestedExecutionMode: TaskExecutionMode, private val taskLifecycleListener: FlutterForegroundTaskLifecycleListener, ) : MethodChannel.MethodCallHandler { companion object { @@ -38,8 +41,22 @@ class ForegroundTask( private const val ACTION_TASK_START = "onStart" private const val ACTION_TASK_REPEAT_EVENT = "onRepeatEvent" private const val ACTION_TASK_DESTROY = "onDestroy" + + @Volatile + private var hasWarnedBackgroundIsolate = false } + /** + * Effective execution mode after reconciliation with platform + * capabilities. [TaskExecutionMode.BACKGROUND_ISOLATE] is reserved — + * it is accepted at the API boundary but downgraded to + * [TaskExecutionMode.MERGED_ENGINE] here until the Dart-side isolate + * dispatcher ships (see `documentation/merged_platform_ui_thread_mitigation.md`, + * Phase 2). [TaskExecutionMode.DEDICATED_ENGINE] is downgraded when + * the `DisableMergedPlatformUIThread` manifest flag is missing. + */ + val effectiveExecutionMode: TaskExecutionMode + private val flutterEngine: FlutterEngine private val flutterLoader: FlutterLoader private val backgroundChannel: MethodChannel @@ -47,6 +64,8 @@ class ForegroundTask( private var isDestroyed: Boolean = false init { + effectiveExecutionMode = resolveExecutionMode(context, requestedExecutionMode) + // create flutter engine flutterEngine = FlutterEngine(context) flutterLoader = FlutterInjector.instance().flutterLoader() @@ -194,6 +213,26 @@ class ForegroundTask( call() } + private fun resolveExecutionMode( + context: Context, + requested: TaskExecutionMode + ): TaskExecutionMode { + if (requested == TaskExecutionMode.BACKGROUND_ISOLATE) { + if (!hasWarnedBackgroundIsolate) { + hasWarnedBackgroundIsolate = true + Log.w( + TAG, + "TaskExecutionMode.backgroundIsolate was requested but is not " + + "yet fully implemented in this release. Falling back to " + + "TaskExecutionMode.mergedEngine. Track progress in " + + "documentation/merged_platform_ui_thread_mitigation.md (Phase 2)." + ) + } + return TaskExecutionMode.MERGED_ENGINE + } + return MergedThreadOptOutDetector.resolveEffectiveMode(context, requested) + } + private fun MethodChannel.invokeMethod(method: String, data: Any?, onComplete: () -> Unit = {}) { val callback = object : MethodChannel.Result { override fun success(result: Any?) { diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/utils/MergedThreadOptOutDetector.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/utils/MergedThreadOptOutDetector.kt new file mode 100644 index 00000000..61a5099d --- /dev/null +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/utils/MergedThreadOptOutDetector.kt @@ -0,0 +1,109 @@ +package com.pravera.flutter_foreground_task.utils + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import com.pravera.flutter_foreground_task.models.TaskExecutionMode + +/** + * Detects whether the app has opted out of Flutter's merged + * platform/UI-thread behavior via the `DisableMergedPlatformUIThread` + * `` flag in `AndroidManifest.xml`. + * + * Starting with Flutter 3.29 (default on 3.32+) `FlutterEngine` multiplexes + * its UI isolate onto the main platform thread. Apps opt out with: + * + * ```xml + * + * ``` + * + * The flag was removed / stopped being honored in Flutter 3.38. We cannot + * reliably detect the runtime Flutter version from Kotlin, so the plugin + * treats the flag as a hint: when the user asks for + * [TaskExecutionMode.DEDICATED_ENGINE] but the flag is missing, we log a + * one-shot warning and fall back to merged-thread behavior (which is what + * actually happens inside the engine anyway). + * + * When the flag is present we still create the secondary engine the same + * way; on Flutter ≤ 3.37 it will get its own OS thread, on Flutter 3.38+ + * it will be merged regardless. The warning path makes that visible to the + * developer instead of silently degrading. + */ +object MergedThreadOptOutDetector { + private const val TAG = "FlutterForegroundTask" + private const val META_NAME = + "io.flutter.embedding.android.DisableMergedPlatformUIThread" + + @Volatile + private var hasWarnedDedicatedEngine = false + + /** + * Returns `true` when the `DisableMergedPlatformUIThread` manifest + * meta-data is present and set to `true`. Returns `false` on any read + * error. + */ + fun isDisableMergedThreadFlagSet(context: Context): Boolean { + return try { + val pm = context.packageManager + val packageName = context.packageName + val appInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getApplicationInfo( + packageName, + PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA.toLong()) + ) + } else { + @Suppress("DEPRECATION") + pm.getApplicationInfo(packageName, PackageManager.GET_META_DATA) + } + + appInfo.metaData?.let { bundle -> + when (val value = bundle.get(META_NAME)) { + is Boolean -> value + is String -> value.equals("true", ignoreCase = true) + else -> false + } + } ?: false + } catch (e: Exception) { + Log.w(TAG, "Failed to read $META_NAME manifest meta-data: ${e.message}") + false + } + } + + /** + * Inspects the requested [mode] and returns the effective mode the + * plugin should use. When the caller asks for + * [TaskExecutionMode.DEDICATED_ENGINE] but the manifest flag is missing + * we emit a one-shot warning and downgrade to + * [TaskExecutionMode.MERGED_ENGINE]. + * + * The warning is gated so repeated service starts do not spam the log. + * The `hasWarnedDedicatedEngine` flag is intentionally process-wide: + * once the developer sees the warning once per process, further noise + * is unhelpful. + */ + fun resolveEffectiveMode( + context: Context, + mode: TaskExecutionMode + ): TaskExecutionMode { + if (mode != TaskExecutionMode.DEDICATED_ENGINE) return mode + + if (isDisableMergedThreadFlagSet(context)) return mode + + if (!hasWarnedDedicatedEngine) { + hasWarnedDedicatedEngine = true + Log.w( + TAG, + "TaskExecutionMode.dedicatedEngine was requested but the " + + "`DisableMergedPlatformUIThread` AndroidManifest meta-data is " + + "not set. Flutter 3.29+ multiplexes every FlutterEngine onto " + + "the main platform thread, so the task will run on the same " + + "thread as the UI. Falling back to TaskExecutionMode.mergedEngine. " + + "See documentation/threading_model.md for guidance." + ) + } + return TaskExecutionMode.MERGED_ENGINE + } +} diff --git a/documentation/merged_platform_ui_thread_mitigation.md b/documentation/merged_platform_ui_thread_mitigation.md new file mode 100644 index 00000000..7073136c --- /dev/null +++ b/documentation/merged_platform_ui_thread_mitigation.md @@ -0,0 +1,754 @@ +# Plan: Mitigating the Flutter 3.29+ Merged Platform/UI Thread Regression + +> Tracks: [Dev-hwang/flutter_foreground_task#352](https://github.com/Dev-hwang/flutter_foreground_task/issues/352) +> +> Upstream references: +> - [flutter/flutter#150525 — Merge the platform and UI threads](https://github.com/flutter/flutter/issues/150525) +> - [flutter/flutter#169339 — Flutter 3.32.0 threading issue](https://github.com/flutter/flutter/issues/169339) +> - [flutter/flutter#176053 — Proposal: Headless Flutter Engine With Dedicated Thread](https://github.com/flutter/flutter/issues/176053) +> - [dart-lang/sdk#46943 — API to allow setting thread pinning for Isolates](https://github.com/dart-lang/sdk/issues/46943) +> - [flutter/flutter#119207 / #130570 — setMessageHandler in background isolates](https://github.com/flutter/flutter/issues/119207) + +--- + +## 1. TL;DR + +Flutter 3.29 started merging the platform thread with the UI (Dart) thread, and in 3.32 the merge became the default on iOS and Android. From that release on, **all `FlutterEngine` instances in a process multiplex their UI isolates onto the same OS thread (the main/platform thread)**. This plugin's core design — spawning a secondary `FlutterEngine` from a foreground service so heavy Dart work runs off the UI thread — no longer achieves thread isolation. + +The temporary escape hatches (`DisableMergedPlatformUIThread` on Android, `FLTEnableMergedPlatformUIThread=false` on iOS) stopped working in **Flutter 3.38**. At the time of writing there is no official replacement yet: + +- `Isolate.spawn` is the Flutter team's preferred migration path, but it does not support `MethodChannel.setMessageHandler` on `BackgroundIsolateBinaryMessenger`, so plugins that deliver events over platform channels (BLE, sensors, location...) break in background isolates. +- Dart SDK is likely to add `Isolate.spawn(..., dedicatedThread: true)` ([dart-lang/sdk#46943](https://github.com/dart-lang/sdk/issues/46943)), but it is not yet implemented and does not fix the callback problem. +- The "headless engine on a dedicated thread" proposal ([flutter/flutter#176053](https://github.com/flutter/flutter/issues/176053)) is `P2` and was pushed back by the engine team. + +This document describes the migration path this plugin should take so that: + +1. Apps on **Flutter ≤ 3.37** keep working without regressions (today's behavior + the escape-hatch flags we document). +2. Apps on **Flutter 3.38+**, where the escape hatch is gone, still get a usable, documented threading model. +3. When upstream APIs (dedicated-thread isolates or headless engines) ship, we can adopt them with minimal breakage. + +--- + +## 2. Known bugs in the current implementation + +The items in this section are concrete regressions or defects in the code as +it currently ships on this branch. They are listed in the order they need to +be fixed; items stay here until they are landed and verified. + +### Bug #1 — `debugThreadId()` measures the wrong thread + +- **Status**: fix landed on this branch. The helper is now backed by a Dart + FFI probe (`lib/utils/thread_id_probe.dart`). Keep this entry until the + integration coverage described in Phase 1 below ships. +- **Severity**: High. This is the helper the documentation tells users to + call to decide whether `dedicatedEngine` actually separated the task + isolate from the UI isolate. If it answers wrong, every derived conclusion + (user guidance, CI signal, fallback logic) is wrong too. + +#### Symptom + +```dart +final uiTid = await FlutterForegroundTask.debugThreadId(); + +class MyTaskHandler extends TaskHandler { + @override + Future onStart(DateTime timestamp, TaskStarter starter) async { + final taskTid = await FlutterForegroundTask.debugThreadId(); + // Observed: uiTid == taskTid on *every* TaskExecutionMode. + // Expected: distinct ids when dedicatedEngine is actually honored. + } +} +``` + +Even with `DisableMergedPlatformUIThread=true` / +`FLTEnableMergedPlatformUIThread=false` set, or with the merged-thread +regression reverted locally, the two TIDs would come back equal because the +helper never looked at the Dart isolate's OS thread in the first place. + +#### Root cause + +The original implementation returned `Process.myTid()` (Android) / +`pthread_mach_thread_np(pthread_self())` (iOS) from inside a default +`MethodChannel` handler: + +- `android/src/main/kotlin/com/pravera/flutter_foreground_task/MethodCallHandlerImpl.kt` +- `android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundTask.kt` +- `ios/.../SwiftFlutterForegroundTaskPlugin.swift` +- `ios/.../service/ForegroundTask.swift` + +Flutter's platform-channel threading rules say that handler invocations are +dispatched on the platform task runner of the target engine **unless** the +channel is created with a `BinaryMessenger.TaskQueue`. None of these +channels were. Per the Flutter engine architecture docs, on Android and iOS +*every* `FlutterEngine` in a process shares the **same** platform task +runner (and therefore the same OS thread) for channel delivery. So the +handlers always executed on the main thread and the probe always returned +the main thread's TID — whether the Dart isolate that invoked the method +was merged onto the platform thread or not. + +The Dart-side glue in `lib/flutter_foreground_task_method_channel.dart` +additionally "preferred the background channel when wired and fell back to +the main channel". Both channels hit the same platform thread, so this +preference ordering only hid the bug behind plausible-looking log output +instead of fixing it. + +#### Fix plan + +1. Move the probe from native into the Dart VM so it executes on whichever + isolate actually called it. +2. Use `package:universal_ffi/ffi.dart` to read the current OS thread id + directly: + - Android: `gettid` from `libc.so`. + - iOS / macOS: `pthread_mach_thread_np(pthread_self())` from the main + process image. + - Other platforms: return `null`. The helper is a debug utility; a + clearly-missing value is strictly better than a silently-wrong value. +3. Delete the stale `debugThreadId` handlers on both Android channels and + the iOS plugin channel. Every code path that used to reach them is now + served by the FFI probe. +4. Keep the public API surface identical + (`FlutterForegroundTask.debugThreadId()` returning `Future`) so + existing user code keeps compiling. +5. Update `documentation/threading_model.md` so the runtime-verification + section describes the shipping behavior instead of an aspirational one. + +#### Verification + +Unit tests cover the Dart-side plumbing by exercising +`MethodChannelFlutterForegroundTask.debugThreadId()` on the host platform +and asserting it returns a positive integer on Android / iOS / macOS and +`null` elsewhere. Integration coverage that compares the UI isolate's TID +with `TaskHandler.onStart`'s TID on a device is still owned by +Phase 1 of the roadmap below. + +--- + +## 2.5 Design revisions adopted from issue #352 maintainer feedback + +After an initial draft of this plan was posted on +[Dev-hwang/flutter_foreground_task#352](https://github.com/Dev-hwang/flutter_foreground_task/issues/352), +@srmncnk (the issue opener) gave concrete feedback that changed the intended +shape of `TaskExecutionMode.backgroundIsolate`. The points below are captured +verbatim in intent and marked with their current status. Each point that +changed the design has a link to the section it now governs so future readers +can follow the chain. + +### 2.5.1 Keep the secondary `FlutterEngine` and spawn a pure Dart `Isolate` *from* it + +> "You can still spawn a new flutter engine and immediately spawn a pure dart +> isolate. That will guarantee continuous execution. It will guarantee a +> different thread than the one from UI." +> — @srmncnk, [2026-04-19](https://github.com/Dev-hwang/flutter_foreground_task/issues/352#issuecomment-3007198987) + +**Status**: ✅ **Adopted.** Replaces the previous design in §7.2.1 that dropped +the secondary engine entirely. The revised topology is: the foreground service +continues to instantiate a secondary `FlutterEngine` (so plugins stay +registered and the process stays alive via the service notification), and the +`TaskHandler` body runs inside a pure Dart `Isolate` spawned from that +engine's isolate. See updated §7.2.1 (Android), §7.2.2 (iOS) and §7.3 +(dispatcher). + +**Why this is strictly better than "spawn from the app's UI isolate"**: + +- Survives OS teardown of the UI engine — the task isolate's parent is the + service's engine, which is kept alive by the foreground notification, not + by the UI. +- Keeps `registerPlugins(engine)` working on the service side, so incoming + plugin callbacks (§2.5.3) have a live messenger to dispatch against. +- Plays nicely with `BackgroundIsolateBinaryMessenger.ensureInitialized(token)` + using the secondary engine's `RootIsolateToken` instead of the app's. + +### 2.5.2 `FlutterEngineGroup` does not help here + +> "IMO separate FlutterEngineGroup does not help." +> — @srmncnk, [2026-04-19](https://github.com/Dev-hwang/flutter_foreground_task/issues/352#issuecomment-3007198987) + +**Status**: ✅ **Confirmed as non-goal.** Already aligned with the plan — no +section referenced `FlutterEngineGroup` as a solution, and Phase 3 only +mentions it as a possible future *option* if upstream ships dedicated-thread +semantics on engine groups. No change required; this entry exists so the +rationale is not lost the next time somebody proposes it. + +### 2.5.3 Thread pinning is not guaranteed until Dart SDK lands it + +> "It will not be always the same thread (still waiting for the [dart] team +> pr to land)." +> — @srmncnk, [2026-04-19](https://github.com/Dev-hwang/flutter_foreground_task/issues/352#issuecomment-3007198987) + +**Status**: ✅ **Acknowledged in design.** The Dart isolate spawned from the +secondary engine will run on Dart's VM thread pool, which today can migrate +between OS threads across GC / suspend points. This is acceptable because: + +- The *UI thread is still distinct* from whichever pool thread is carrying the + task isolate at any moment — that solves the regression §1 describes. +- When [dart-lang/sdk#46943](https://github.com/dart-lang/sdk/issues/46943) + ships `Isolate.spawn(..., dedicatedThread: true)` (or the equivalent), we + flip a single flag in the dispatcher. No API change is required. + +The public documentation (§7.5, `documentation/threading_model.md`) already +calls this out as "separate thread, not a pinned thread"; keep that framing +when writing user-facing copy. + +### 2.5.4 `MethodChannel.setMessageHandler` callbacks can be delivered via FFI + +> "Callbacks won't work but can be achieved via hacking the ffi api." +> "Did you use NativeCallable? +> https://api.dart.dev/dart-ffi/NativeCallable/NativeCallable.listener.html" +> — @srmncnk, +> [2026-04-19](https://github.com/Dev-hwang/flutter_foreground_task/issues/352#issuecomment-3007198987), +> [2026-04-20](https://github.com/Dev-hwang/flutter_foreground_task/issues/352#issuecomment-3017642842) + +**Status**: ✅ **Adopted as the callback-delivery mechanism.** This removes the +"non-starter" caveat in §5 and the corresponding risk row in §10. Concretely: + +- [`NativeCallable.listener`](https://api.dart.dev/dart-ffi/NativeCallable/NativeCallable.listener.html) + creates a native function pointer that posts to the port of the isolate + that created it. Any native thread can invoke it; the Dart-side listener + runs on the *creating* isolate. +- The task isolate creates one `NativeCallable.listener` per callback-bearing + plugin method, hands the pointer to native through an FFI trampoline + (Kotlin/Swift side), and lets native invoke it whenever the underlying + plugin would have invoked `channel.invokeMethod` against a + `BackgroundIsolateBinaryMessenger`. +- Inbound events therefore flow: `native plugin → FFI trampoline → + NativeCallable.listener → task isolate port → user code`, which is + equivalent in latency to a method-channel callback but does not depend on + `setMessageHandler` on `BackgroundIsolateBinaryMessenger` working. + +The implementation shape is described in the revised §7.3 and prototyped in +§8 Phase 2. It is deliberately opt-in per plugin: we ship a **generic +`NativeCallable.listener` relay** so that plugin authors (or app authors) can +bridge their own channel-callback plugins without reinventing the FFI glue. +This supersedes the §5 hard constraint "method-channel callbacks must keep +working" — the constraint is now "*invokeMethod* must keep working, and +incoming callbacks must be reachable via a documented FFI path". + +See the updated §12 open question about whether to ship this relay as part of +the plugin or as a sibling package. + +### 2.5.5 Prior art: the author's FFI callback prototype + +> "I implemented a solution that hacks the ffi for the callbacks, it's in the +> list of issues reported." +> — @ppamorim, [2026-04-19](https://github.com/Dev-hwang/flutter_foreground_task/issues/352#issuecomment-3014013113) + +**Status**: 🔗 **Tracked.** The prototype informs the Phase 2 design in §8. +When the Phase 2 code lands, cross-link the PR from this section so future +readers can see the reference implementation. + +--- + +## 3. Background: what exactly changed in Flutter + +Historically each `FlutterEngine` came with its own OS thread for the UI (Dart) isolate — separate from the platform (main) thread. A foreground service created with this plugin would: + +- Instantiate `FlutterEngine(context)` (Android) or `FlutterEngine(name:, allowHeadlessExecution: true)` (iOS). +- Start a Dart entrypoint via `DartExecutor.executeDartCallback(...)` / `engine.run(withEntrypoint:)`. +- Communicate with it via a `MethodChannel` on that engine's binary messenger. + +Because that second engine had its **own UI thread**, users could run blocking or heavy Dart work in `TaskHandler.onStart/onRepeatEvent/...` without janking the app's UI. + +Starting Flutter 3.29 and defaulting in 3.32: + +- **All UI isolates in the process share the platform thread.** Spawning multiple `FlutterEngine`s now multiplexes them onto a single thread. Heavy work in the background engine directly blocks the main UI. +- `runOnPlatformThread` / platform isolates (`EnablePlatformIsolates`) became supported, enabling synchronous FFI from Dart to platform APIs. +- Two opt-outs were introduced to restore the old behavior globally: + - **Android** (`AndroidManifest.xml`): + ```xml + + ``` + - **iOS** (`Info.plist`): + ```xml + FLTEnableMergedPlatformUIThread + + ``` +- Those flags **stopped being honored in Flutter 3.38.x** (see `#169339` Feb 24 2026 comment). Flutter 3.35.7 is the last stable version that both supports Xcode 26 and still accepts these flags. + +### 3.1 Why this directly breaks `flutter_foreground_task` + +The plugin's whole selling point is "run Dart code in the background without blocking the UI". Post-merge, the background engine's isolate shares a thread with the main engine's isolate. From the OS perspective the foreground service still keeps the process alive and prevents Android from killing it, but inside the process the TaskHandler runs on the same thread as the UI. + +Consequences visible to users: + +- Heavy work inside `TaskHandler` (computations, synchronous FFI, database batches) janks the main UI. +- Heavy work on the main UI janks the `TaskHandler` loop, delaying sensor sampling, location updates, etc. +- Existing timing assumptions (e.g. `onRepeatEvent` being called roughly every `N` ms) no longer hold if either side is busy. +- On Flutter 3.38+ users cannot even opt out — their apps are strictly worse than on 3.28. + +--- + +## 4. Current implementation (what we need to change) + +### 4.1 Android — `ForegroundTask.kt` + +```50:72:android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundTask.kt +// create flutter engine +flutterEngine = FlutterEngine(context) +flutterLoader = FlutterInjector.instance().flutterLoader() +... +taskLifecycleListener.onEngineCreate(flutterEngine) + +// create background channel +val messenger = flutterEngine.dartExecutor.binaryMessenger +backgroundChannel = MethodChannel(messenger, "flutter_foreground_task/background") +backgroundChannel.setMethodCallHandler(this) + +// execute callback +val callbackHandle = taskData.callbackHandle +if (callbackHandle != null) { + val bundlePath = flutterLoader.findAppBundlePath() + val callbackInfo = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle) + val dartCallback = DartExecutor.DartCallback(context.assets, bundlePath, callbackInfo) + flutterEngine.dartExecutor.executeDartCallback(dartCallback) +} +``` + +This is a direct `FlutterEngine(context)` — which means it is subject to thread merging. The plugin has no current mechanism to: + +- Opt that specific engine out of thread merging. +- Use a `FlutterEngineGroup` (which would, in the post-merge world, enable "spawn" semantics that share the isolate group). +- Fall back to a pure Dart `Isolate` when the merged-thread penalty would be unacceptable. + +### 4.2 iOS — `ForegroundTask.swift` + +```63:79:ios/flutter_foreground_task/Sources/flutter_foreground_task/service/ForegroundTask.swift +// create flutter engine & execute callback +let flutterEngine = FlutterEngine(name: BG_ISOLATE_NAME, project: nil, allowHeadlessExecution: true) +let isRunningEngine = flutterEngine.run(withEntrypoint: entrypoint, libraryURI: libraryURI) + +if isRunningEngine { + // register plugins + registerPlugins(flutterEngine) + taskLifecycleListener.onEngineCreate(flutterEngine: flutterEngine) + ... +} +``` + +Same story on iOS. One more wrinkle: iOS does not have a real foreground-service concept, so we cannot piggy-back on a separate OS process to get a separate thread. + +### 4.3 Dart-side API (`TaskHandler`) + +```4:33:lib/task_handler.dart +abstract class TaskHandler { + Future onStart(DateTime timestamp, TaskStarter starter); + void onRepeatEvent(DateTime timestamp); + Future onDestroy(DateTime timestamp, bool isTimeout); + void onReceiveData(Object data) {} + void onNotificationButtonPressed(String id) {} + void onNotificationPressed() {} + void onNotificationDismissed() {} +} +``` + +Any migration must preserve this contract. Users should not have to rewrite `TaskHandler` implementations to get a fix. + +--- + +## 5. Constraints & non-goals + +Constraints (hard): + +- **Must compile and behave reasonably** on Flutter 3.22 (our minimum in `pubspec.yaml`) up through the latest stable. +- **Must not break the existing `TaskHandler` API.** Users rely on `onStart`/`onRepeatEvent`/etc. +- **Must not rely on unreleased APIs** as the only path. Anything we depend on must exist today. +- **Must not require every user to rewrite their app.** A small, clearly-documented migration is acceptable; a forced architectural rewrite is not. +- **`invokeMethod` must keep working, and incoming callbacks must be reachable via a documented FFI path.** A large fraction of our users drive a plugin that uses `MethodChannel.setMessageHandler` (BLE, location, sensors). `BackgroundIsolateBinaryMessenger` cannot deliver those callbacks directly ([flutter/flutter#119207](https://github.com/flutter/flutter/issues/119207)), so for `backgroundIsolate` mode the plugin exposes a `NativeCallable.listener`-based relay (§2.5.4, §7.3). Plugins that already expose an FFI surface get callbacks "for free"; plugins that don't can either stay on `mergedEngine`/`dedicatedEngine` or adopt the relay. + +Non-goals: + +- Solving the upstream threading problem. That is the Flutter/Dart teams' job. +- Offering low-latency / real-time guarantees. Dart GC still stops the world; anything truly real-time must live in C/Kotlin/Swift. +- Supporting `dart:ui` in background isolates. This plugin does not need it. + +--- + +## 6. Threading models to support + +We will offer **three execution modes** (internally; exposed as one `enum` on the Dart side) and let the user pick per-service: + +| Mode | Where Dart code runs | Pros | Cons | Requires | +|------|----------------------|------|------|----------| +| `dedicatedEngine` *(current behavior, pre-3.38 only)* | Secondary `FlutterEngine` with global merge flag disabled | Full plugin support, method-channel callbacks work, same API as today | Global flag, deprecated/removed in 3.38+, forces main app onto separated threads too | Android manifest + iOS Info.plist flags still honored | +| `mergedEngine` *(default on 3.29+)* | Secondary `FlutterEngine` multiplexed on main thread | Works on every Flutter version, plugin API unchanged, method-channel callbacks work | Heavy work blocks the UI. Requires user discipline (offload via FFI/JNI/`compute`). | Nothing (default) | +| `backgroundIsolate` *(new)* | Secondary `FlutterEngine` spawned by the service (keeps process + plugins alive), and inside that engine a pure Dart `Isolate` spawned via `Isolate.spawn` with the engine's `RootIsolateToken`. `MethodChannel.invokeMethod` works through `BackgroundIsolateBinaryMessenger`; incoming `setMessageHandler`-style callbacks are relayed through a `NativeCallable.listener` FFI trampoline (§2.5.4, §7.3). | Separate OS thread from the UI (Dart VM thread pool — not pinned until [dart-lang/sdk#46943](https://github.com/dart-lang/sdk/issues/46943) ships, see §2.5.3), survives UI engine teardown, safe against merge regression | No `dart:ui`. Plugins that rely on `setMessageHandler` require the FFI relay on their native side (or must stay on `mergedEngine`). | `RootIsolateToken` support in user-facing plugins (Flutter 3.7+) | + +### 6.1 Why not just "pick one"? + +The discussion on `#169339` and `#176053` makes it clear that: + +- Dropping the extra engine entirely regresses plugin-callback users and loses the + process-keepalive property of the service-owned engine. Maintainers of + `location`, `background_locator_2`, BLE plugins, etc. all rely on method + channels. This is why the revised `backgroundIsolate` mode (§2.5.1) keeps the + secondary engine and nests an isolate inside it rather than replacing it. +- Keeping the extra engine only (only `mergedEngine`) regresses CPU-bound users who need a separate thread. +- There is no way to satisfy both with a single fixed choice, so we must surface the choice. + +--- + +## 7. Proposed design + +### 7.1 New Dart API + +Add a new enum and an option on `ForegroundTaskOptions`: + +```dart +/// Controls where the TaskHandler runs when the service starts. +/// +/// - [dedicatedEngine]: Legacy behavior. A secondary `FlutterEngine` +/// is created and, if the platform allows it, the global +/// merged-thread flag is expected to be set so this engine runs +/// on a separate OS thread. Falls back to [mergedEngine] when the +/// flag is not effective (Flutter 3.38+) or absent. +/// - [mergedEngine]: A secondary `FlutterEngine` is created but is +/// multiplexed onto the main platform thread. Plugin API remains +/// fully available, including method-channel callbacks. +/// - [backgroundIsolate]: The foreground service instantiates a +/// secondary `FlutterEngine` as today (keeping the process alive +/// and plugins registered), and inside that engine's Dart isolate +/// we `Isolate.spawn` a pure Dart isolate that owns the +/// `TaskHandler`. `MethodChannel.invokeMethod` works through +/// `BackgroundIsolateBinaryMessenger` initialised with the +/// secondary engine's `RootIsolateToken`. Incoming plugin callbacks +/// are relayed via `NativeCallable.listener` (see the threading +/// model doc) instead of `setMessageHandler` — plugins that don't +/// adopt the relay won't deliver callbacks into this mode. +enum TaskExecutionMode { + dedicatedEngine, + mergedEngine, + backgroundIsolate, +} +``` + +Wire it into `ForegroundTaskOptions`: + +```dart +class ForegroundTaskOptions { + // ... existing fields + final TaskExecutionMode executionMode; + + const ForegroundTaskOptions({ + // ... existing params + this.executionMode = TaskExecutionMode.mergedEngine, // safe default + }); +} +``` + +Default is `mergedEngine` because: + +- It's the behavior users already get on Flutter 3.29+ whether they want it or not. +- It preserves 100% of the plugin API (method-channel callbacks, platform channels, `dart:ui` if anyone uses it). +- Users who explicitly want the old "dedicated thread" behavior or the new "pure isolate" behavior opt in. + +### 7.2 Native plumbing + +#### 7.2.1 Android + +Introduce an `executionMode` field inside `ForegroundTaskOptions` (the Kotlin model) and branch in `ForegroundTask.kt`: + +```kotlin +when (executionMode) { + ExecutionMode.DEDICATED_ENGINE, + ExecutionMode.MERGED_ENGINE -> startWithFlutterEngine(context, /* ... */) + ExecutionMode.BACKGROUND_ISOLATE -> startEngineAndSpawnNestedIsolate(context, /* ... */) +} +``` + +For the `backgroundIsolate` path (revised per §2.5.1) we **still** spawn a +secondary `FlutterEngine` — but the engine's Dart entrypoint does almost +nothing beyond calling `Isolate.spawn` to create the real task isolate and +handing over a `SendPort`/`RootIsolateToken` pair. Specifically: + +1. `ForegroundTask.kt` instantiates `FlutterEngine(context)` and runs the + plugin's registrant callback. This step is identical to the other two + modes, so the plugin's existing plugin-registration and keep-alive + guarantees are preserved. +2. The Dart entrypoint on the secondary engine is a thin bootstrap: it + captures `RootIsolateToken.instance`, opens a `ReceivePort`, spawns the + task isolate with `Isolate.spawn(entry, (token, sendPort, taskCallback))`, + and forwards `onStart/onRepeatEvent/onDestroy` calls it receives from + native onto the task isolate via `SendPort`. +3. The task isolate calls + `BackgroundIsolateBinaryMessenger.ensureInitialized(token)` so that any + `MethodChannel.invokeMethod` inside `TaskHandler` reaches the secondary + engine's messenger. Incoming plugin callbacks are delivered via the + `NativeCallable.listener` relay (§7.3). +4. The `FlutterForegroundService` Android component keeps doing what it does + today — holds a foreground notification so the process stays alive and + calls the lifecycle callbacks. + +The lifecycle listener interface (`FlutterForegroundTaskLifecycleListener`) already abstracts "an engine was created / destroyed". We add a parallel set of hooks so services can observe the nested isolate without peeking inside the dispatcher: + +```kotlin +interface FlutterForegroundTaskLifecycleListener { + // existing + fun onEngineCreate(flutterEngine: FlutterEngine) {} + fun onEngineWillDestroy() {} + fun onTaskStart(starter: FlutterForegroundTaskStarter) {} + fun onTaskRepeatEvent() {} + fun onTaskDestroy() {} + + // new (backgroundIsolate mode) — fire after the secondary engine's + // bootstrap has spawned the task isolate and, respectively, before it + // is torn down. + fun onBackgroundIsolateStart() {} + fun onBackgroundIsolateStop() {} +} +``` + +#### 7.2.2 iOS + +iOS follows the same shape. `ForegroundTask.swift` continues to instantiate +`FlutterEngine(name:project:allowHeadlessExecution:)` and register plugins; +the engine's Dart entrypoint bootstraps the nested isolate exactly as on +Android. Because the engine (and therefore the nested isolate) is owned by +the plugin's native side, iOS-only lifecycle hooks such as +`BGContinuedProcessingTask`, `UNUserNotificationCenter`, or audio-category +wiring stay in the plugin as today — the task isolate only has to care about +Dart. + +### 7.3 Dart-side dispatcher + +Today the plugin publishes a single top-level entrypoint via `PluginUtils.getFlutterCallbackHandle` for a user-supplied `Function callback` and executes it in the background engine. Under `backgroundIsolate` mode, the user's callback still runs against the secondary engine, but via a nested `Isolate.spawn` whose entrypoint becomes the real host of `TaskHandler`. + +Revised Dart flow for `backgroundIsolate` mode (per §2.5.1): + +``` +┌───────────────────────┐ +│ Secondary engine's │ +│ Dart bootstrap │ Isolate.spawn(entry, (token, sendPort, cb)) +│ (spawned by native) │────────────────────────────────────────────┐ +│ │ │ +│ - RootIsolateToken │ ▼ +│ - ReceivePort │ ┌──────────────────┐ +│ - forwards │ │ Task isolate │ +│ onStart/... │ │ - RootToken │ +│ │ │ - TaskHandler │ +└─────────┬─────────────┘ │ - BackgroundIso.│ + │ SendPort (lifecycle events) │ BinaryMsgr │ + └─────────────────────────────────────────────────►│ - Ncb.listener │ + └─────────▲────────┘ + │ FFI + │ (NativeCallable.listener) + ┌─────────┴────────┐ +┌────────────────┐ method channel (no change) │ Native plugin │ +│ UI isolate │────────────────────────────────────────► │ side (Kotlin/ │ +│ │ │ Swift) inside │ +└────────────────┘ │ the secondary │ + │ engine │ +┌────────────────┐ method channel (existing) └──────────────────┘ +│ UI isolate │───────────────────────────────────────► ┌──────────────────┐ +│ │ │ Android service │ +└────────────────┘ │ (notification, │ + │ wakelock, OS) │ + └──────────────────┘ +``` + +Implementation notes: + +- Re-use `FlutterForegroundTask.initCommunicationPort` + `sendDataToMain`/`sendDataToTask` infrastructure. Under `backgroundIsolate` mode, lifecycle events (`onStart`, `onRepeatEvent`, `onDestroy`) are forwarded from the secondary engine's bootstrap isolate into the nested task isolate over a `SendPort`; they still originate from the native side's existing method-channel calls, so there is no new platform surface. +- The bootstrap isolate captures `RootIsolateToken.instance` from the secondary engine and forwards it to the nested isolate. The nested isolate calls `BackgroundIsolateBinaryMessenger.ensureInitialized(token)` before invoking any `MethodChannel`. Because the token belongs to the *secondary* engine, `invokeMethod` delivery hits the plugin code registered on that engine — not the UI engine. +- **Callback relay via `NativeCallable.listener`** (§2.5.4). The dispatcher exposes a small helper: + + ```dart + class ForegroundTaskCallbackRelay { + /// Returns a raw native function pointer that the plugin's native side + /// can invoke from any thread. The pointer posts the payload to this + /// isolate's port, where [onEvent] runs synchronously with respect to + /// the task isolate. + /// + /// Safe to call from the task isolate's entrypoint. Dispose by calling + /// [close] before the isolate exits, to release the `NativeCallable`. + Pointer> register( + void Function(Pointer bytes, int length) onEvent, + ); + void close(); + } + ``` + + Plugin authors (or app authors for closed-source plugins) wire their + native side to accept that pointer through a new FFI boundary function + such as `ffSetEventCallback(Pointer fn)` and invoke it on whatever thread + the plugin currently produces events on. The plugin ships one + representative example in `example/` to keep the API honest. +- The repeat-event timer moves entirely into Dart (a simple `Timer.periodic`) inside the task isolate, so there is no native ↔ Dart hop per tick. This is actually a small win: it removes one of the current sources of main-thread traffic. + +### 7.4 Feature detection and fallback + +`dedicatedEngine` is only meaningful when the Flutter runtime still honors the +merge opt-out flag. Detection must happen in two phases: + +1. **Configuration-time check**: + - **Android**: read the `DisableMergedPlatformUIThread` meta-data ourselves and surface a warning at startup if it is missing but the user asked for `dedicatedEngine`. + - **iOS**: read `FLTEnableMergedPlatformUIThread` from `Info.plist`. Same warning. +2. **Runtime verification when the flag is present**: + - Do **not** use a `MethodChannel` handler that returns + `Process.myTid()` / `pthread_mach_thread_np(pthread_self())`. By default, + Flutter executes platform-channel handlers on the platform main thread + unless the channel is explicitly attached to a Task Queue, so that only + measures the native handler thread, not the Dart isolate we care about. + - Instead, use the shipping isolate-side FFI probe + (`lib/utils/thread_id_probe.dart`, exposed publicly via + `FlutterForegroundTask.debugThreadId()`) and execute it from both the UI + isolate and the task isolate: + - Android: `gettid` from `libc.so`. + - iOS / macOS: `pthread_mach_thread_np(pthread_self())` from the main + process image. + - When `executionMode = dedicatedEngine` and the manifest/plist flag is + present, collect both values after `TaskHandler.onStart`. + - If they still match, log once that the runtime ignored the opt-out flag + and treat the effective mode as `mergedEngine`. + +No crashes. Worst case, we silently do what we'd do today and tell the user about it in logs + README. + +### 7.5 Documentation deliverables + +As part of this work we ship: + +1. This file (`documentation/merged_platform_ui_thread_mitigation.md`). +2. A new top-level `documentation/threading_model.md` explaining the three modes, the trade-offs, how to pick one, and how to mitigate main-thread contention via `compute`, FFI, Kotlin/Swift worker threads, etc. +3. A `CHANGELOG.md` entry explicitly calling out the Flutter 3.29+ regression and our recommended migration. +4. README updates (linking to the new docs; updating the "important notes" box). + +--- + +## 8. Step-by-step implementation plan + +### Phase 0 — Groundwork (no behavior change) + +1. Create `TaskExecutionMode` enum and `executionMode` field on `ForegroundTaskOptions` (Dart). Default: `mergedEngine`. Equivalent enum in Kotlin and Swift with the same defaults. +2. Plumb the option through the method-channel payload (`PreferencesKey` / `ForegroundTaskOptions` native model on both platforms). +3. Add `assert`s / warnings so that unknown enum values in native fall back to `mergedEngine` and log. +4. Ship this as a minor version bump. No functional change for existing users. + +### Phase 1 — `dedicatedEngine` detection, warning, and verification + +1. Android: in `FlutterForegroundTaskPlugin.onAttachedToEngine`, read the `DisableMergedPlatformUIThread` meta-data. Persist the result in `PluginUtils`. +2. iOS: on first plugin call, read `FLTEnableMergedPlatformUIThread` from `Info.plist`. Persist similarly. +3. ✅ **Shipped** — `lib/utils/thread_id_probe.dart` exposes a `nativeThreadId()` + helper backed by `package:universal_ffi/ffi.dart` (`gettid` on Android, + `pthread_mach_thread_np(pthread_self())` on iOS/macOS). Reuse it from + `MethodChannelFlutterForegroundTask.debugThreadId` and from the + `TaskExecutionMode` runtime-verification logic. Do **not** implement this + via `MethodChannel`; that only reports the native handler thread unless the + channel uses a Task Queue. +4. When `startService` is called with `executionMode = dedicatedEngine`: + - If the flag is missing → log a clear message and continue with + `mergedEngine`. + - If the flag is present → capture a UI-isolate TID before service start and + a task-isolate TID from `TaskHandler.onStart`. + - If the values differ → keep `dedicatedEngine`. + - If the values match → log a clear message ("The merged-thread opt-out flag + is present but the runtime still scheduled both isolates on the same OS + thread. Falling back to mergedEngine."), continue with `mergedEngine`. + +### Phase 2 — `backgroundIsolate` mode + +1. Dart side: + - Add `_BackgroundIsolateBootstrap` that runs as the secondary engine's Dart entrypoint. It captures `RootIsolateToken.instance`, calls `Isolate.spawn` to create the task isolate, sets up the `SendPort`/`ReceivePort` bridge, and forwards method-channel lifecycle calls (`onStart`, `onRepeatEvent`, `onDestroy`, `onReceiveData`, notification callbacks) to the task isolate. + - Inside the task isolate, add `_BackgroundIsolateDispatcher` that calls `BackgroundIsolateBinaryMessenger.ensureInitialized(token)`, instantiates the user's `TaskHandler`, and drives `onStart`/`onRepeatEvent`/`onDestroy`. The repeat-event timer lives here. + - Add `ForegroundTaskCallbackRelay` (§7.3) wrapping `NativeCallable.listener` — lifecycle is tied to the task isolate. + - `FlutterForegroundTaskController.startService` detects the mode and picks dispatcher: engine-only (`mergedEngine`/`dedicatedEngine`) vs engine + nested isolate (`backgroundIsolate`). +2. Android side: + - `ForegroundService.onStartCommand` keeps running as today (notification, wakelock). In `backgroundIsolate` mode it **still instantiates** `ForegroundTask`/`FlutterEngine`; the difference is the Dart entrypoint it runs is the isolate bootstrap rather than the user's callback directly. This preserves `registerPlugins(engine)`, keeps plugins' `MethodCallHandler`s on the secondary engine reachable, and keeps process lifetime tied to the service. + - Broadcast receivers (`onNotificationPressed`, `onNotificationButtonPressed`) invoke the usual method-channel calls; the bootstrap isolate relays them to the task isolate over its `SendPort`. +3. iOS side: + - `BackgroundService.startService` instantiates the `FlutterEngine` as today in all modes. In `backgroundIsolate` mode, the engine runs the isolate-bootstrap entrypoint; notification / `BGContinuedProcessingTask` wiring stays in the native plugin. +4. Callback-relay docs: + - Add `documentation/native_callback_relay.md` (new) that shows the minimal Kotlin/Swift side of a plugin integrating the FFI callback trampoline. + - Wire one sensor-ish example in `example/` to exercise the relay end-to-end. +5. Provide a `README` section: "Which mode should I pick?" flowchart: + - Does your task use plugins with `setMessageHandler` callbacks **and** those plugins don't expose (and you don't want to add) an FFI callback trampoline? → `mergedEngine` (or `dedicatedEngine` on Flutter ≤ 3.37). + - Is your task CPU-bound and uses only FFI / pure Dart / plugins you call into (or plugins that adopt the relay)? → `backgroundIsolate`. + - Are you on Flutter ≤ 3.37 and the escape-hatch flag is set? → `dedicatedEngine` (old behavior). + +### Phase 3 — Forward-looking hooks + +1. Stub points ready to accept: + - `Isolate.spawn(..., dedicatedThread: true)` once that lands in Dart SDK. Behind a version check, we can make `backgroundIsolate` mode pin to a dedicated thread automatically. + - A `FlutterEngineGroup.Options.runOnDedicatedThread` or equivalent, if the proposal in [`flutter/flutter#176053`](https://github.com/flutter/flutter/issues/176053) lands. Add a fourth enum value (`TaskExecutionMode.dedicatedEngineOnNewThread`) with a feature-detect at runtime. +2. ✅ **Shipped** — `FlutterForegroundTask.debugThreadId()` is backed by a + Dart-side FFI probe (`gettid` from `libc.so` on Android, + `pthread_mach_thread_np(pthread_self())` on iOS/macOS) defined in + `lib/utils/thread_id_probe.dart`. The previous `MethodChannel`-based + handlers have been removed because they only measured the handler thread + (always the platform main thread unless the channel uses a Task Queue). + This gives users and CI a way to verify which mode is actually in effect. + +### Phase 4 — Migration aids + +1. `flutter_foreground_task doctor` style check: a helper that prints the current detected threading setup ("you are on Flutter 3.38, `DisableMergedPlatformUIThread` is not honored, your `executionMode` is X, therefore your task runs on thread Y"). +2. A lint-on-import note (in the README's "Getting started") that points Flutter-3.38+ users directly to this document. + +--- + +## 9. Testing plan + +- **Unit tests (Dart)**: mock the platform interface and assert that `executionMode` round-trips through `ForegroundTaskOptions` preferences. +- **Integration tests**: + - Spin a service in each mode, measure the FFI-backed `debugThreadId` from inside `TaskHandler.onStart` and compare to the UI isolate's TID. Expected: + - `dedicatedEngine` (flag honored) → distinct TID. + - `mergedEngine` → same TID as UI. + - `backgroundIsolate` → distinct TID (most of the time; thread-pool). + - Block the UI isolate for 500 ms and verify: + - In `mergedEngine` the task's `onRepeatEvent` also stalls. + - In `backgroundIsolate` it doesn't. +- **Regression tests** for the existing examples app: all modes must pass the existing `example/` smoke tests on Flutter 3.22 (minimum), current stable, and master. +- **Platform matrix**: + - Android API 21, 26 (first with channels), 33 (POST_NOTIFICATIONS), 34 (foreground service types), 35 (compile target). + - iOS 13, 16 (UNUserNotificationCenter nuances), 17, 26 (BGContinuedProcessingTask). + +--- + +## 10. Risks & mitigations + +| Risk | Mitigation | +|------|------------| +| `BackgroundIsolateBinaryMessenger` doesn't deliver `setMessageHandler` callbacks ([flutter/flutter#119207](https://github.com/flutter/flutter/issues/119207)). | ✅ Resolved in design — the `backgroundIsolate` mode ships a `NativeCallable.listener` relay (§2.5.4, §7.3) so plugins that adopt it can still deliver inbound events into the task isolate. Plugins that don't adopt it stay on `mergedEngine`/`dedicatedEngine`, which we document rather than silently breaking. | +| Users expect `TaskHandler` to survive the UI being killed by the OS. | ✅ Resolved in design — because `backgroundIsolate` (per §2.5.1) nests the task isolate inside the **service-owned** secondary engine, the task isolate's lifetime is tied to the foreground service (notification-backed on Android), not to the UI engine. On iOS parity with today applies: the OS can still reclaim the app, but while the process is alive the isolate runs regardless of the UI engine's state. | +| Third-party plugins register in the old engine-based flow (`setPluginRegistrantCallback`). | ✅ Resolved in design — because `backgroundIsolate` keeps the secondary engine (§2.5.1), `registerPlugins(engine)` still runs and plugins stay registered. The task isolate reaches them via `BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken)` using the **secondary** engine's token. | +| A `debugThreadId()` helper implemented through `MethodChannel` measures the wrong thread. | ✅ Resolved — the probe now runs via `package:universal_ffi/ffi.dart` inside the calling isolate (`lib/utils/thread_id_probe.dart`), so it reports the actual OS thread id of that isolate. Never infer isolate affinity from the host platform-channel handler thread. See Bug #1 in §2. | +| Detection of `DisableMergedPlatformUIThread` effectiveness is heuristic. | Run an explicit FFI TID probe when `executionMode = dedicatedEngine` is first entered. If the TID matches the main thread, log the warning and flip to `mergedEngine`. | +| Upstream lands a new API (e.g. `dedicatedThread: true`) that supersedes part of this plan. | Phase 3 leaves the `TaskExecutionMode` enum extensible. The default stays `mergedEngine`, so introducing new values is non-breaking. | +| Users on Flutter 3.22–3.28 still get the old behavior "for free" and don't need to do anything. | Make sure `executionMode = dedicatedEngine` continues to *just work* on pre-3.29 Flutter without any warnings. | + +--- + +## 11. Roll-out & communication + +1. **Version bump**: ship the `executionMode` option as a **minor** release (default behavior unchanged). Changelog entry must explicitly describe: + - The underlying Flutter change. + - The three modes. + - The default (`mergedEngine`). + - Why it is different from pre-3.29 Flutter (calls out the "same thread as UI" caveat). +2. **README**: add a prominent callout at the top ("If you upgraded to Flutter 3.29+, please read this") pointing at `documentation/threading_model.md`. +3. **Issue triage template**: update `.github` issue template to ask the user which `TaskExecutionMode` they're using + Flutter version, since those will be the two dominant causes of reports. +4. **Long-term**: track upstream progress. Re-evaluate defaults when: + - [`dart-lang/sdk#46943`](https://github.com/dart-lang/sdk/issues/46943) lands `dedicatedThread: true` in a stable Dart SDK → adopt in `backgroundIsolate` mode, likely making it the recommended default. + - [`flutter/flutter#176053`](https://github.com/flutter/flutter/issues/176053) lands headless-engine-with-dedicated-thread → add `TaskExecutionMode.dedicatedEngineOnNewThread` and probably flip the default there. + +--- + +## 12. Open questions for the maintainers + +1. Should the default be `mergedEngine` (safe, same as Flutter's own default) or `backgroundIsolate` (better UX for CPU-bound tasks, needs opt-in for callback-driven plugins)? **Proposed: `mergedEngine`.** +2. Do we want to deprecate `dedicatedEngine` immediately, or keep it as a documented-but-warned mode until Flutter 3.37 is out of support? **Proposed: keep it, with loud warnings, until Flutter 3.41 is minimum.** +3. Do we publish a separate companion package (e.g. `flutter_foreground_task_isolate`) for the `backgroundIsolate` mode, or keep it in the main package behind the enum? **Proposed: main package, behind the enum. Single import surface is worth the slight binary size.** +4. How do we want to evolve `TaskHandler`? Do we introduce a mode-aware interface (e.g. split lifecycle hooks by "engine mode" vs "isolate mode"), or keep a single interface and document which hooks are no-ops in which mode? **Proposed: single interface; `onNotificationButtonPressed` etc. stay wired via bootstrap-isolate → port in `backgroundIsolate` mode.** +5. **(New, from §2.5.4)** Do we ship the `NativeCallable.listener` relay (`ForegroundTaskCallbackRelay` + a minimal Kotlin/Swift sample) inside this package, or as a sibling package (`flutter_foreground_task_callback_relay`) so plugin authors can depend on it without pulling the foreground-service surface? **Proposed: keep it inside this package for v9.4, re-evaluate once a second consumer appears. A sibling package pre-emptively is over-engineering given current adoption signals on issue #352.** +6. **(New)** Should the relay be Dart-typed (e.g. `ForegroundTaskCallbackRelay` with a codec) or raw-bytes (`Pointer` + length, like §7.3 suggests)? **Proposed: raw bytes for v1 (escape hatch), optional typed wrapper on top. Plugin authors already know their wire format; forcing a codec choice makes integration harder.** + +--- + +## 13. References + +- Flutter 3.29 release notes / thread-merge announcement. +- [flutter/flutter#150525 — Merge the platform and UI threads](https://github.com/flutter/flutter/issues/150525) +- [flutter/flutter#169339 — Flutter 3.32.0 threading issue](https://github.com/flutter/flutter/issues/169339) +- [flutter/flutter#176053 — Proposal: Headless Flutter Engine With Dedicated Thread](https://github.com/flutter/flutter/issues/176053) +- [flutter/flutter#119207 / #130570 — setMessageHandler in background isolates](https://github.com/flutter/flutter/issues/119207) +- [dart-lang/sdk#46943 — API to allow setting thread pinning for Isolates](https://github.com/dart-lang/sdk/issues/46943) +- [dart-lang/sdk#56841 — runSync / synchronous isolate entry](https://github.com/dart-lang/sdk/issues/56841) +- [naviter/flutter_threading — reproducer](https://github.com/naviter/flutter_threading) diff --git a/documentation/native_callback_relay.md b/documentation/native_callback_relay.md new file mode 100644 index 00000000..272aebdf --- /dev/null +++ b/documentation/native_callback_relay.md @@ -0,0 +1,157 @@ +# `ForegroundTaskCallbackRelay`: delivering native callbacks into a Dart isolate + +`flutter_foreground_task` exposes `ForegroundTaskCallbackRelay` in +[`lib/utils/foreground_task_callback_relay.dart`](../lib/utils/foreground_task_callback_relay.dart). +It is a thin wrapper around Dart's +[`NativeCallable.listener`](https://api.dart.dev/dart-ffi/NativeCallable/NativeCallable.listener.html) +that delivers raw byte payloads from native code into the Dart isolate +that created it. + +The relay was added to unblock plugins that today deliver events via +`MethodChannel.setMessageHandler` but need to keep working when their +consumer runs in a **background isolate** (either one you spawned +yourself, or the upcoming `TaskExecutionMode.backgroundIsolate` mode of +this plugin). Background isolates cannot receive +`setMessageHandler` callbacks over `BackgroundIsolateBinaryMessenger` +— see +[flutter/flutter#119207](https://github.com/flutter/flutter/issues/119207) +— so the relay provides an FFI side channel that *does* work across +isolates and threads. + +## When to use it + +Reach for `ForegroundTaskCallbackRelay` when **all** of the following +hold: + +- The data you need to deliver is event-driven (your plugin currently + uses `MethodChannel.setMessageHandler`, an `EventChannel`, or a + native-side registration callback). +- The Dart consumer runs inside an isolate that cannot install a + `setMessageHandler` (e.g. a background isolate launched via + `BackgroundIsolateBinaryMessenger.ensureInitialized`). +- The native side can afford to allocate the payload as raw bytes. + +If your plugin only needs `invokeMethod` (Dart → native), you do **not** +need the relay — `BackgroundIsolateBinaryMessenger` already covers that +direction. + +## Minimal usage + +```dart +import 'dart:typed_data'; + +import 'package:flutter_foreground_task/utils/foreground_task_callback_relay.dart'; + +final ForegroundTaskCallbackRelay relay = + ForegroundTaskCallbackRelay.listener((Uint8List bytes) { + // Parse `bytes` however your plugin's wire format encodes events. + // Called on *this* isolate's event loop regardless of which OS + // thread the native side invoked it on. +}); + +// Hand the native function pointer to your native side. On Android / +// iOS plugin code this is typically done by passing `relay.address` +// (an int) through a MethodChannel and reconstituting it as +// `void (*)(uint8_t*, int32_t)` in C / Kotlin / Swift. +await _myPlugin.channel.invokeMethod('installCallback', relay.address); + +// When you're done, release the NativeCallable. Tell the native side +// to stop issuing events first; invoking a closed relay is undefined +// behaviour. +relay.close(); +``` + +### Native side (Android / Kotlin sketch) + +```kotlin +// The Dart side has sent us `pointerAddress` (a Long). +private var callback: CPointer, Int) -> Unit>>? = null + +fun installCallback(pointerAddress: Long) { + callback = pointerAddress.toCPointer() +} + +fun onEvent(payload: ByteArray) { + val cb = callback ?: return + memScoped { + val buffer = allocArray(payload.size) + for (i in payload.indices) buffer[i] = payload[i] + cb.invoke(buffer, payload.size) + // buffer goes out of scope after `memScoped` returns; the + // relay has already taken a copy internally before this line. + } +} +``` + +### Native side (iOS / Swift sketch) + +```swift +typealias CallbackFn = @convention(c) (UnsafeMutablePointer?, Int32) -> Void +private var callback: CallbackFn? + +func installCallback(pointerAddress: Int) { + let raw = UnsafeRawPointer(bitPattern: pointerAddress)! + callback = unsafeBitCast(raw, to: CallbackFn.self) +} + +func onEvent(_ payload: Data) { + guard let cb = callback else { return } + payload.withUnsafeBytes { (rawBuf: UnsafeRawBufferPointer) in + let ptr = UnsafeMutablePointer( + mutating: rawBuf.bindMemory(to: UInt8.self).baseAddress!) + cb(ptr, Int32(payload.count)) + } +} +``` + +## Buffer-lifetime contract + +`NativeCallable.listener` delivers the invocation to Dart +**asynchronously**: the native call returns immediately and the Dart +closure runs on the originating isolate's event loop. That means the +bytes are read on the Dart side **after** the native call has returned. +The native caller therefore **must keep the buffer alive until Dart has +had a chance to dispatch the event**. + +Two patterns satisfy this: + +1. **Per-event allocation.** The native side allocates a fresh buffer + per event (`malloc`, `calloc`, or equivalent) and the *Dart side* + frees it after `onEvent` has copied the contents. + + > The current relay implementation does not free native memory for + > you — if your native code relies on heap allocations, wrap the + > relay with a second pointer the Dart side can invoke to hand + > ownership back. +2. **Single long-lived buffer.** The native side owns a buffer that + lives at least until the relay is closed (or until it has received + an acknowledgement that the current event has been consumed). The + native caller must not mutate the buffer between the call and the + Dart-side dispatch. + +If neither pattern fits cleanly, use a primitive-only signature +(`void (*)(int64_t)`, `void (*)(int32_t, int32_t)`, …) so the payload +travels by value through `NativeCallable.listener`'s message encoding +and does not depend on native memory lifetime. In that case you do not +need this relay — create your own `NativeCallable.listener` directly +with the primitive signature you want. + +## Lifecycle + +- Create the relay **once** per consumer isolate, on that isolate. +- Single-consumer: only one native subscriber should hold the pointer + at a time. If multiple subscribers need delivery, create one relay + per subscriber or multiplex on top of a single one. +- Call `close()` before the isolate exits. `close()` is idempotent; + accessing `nativeFunction` or `address` after `close()` throws + `StateError`. + +## Design references + +- Section 2.5.4 of + [merged_platform_ui_thread_mitigation.md](./merged_platform_ui_thread_mitigation.md) + — rationale for adopting `NativeCallable.listener`. +- Section 7.3 of the same document — how the relay fits into the + upcoming `TaskExecutionMode.backgroundIsolate` dispatcher. +- [`test/foreground_task_callback_relay_test.dart`](../test/foreground_task_callback_relay_test.dart) + — worked examples of correct usage, including cross-isolate delivery. diff --git a/documentation/threading_model.md b/documentation/threading_model.md new file mode 100644 index 00000000..5eee4888 --- /dev/null +++ b/documentation/threading_model.md @@ -0,0 +1,233 @@ +# Threading model + +Starting with **Flutter 3.29** the engine began merging the platform and UI +threads, and as of **Flutter 3.32** the merge is the default on both Android +and iOS. Every `FlutterEngine` in the process — including the secondary engine +this plugin spawns for `TaskHandler` — now multiplexes its UI isolate onto the +**same OS thread as the main app**. Code that assumed the background engine ran +on its own thread no longer gets that isolation. + +For the motivation, upstream references, and long-term roadmap, see +[merged_platform_ui_thread_mitigation.md](./merged_platform_ui_thread_mitigation.md). +This page documents the knobs the plugin exposes today and how to choose +between them. + +## TL;DR + +```dart +ForegroundTaskOptions( + eventAction: ForegroundTaskEventAction.repeat(5000), + + // Default on the current plugin version. Matches Flutter 3.29+ behaviour: + // the TaskHandler shares the platform thread with the UI isolate. + executionMode: TaskExecutionMode.mergedEngine, +); +``` + +If your app's task work is mostly `await`-heavy (waiting on I/O, platform +channels, timers) the default is fine. If you do CPU-bound work inside the +TaskHandler you need to do one of: + +1. **Move the hot loop off the UI thread yourself** with `Isolate.run`, + `compute`, or a long-lived background isolate you manage, and keep + `executionMode: TaskExecutionMode.mergedEngine`. +2. **Opt into `TaskExecutionMode.dedicatedEngine`** *and* set the Flutter + merged-thread opt-out flag on the platform. The plugin detects whether the + flag is present and, if not, logs a clear warning and falls back to + `mergedEngine`. + +## Execution modes + +| Mode | What the plugin does | Thread isolation | +| ---- | -------------------- | ---------------- | +| `mergedEngine` *(default)* | Spawns a secondary `FlutterEngine` for the TaskHandler. Flutter multiplexes it on the platform/UI thread. | None — same OS thread as UI. | +| `dedicatedEngine` | Same as above, but relies on the platform opt-out flag so the secondary engine runs on its own OS thread. | Yes on engines that still honor the flag (Flutter ≤ 3.37). Falls back to `mergedEngine` when the flag is missing. | +| `backgroundIsolate` | Reserved. Once shipped, the service spawns the secondary `FlutterEngine` as today (plugins stay registered, process stays alive), and inside that engine a nested `Isolate.spawn` hosts the `TaskHandler`. Incoming plugin callbacks are relayed via `NativeCallable.listener`. Currently falls back to `mergedEngine` with a log warning. | Planned. Not yet available. See `merged_platform_ui_thread_mitigation.md` §2.5 for the adopted design and §8 Phase 2 for the implementation roadmap. | + +The enum values are stable strings (`"mergedEngine"`, `"dedicatedEngine"`, +`"backgroundIsolate"`) and get persisted with the rest of the service options, +so changes take effect on the next service start. + +## Opting into `dedicatedEngine` + +`dedicatedEngine` requires both a Flutter engine that still supports the +merged-thread opt-out **and** the corresponding app-level flag. The plugin +checks for the flag at service start, logs a warning if it is missing, and +falls back to `mergedEngine`. + +### Android — `AndroidManifest.xml` + +```xml + + ... + + +``` + +### iOS — `Info.plist` + +```xml +FLTEnableMergedPlatformUIThread + +``` + +> **Important:** These flags stopped being honored in **Flutter 3.38**. On +> engines that ignore them, `dedicatedEngine` will still compile and run, but +> the secondary engine will end up on the platform thread just like +> `mergedEngine`. +> +> Use [`FlutterForegroundTask.debugThreadId()`](#runtime-verification-with-debugthreadid) +> to tell the difference at runtime. The helper is now backed by an +> isolate-side `package:universal_ffi/ffi.dart` probe (`gettid` on Android, +> `pthread_mach_thread_np(pthread_self())` on iOS/macOS), so it reports the +> OS thread of whichever isolate invoked it. Earlier versions of this plugin +> implemented `debugThreadId()` via a `MethodChannel` round-trip, which +> always returned the main platform thread and could not distinguish +> `mergedEngine` from `dedicatedEngine`. + +### Dart + +```dart +FlutterForegroundTask.init( + androidNotificationOptions: AndroidNotificationOptions( + channelId: 'foreground_service', + channelName: 'Foreground Service', + ), + iosNotificationOptions: const IOSNotificationOptions(), + foregroundTaskOptions: ForegroundTaskOptions( + eventAction: ForegroundTaskEventAction.repeat(5000), + executionMode: TaskExecutionMode.dedicatedEngine, + ), +); +``` + +If the flag is missing you will see a log line similar to: + +``` +W/FlutterForegroundTask: TaskExecutionMode.dedicatedEngine was requested but +the `DisableMergedPlatformUIThread` AndroidManifest meta-data is not set. +Flutter 3.29+ multiplexes every FlutterEngine onto the main platform thread, +so the task will run on the same thread as the UI. Falling back to +TaskExecutionMode.mergedEngine. See documentation/threading_model.md for +guidance. +``` + +The equivalent message is emitted on iOS when +`FLTEnableMergedPlatformUIThread=false` is missing from `Info.plist`. + +## Runtime verification with `debugThreadId()` + +`FlutterForegroundTask.debugThreadId()` returns the OS thread id of the Dart +isolate that calls it. The implementation lives in +`lib/utils/thread_id_probe.dart` and uses `package:universal_ffi/ffi.dart` +directly: + +- Android: `gettid()` from `libc.so` (returns a Linux TID). +- iOS / macOS: `pthread_mach_thread_np(pthread_self())` from the main + process image (returns a `mach_port_t`). +- Other platforms (Linux/Windows desktop, web): `null`. + +Because the probe runs inside the calling isolate, its answer reflects the +true OS thread of that isolate — not the platform-channel handler thread. +Compare one reading from the UI isolate with one from inside +`TaskHandler.onStart`: + +```dart +final uiTid = await FlutterForegroundTask.debugThreadId(); + +class MyTaskHandler extends TaskHandler { + @override + Future onStart(DateTime timestamp, TaskStarter starter) async { + final taskTid = await FlutterForegroundTask.debugThreadId(); + print('uiTid=$uiTid taskTid=$taskTid'); + } + + // ... +} +``` + +Interpretation: + +- `uiTid == taskTid` -> the task isolate is sharing the platform/UI thread + (`mergedEngine`, or `dedicatedEngine` on a Flutter release that no longer + honors the opt-out flag). +- `uiTid != taskTid` -> the task isolate has its own OS thread + (`dedicatedEngine` on a supported engine). + +### Why the older `MethodChannel`-based helper was unreliable + +Earlier versions of this plugin implemented `debugThreadId()` by invoking a +method on the plugin's platform channel that returned `Process.myTid()` +(Android) or `pthread_mach_thread_np(pthread_self())` (iOS) from a default +`MethodChannel.setMethodCallHandler` body. Flutter's platform-channel +threading rules dispatch those handlers on the target engine's platform +task runner — which, on Android and iOS, is the main thread for every +`FlutterEngine` in the process unless the channel is built with a +`BinaryMessenger.TaskQueue`. None of the plugin's channels used a Task +Queue, so the helper always measured the main thread and returned the same +TID whether `mergedEngine` or `dedicatedEngine` was active. + +See `documentation/merged_platform_ui_thread_mitigation.md` (Bug #1) for +the full history and the fix plan that produced the current FFI-based +implementation. + +### What to expect per configuration + +- Missing opt-out flag -> the plugin logs a warning and falls back to + `mergedEngine`; `uiTid == taskTid`. +- Opt-out flag present on Flutter <= 3.37 -> `dedicatedEngine` behaves like + the legacy dedicated-thread model; `uiTid != taskTid`. +- Opt-out flag present on Flutter 3.38+ -> the engine may still merge + threads. `debugThreadId()` will show `uiTid == taskTid` if that happened; + treat the effective mode as `mergedEngine` and surface it in your logs. + +## FAQ + +**Q: My app is on Flutter 3.32+ and I don't want my TaskHandler to jank the +UI. What should I do?** + +Keep `executionMode: TaskExecutionMode.mergedEngine` (the default) and make +sure any CPU-heavy work inside the TaskHandler is offloaded — either to +`Isolate.run` / `compute` for one-shot work, or to a dedicated long-lived +isolate you manage yourself. The TaskHandler itself can then stay lightweight +(orchestration, platform channels, reporting progress). + +**Q: Why isn't `backgroundIsolate` implemented yet?** + +`Isolate.spawn` is the Flutter team's preferred replacement, but +`MethodChannel.setMessageHandler` is not supported on the +`BackgroundIsolateBinaryMessenger` used there +([flutter/flutter#119207](https://github.com/flutter/flutter/issues/119207), +[#130570](https://github.com/flutter/flutter/issues/130570)). That breaks +plugins that deliver events via platform channels (BLE, sensors, location, +etc.). + +The design adopted for this plugin (see +[`merged_platform_ui_thread_mitigation.md` §2.5](./merged_platform_ui_thread_mitigation.md#25-design-revisions-adopted-from-issue-352-maintainer-feedback)) +is to keep the secondary `FlutterEngine` created by the foreground service +and, from its Dart bootstrap, `Isolate.spawn` a nested task isolate that +hosts `TaskHandler`. Plugin `invokeMethod` calls continue to work through +`BackgroundIsolateBinaryMessenger.ensureInitialized` using the **secondary** +engine's `RootIsolateToken`, and inbound callbacks are delivered via a +[`NativeCallable.listener`](https://api.dart.dev/dart-ffi/NativeCallable/NativeCallable.listener.html) +FFI trampoline that the plugin ships alongside the mode. The enum value is +reserved today so apps can opt in once the dispatcher + relay land in a +follow-up release. + +**Q: My logs show `TaskExecutionMode.dedicatedEngine` falling back even though +I set the meta-data / plist entry.** + +Double-check the flag is in the main `AndroidManifest.xml` of your +**application** module (not a library) and that the key/value spelling matches +exactly. On iOS make sure the key lives in the main app's `Info.plist` and the +value is a boolean ``, not a string. + +If the flag is present and you still see merged-thread behavior, you are likely +on Flutter 3.38+ where the opt-out was removed upstream — see +[flutter/flutter#169339](https://github.com/flutter/flutter/issues/169339). +Confirm by calling `FlutterForegroundTask.debugThreadId()` from the UI +isolate and again from inside `TaskHandler.onStart`; equal values mean the +runtime merged the threads despite the flag, and the task is effectively +running in `mergedEngine` mode. diff --git a/example/ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift b/example/ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift index d1429bee..b8784889 100644 --- a/example/ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift +++ b/example/ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift @@ -15,12 +15,14 @@ let package = Package( .library(name: "FlutterGeneratedPluginSwiftPackage", type: .static, targets: ["FlutterGeneratedPluginSwiftPackage"]) ], dependencies: [ + .package(name: "flutter_foreground_task", path: "../.packages/flutter_foreground_task"), .package(name: "shared_preferences_foundation", path: "../.packages/shared_preferences_foundation") ], targets: [ .target( name: "FlutterGeneratedPluginSwiftPackage", dependencies: [ + .product(name: "flutter-foreground-task", package: "flutter_foreground_task"), .product(name: "shared-preferences-foundation", package: "shared_preferences_foundation") ] ) diff --git a/example/lib/main.dart b/example/lib/main.dart index 2d9ac570..9d75a293 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,8 +1,10 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; +import 'package:platform/platform.dart'; + +const LocalPlatform localPlatform = LocalPlatform(); void main() { // Initialize port for communication between TaskHandler and UI. @@ -116,7 +118,7 @@ class _ExamplePageState extends State { await FlutterForegroundTask.requestNotificationPermission(); } - if (Platform.isAndroid) { + if (localPlatform.isAndroid) { // Android 12+, there are restrictions on starting a foreground service. // // To restart the service on device reboot or unexpected problem, you need to allow below permission. diff --git a/example/pubspec.yaml b/example/pubspec.yaml index b0bbe590..5a5bea49 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: # The example app is bundled with the plugin so we use a path dependency on # the parent directory to use the current plugin's version. path: ../ + platform: ^3.1.6 dev_dependencies: flutter_test: diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/MergedThreadOptOutDetector.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/MergedThreadOptOutDetector.swift new file mode 100644 index 00000000..581ff6c9 --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/MergedThreadOptOutDetector.swift @@ -0,0 +1,62 @@ +// +// MergedThreadOptOutDetector.swift +// flutter_foreground_task +// +// Detects whether the app has opted out of Flutter's merged platform/UI +// thread behavior via `FLTEnableMergedPlatformUIThread=false` in +// `Info.plist`. See documentation/merged_platform_ui_thread_mitigation.md. +// + +import Foundation + +enum MergedThreadOptOutDetector { + private static let infoPlistKey = "FLTEnableMergedPlatformUIThread" + private static var hasWarnedDedicatedEngine = false + private static let warnLock = NSLock() + + /// Returns `true` when `FLTEnableMergedPlatformUIThread` is set to `false` + /// in `Info.plist`, i.e. the app has asked Flutter to keep the UI isolate + /// on its own thread. + /// + /// The flag was removed / stopped being honored in Flutter 3.38. We + /// cannot detect the runtime Flutter version from Swift, so the plugin + /// treats the flag as a hint: when the caller asks for a dedicated + /// engine but the flag is missing, we log a warning and fall back. + static var isOptedOutOfMergedThread: Bool { + guard let value = Bundle.main.object(forInfoDictionaryKey: infoPlistKey) else { + return false + } + if let bool = value as? Bool { + return !bool + } + if let string = value as? String { + return string.lowercased() == "false" || string == "NO" + } + if let number = value as? NSNumber { + return !number.boolValue + } + return false + } + + /// Inspects the requested `mode` and returns the effective mode the + /// plugin should use. Emits a one-shot warning (per process) when + /// `.dedicatedEngine` is requested without the matching Info.plist key. + static func resolveEffectiveMode(_ mode: TaskExecutionMode) -> TaskExecutionMode { + guard mode == .dedicatedEngine else { return mode } + if isOptedOutOfMergedThread { return mode } + + warnLock.lock() + defer { warnLock.unlock() } + if !hasWarnedDedicatedEngine { + hasWarnedDedicatedEngine = true + print( + "[flutter_foreground_task] TaskExecutionMode.dedicatedEngine was requested but " + + "`FLTEnableMergedPlatformUIThread` is not set to false in Info.plist. " + + "Flutter 3.29+ multiplexes every FlutterEngine onto the main platform thread, " + + "so the task will run on the same thread as the UI. Falling back to " + + "TaskExecutionMode.mergedEngine. See documentation/threading_model.md." + ) + } + return .mergedEngine + } +} diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/PreferencesKey.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/PreferencesKey.swift index 8eeef2fc..8991ca1a 100644 --- a/ios/flutter_foreground_task/Sources/flutter_foreground_task/PreferencesKey.swift +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/PreferencesKey.swift @@ -28,6 +28,7 @@ let NOTIFICATION_CONTENT_BUTTONS = "buttons" let TASK_EVENT_ACTION = "taskEventAction" // new let INTERVAL = "interval" // deprecated let IS_ONCE_EVENT = "isOnceEvent" // deprecated +let EXECUTION_MODE = "executionMode" // task data let CALLBACK_HANDLE = "callbackHandle" diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/ForegroundTaskOptions.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/ForegroundTaskOptions.swift index 409477ca..80c03607 100644 --- a/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/ForegroundTaskOptions.swift +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/ForegroundTaskOptions.swift @@ -9,6 +9,7 @@ import Foundation struct ForegroundTaskOptions { let eventAction: ForegroundTaskEventAction + let executionMode: TaskExecutionMode static func getData() -> ForegroundTaskOptions { let prefs = ForegroundTaskStorageProvider.current @@ -27,8 +28,10 @@ struct ForegroundTaskOptions { eventAction = ForegroundTaskEventAction(type: .REPEAT, interval: oldInterval) } } + + let executionMode = TaskExecutionMode.fromRawValue(prefs.string(forKey: EXECUTION_MODE)) - return ForegroundTaskOptions(eventAction: eventAction) + return ForegroundTaskOptions(eventAction: eventAction, executionMode: executionMode) } static func setData(args: Dictionary) { @@ -41,6 +44,9 @@ struct ForegroundTaskOptions { } } } + + let executionMode = args[EXECUTION_MODE] as? String ?? TaskExecutionMode.mergedEngine.rawValue + prefs.set(executionMode, forKey: EXECUTION_MODE) } static func updateData(args: Dictionary) { @@ -53,6 +59,10 @@ struct ForegroundTaskOptions { } } } + + if let executionMode = args[EXECUTION_MODE] as? String { + prefs.set(executionMode, forKey: EXECUTION_MODE) + } } static func clearData() { @@ -60,5 +70,6 @@ struct ForegroundTaskOptions { prefs.removeObject(forKey: TASK_EVENT_ACTION) // new prefs.removeObject(forKey: INTERVAL) // deprecated prefs.removeObject(forKey: IS_ONCE_EVENT) // deprecated + prefs.removeObject(forKey: EXECUTION_MODE) } } diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/TaskExecutionMode.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/TaskExecutionMode.swift new file mode 100644 index 00000000..a1d40a18 --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/TaskExecutionMode.swift @@ -0,0 +1,36 @@ +// +// TaskExecutionMode.swift +// flutter_foreground_task +// +// Controls where the TaskHandler runs when the service starts. +// See documentation/threading_model.md for full context. +// + +import Foundation + +enum TaskExecutionMode: String { + /// Legacy behavior. A secondary FlutterEngine is created and, if + /// supported by the runtime, opts out of the merged platform/UI thread + /// via `FLTEnableMergedPlatformUIThread=false` in `Info.plist`. Falls + /// back to `.mergedEngine` with a one-shot warning when the opt-out is + /// missing (Flutter 3.38+ removed the honor behavior). + case dedicatedEngine + + /// Secondary FlutterEngine multiplexed onto the main platform thread. + /// Matches Flutter's own 3.29+ default. **Safe default.** + case mergedEngine + + /// Reserved value. End-to-end support is planned for a follow-up + /// release; today this downgrades to `.mergedEngine` with a one-shot + /// log line. See Phase 2 in + /// documentation/merged_platform_ui_thread_mitigation.md. + case backgroundIsolate + + static func fromRawValue(_ value: String?) -> TaskExecutionMode { + guard let value = value, + let mode = TaskExecutionMode(rawValue: value) else { + return .mergedEngine + } + return mode + } +} diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/BackgroundService.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/BackgroundService.swift index 22f9e45b..467c7905 100644 --- a/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/BackgroundService.swift +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/BackgroundService.swift @@ -250,6 +250,7 @@ class BackgroundService: NSObject { serviceStatus: backgroundServiceStatus, taskData: currForegroundTaskData, taskEventAction: currForegroundTaskOptions.eventAction, + requestedExecutionMode: currForegroundTaskOptions.executionMode, taskLifecycleListener: taskLifecycleListeners ) } diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/ForegroundTask.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/ForegroundTask.swift index 35ea4187..00193824 100644 --- a/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/ForegroundTask.swift +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/ForegroundTask.swift @@ -15,10 +15,19 @@ private let ACTION_TASK_REPEAT_EVENT = "onRepeatEvent" private let ACTION_TASK_DESTROY = "onDestroy" class ForegroundTask { + private static var hasWarnedBackgroundIsolate = false + private static let warnLock = NSLock() + private let serviceStatus: BackgroundServiceStatus private let taskData: ForegroundTaskData private var taskEventAction: ForegroundTaskEventAction private let taskLifecycleListener: FlutterForegroundTaskLifecycleListener + + /// Effective execution mode after reconciliation with platform + /// capabilities. `.backgroundIsolate` is reserved and currently + /// downgraded to `.mergedEngine`; `.dedicatedEngine` is downgraded when + /// `FLTEnableMergedPlatformUIThread` is not set to `false` in Info.plist. + let effectiveExecutionMode: TaskExecutionMode private var flutterEngine: FlutterEngine? = nil private var backgroundChannel: FlutterMethodChannel? = nil @@ -29,14 +38,34 @@ class ForegroundTask { serviceStatus: BackgroundServiceStatus, taskData: ForegroundTaskData, taskEventAction: ForegroundTaskEventAction, + requestedExecutionMode: TaskExecutionMode, taskLifecycleListener: FlutterForegroundTaskLifecycleListener ) { self.serviceStatus = serviceStatus self.taskData = taskData self.taskEventAction = taskEventAction self.taskLifecycleListener = taskLifecycleListener + self.effectiveExecutionMode = Self.resolveExecutionMode(requestedExecutionMode) initialize() } + + private static func resolveExecutionMode(_ requested: TaskExecutionMode) -> TaskExecutionMode { + if requested == .backgroundIsolate { + warnLock.lock() + defer { warnLock.unlock() } + if !hasWarnedBackgroundIsolate { + hasWarnedBackgroundIsolate = true + print( + "[flutter_foreground_task] TaskExecutionMode.backgroundIsolate was requested " + + "but is not yet fully implemented in this release. Falling back to " + + "TaskExecutionMode.mergedEngine. Track progress in " + + "documentation/merged_platform_ui_thread_mitigation.md (Phase 2)." + ) + } + return .mergedEngine + } + return MergedThreadOptOutDetector.resolveEffectiveMode(requested) + } private func initialize() { guard let registerPlugins = SwiftFlutterForegroundTaskPlugin.registerPlugins else { diff --git a/lib/flutter_foreground_task.dart b/lib/flutter_foreground_task.dart index 840d72c7..6166c5b6 100644 --- a/lib/flutter_foreground_task.dart +++ b/lib/flutter_foreground_task.dart @@ -30,6 +30,7 @@ export 'models/notification_permission.dart'; export 'models/notification_priority.dart'; export 'models/notification_visibility.dart'; export 'models/service_request_result.dart'; +export 'models/task_execution_mode.dart'; export 'ui/with_foreground_task.dart'; export 'task_handler.dart'; @@ -349,4 +350,28 @@ class FlutterForegroundTask { /// Always returns `false` on Android and on iOS versions prior to 26. static Future get isIOSContinuedProcessingTaskSupported => _platform.isIOSContinuedProcessingTaskSupported; + + // ============== Debug helpers ============== + + /// Returns the OS thread id of the Dart isolate that invoked this call. + /// + /// The probe runs in-process via `package:universal_ffi/ffi.dart`, so when + /// called from the UI + /// isolate it reports the UI isolate's OS thread, and when called from + /// inside a `TaskHandler` it reports the task isolate's OS thread. + /// + /// Use this to diagnose which [TaskExecutionMode] is actually in effect: + /// + /// ```dart + /// final uiTid = await FlutterForegroundTask.debugThreadId(); + /// // Inside TaskHandler.onStart: + /// final taskTid = await FlutterForegroundTask.debugThreadId(); + /// // uiTid == taskTid -> merged (same OS thread) + /// // uiTid != taskTid -> dedicated OS thread + /// ``` + /// + /// On Android this returns `gettid()`, on iOS / macOS the `mach_port_t` + /// from `pthread_mach_thread_np(pthread_self())`, and `null` on platforms + /// where the probe is not implemented (Linux/Windows desktop, web). + static Future debugThreadId() => _platform.debugThreadId(); } diff --git a/lib/flutter_foreground_task_method_channel.dart b/lib/flutter_foreground_task_method_channel.dart index 1135027e..225e65c4 100644 --- a/lib/flutter_foreground_task_method_channel.dart +++ b/lib/flutter_foreground_task_method_channel.dart @@ -17,6 +17,8 @@ import 'models/notification_options.dart'; import 'models/notification_permission.dart'; import 'models/service_options.dart'; import 'task_handler.dart'; +import 'utils/background_isolate_bootstrap.dart'; +import 'utils/thread_id_probe.dart'; /// An implementation of [FlutterForegroundTaskPlatform] that uses method channels. class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { @@ -121,6 +123,29 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { WidgetsFlutterBinding.ensureInitialized(); DartPluginRegistrant.ensureInitialized(); + // In backgroundIsolate mode the user's callback runs inside a nested + // Dart isolate spawned by `foregroundTaskBackgroundIsolateBootstrap`. + // That isolate does not own the secondary engine's background + // method channel, so installing a handler via + // `mBGChannel.setMethodCallHandler` would be a no-op: the + // MethodChannel's messenger is bound to the bootstrap isolate, not + // this one. Instead, the dispatcher translates SendPort messages + // into TaskHandler method calls; we attach the handler to it. + if (isInBackgroundTaskIsolate) { + final BackgroundTaskDispatcher? dispatcher = + activeBackgroundTaskDispatcher; + if (dispatcher == null) { + throw StateError( + 'setTaskHandler was called inside a background task isolate but ' + 'no dispatcher is registered. This indicates the isolate was ' + 'started outside of foregroundTaskBackgroundIsolateBootstrap, ' + 'which is a plugin bug.', + ); + } + dispatcher.attachTaskHandler(handler); + return; + } + // Set the method call handler for the background channel. mBGChannel.setMethodCallHandler((call) async { await onBackgroundChannel(call, handler); @@ -316,4 +341,18 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { .invokeMethod('isIOSContinuedProcessingTaskSupported') ?? false; } + + @override + Future debugThreadId() async { + // Runs inside the calling Dart isolate via `package:universal_ffi/ffi.dart`, + // so it reports the OS thread currently owning that isolate regardless of + // whether the task engine's UI thread is merged with the platform thread. + // + // The previous implementation round-tripped through a `MethodChannel`, + // which always runs its handler on the target engine's platform task + // runner -- the main thread on Android and iOS -- and therefore could not + // distinguish `mergedEngine` from `dedicatedEngine`. See Bug #1 in + // `documentation/merged_platform_ui_thread_mitigation.md`. + return nativeThreadId(); + } } diff --git a/lib/flutter_foreground_task_platform_interface.dart b/lib/flutter_foreground_task_platform_interface.dart index b0e05960..a92b1ad3 100644 --- a/lib/flutter_foreground_task_platform_interface.dart +++ b/lib/flutter_foreground_task_platform_interface.dart @@ -169,4 +169,26 @@ abstract class FlutterForegroundTaskPlatform extends PlatformInterface { throw UnimplementedError( 'isIOSContinuedProcessingTaskSupported has not been implemented.'); } + + // ======= Debug helpers ======= + + /// Returns the OS thread id of the Dart isolate that invoked this call. + /// + /// Implemented via `package:universal_ffi/ffi.dart` so the probe runs inside + /// the calling isolate (no platform-channel round-trip): + /// + /// * Android: `gettid()` from `libc.so`. + /// * iOS / macOS: `pthread_mach_thread_np(pthread_self())`. + /// * Other platforms: `null`. + /// + /// Use this from the UI isolate and from inside `TaskHandler.onStart` to + /// verify which [TaskExecutionMode] is actually in effect: matching values + /// means both isolates run on the same OS thread (merged), different + /// values means they are on separate threads. See + /// `documentation/threading_model.md` for examples and + /// `documentation/merged_platform_ui_thread_mitigation.md` (Bug #1) for + /// why a `MethodChannel`-based probe would give the wrong answer here. + Future debugThreadId() { + throw UnimplementedError('debugThreadId() has not been implemented.'); + } } diff --git a/lib/models/foreground_task_options.dart b/lib/models/foreground_task_options.dart index 814bb16a..b28821c2 100644 --- a/lib/models/foreground_task_options.dart +++ b/lib/models/foreground_task_options.dart @@ -1,4 +1,5 @@ import 'foreground_task_event_action.dart'; +import 'task_execution_mode.dart'; /// Data class with foreground task options. class ForegroundTaskOptions { @@ -11,6 +12,7 @@ class ForegroundTaskOptions { this.allowWifiLock = false, this.allowAutoRestart = true, this.stopWithTask, + this.executionMode = TaskExecutionMode.mergedEngine, }); /// The action of onRepeatEvent in [TaskHandler]. @@ -43,6 +45,14 @@ class ForegroundTaskOptions { /// If set, overrides the service android:stopWithTask behavior. final bool? stopWithTask; + /// Controls where the `TaskHandler` runs when the service starts. + /// + /// Defaults to [TaskExecutionMode.mergedEngine], which matches Flutter's + /// own 3.29+ default and keeps the full plugin API available. See + /// [TaskExecutionMode] and `documentation/threading_model.md` for the + /// trade-offs of each mode. + final TaskExecutionMode executionMode; + /// Returns the data fields of [ForegroundTaskOptions] in JSON format. Map toJson() { return { @@ -53,6 +63,7 @@ class ForegroundTaskOptions { 'allowWifiLock': allowWifiLock, 'allowAutoRestart': allowAutoRestart, if (stopWithTask != null) 'stopWithTask': stopWithTask, + 'executionMode': executionMode.rawValue, }; } @@ -67,6 +78,7 @@ class ForegroundTaskOptions { bool? allowWifiLock, bool? allowAutoRestart, Object? stopWithTask = _unset, + TaskExecutionMode? executionMode, }) => ForegroundTaskOptions( eventAction: eventAction ?? this.eventAction, @@ -76,5 +88,6 @@ class ForegroundTaskOptions { allowWifiLock: allowWifiLock ?? this.allowWifiLock, allowAutoRestart: allowAutoRestart ?? this.allowAutoRestart, stopWithTask: identical(stopWithTask, _unset) ? this.stopWithTask : stopWithTask as bool?, + executionMode: executionMode ?? this.executionMode, ); } \ No newline at end of file diff --git a/lib/models/service_options.dart b/lib/models/service_options.dart index 3f6340b2..fdf80ed4 100644 --- a/lib/models/service_options.dart +++ b/lib/models/service_options.dart @@ -2,11 +2,13 @@ import 'dart:ui'; import 'package:platform/platform.dart'; +import '../utils/background_isolate_bootstrap.dart'; import 'foreground_service_types.dart'; import 'foreground_task_options.dart'; import 'notification_button.dart'; import 'notification_icon.dart'; import 'notification_options.dart'; +import 'task_execution_mode.dart'; class ServiceStartOptions { const ServiceStartOptions({ @@ -64,6 +66,30 @@ class ServiceStartOptions { PluginUtilities.getCallbackHandle(callback!)?.toRawHandle(); } + // When running in backgroundIsolate mode, the native side must + // launch OUR bootstrap entrypoint inside the secondary FlutterEngine + // instead of the user's callback. It still needs the user's handle + // (passed above as `callbackHandle`) so the bootstrap can hand it + // to the nested task isolate, but it also needs the bootstrap's + // own handle as the function to invoke. We compute it here so the + // native side does not have to hard-code a library URI. + // + // NOTE: the Android / iOS native plumbing that actually uses + // `bootstrapCallbackHandle` is not yet in place -- native still + // downgrades backgroundIsolate to mergedEngine with a warning. + // This field is wired in advance so the Phase 2 native patches can + // land without a coordinated Dart change. + if (foregroundTaskOptions.executionMode == + TaskExecutionMode.backgroundIsolate) { + final int? bootstrapHandle = + PluginUtilities.getCallbackHandle( + foregroundTaskBackgroundIsolateBootstrap) + ?.toRawHandle(); + if (bootstrapHandle != null) { + json['bootstrapCallbackHandle'] = bootstrapHandle; + } + } + return json; } } @@ -108,6 +134,17 @@ class ServiceUpdateOptions { PluginUtilities.getCallbackHandle(callback!)?.toRawHandle(); } + final TaskExecutionMode? mode = foregroundTaskOptions?.executionMode; + if (mode == TaskExecutionMode.backgroundIsolate) { + final int? bootstrapHandle = + PluginUtilities.getCallbackHandle( + foregroundTaskBackgroundIsolateBootstrap) + ?.toRawHandle(); + if (bootstrapHandle != null) { + json['bootstrapCallbackHandle'] = bootstrapHandle; + } + } + return json; } } diff --git a/lib/models/task_execution_mode.dart b/lib/models/task_execution_mode.dart new file mode 100644 index 00000000..2cdbcc08 --- /dev/null +++ b/lib/models/task_execution_mode.dart @@ -0,0 +1,96 @@ +/// Controls where the `TaskHandler` runs when the foreground service starts. +/// +/// Starting with Flutter 3.29 (and by default on iOS/Android in 3.32) every +/// `FlutterEngine` in a process multiplexes its UI (Dart) isolate onto the +/// main platform thread. The global merge opt-out flags +/// (`DisableMergedPlatformUIThread` on Android, `FLTEnableMergedPlatformUIThread` +/// on iOS) stopped being honored in Flutter 3.38. +/// +/// This enum lets the plugin surface that trade-off explicitly. See +/// `documentation/threading_model.md` for a full decision guide. +enum TaskExecutionMode { + /// Legacy behavior (pre-3.29). A secondary `FlutterEngine` is created and, + /// if the platform supports it, the global merge opt-out flag is expected + /// to be set so that the engine's isolate runs on its own OS thread. + /// + /// The plugin detects the flag at runtime. If it is missing or no longer + /// effective (Flutter 3.38+) the plugin logs a one-shot warning and falls + /// back to [mergedEngine]. + /// + /// Use this on Flutter ≤ 3.37 with `DisableMergedPlatformUIThread=true` / + /// `FLTEnableMergedPlatformUIThread=false` when your task uses plugins that + /// rely on `MethodChannel.setMessageHandler` callbacks *and* you need a + /// dedicated thread. + dedicatedEngine, + + /// A secondary `FlutterEngine` is created but is multiplexed onto the main + /// platform thread. Matches Flutter's own 3.29+ default. + /// + /// Plugin API remains fully available, including method-channel callbacks. + /// Heavy work inside the `TaskHandler` will jank the main UI and vice + /// versa; use `compute`, FFI, or native threads to offload CPU-bound work. + /// + /// **Safe default** across every Flutter version. + mergedEngine, + + /// The foreground service creates a secondary `FlutterEngine` as today + /// (so plugins stay registered and the process is kept alive by the + /// service notification), and inside that engine's Dart bootstrap an + /// `Isolate.spawn` creates the real task isolate that hosts + /// `TaskHandler`. This runs on Dart's VM thread pool and is therefore on + /// a different OS thread than the UI isolate, regardless of whether + /// Flutter merged the platform/UI threads. + /// + /// `MethodChannel.invokeMethod` works normally via + /// `BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken)` + /// using the secondary engine's token. Incoming callbacks normally + /// delivered through `MethodChannel.setMessageHandler` are relayed into + /// the task isolate through a `NativeCallable.listener`-based FFI + /// trampoline shipped by this plugin; plugin authors (or app authors) + /// wire their native side into the relay to have their events reach the + /// task isolate. + /// + /// Plugins that have not adopted the relay will not deliver incoming + /// callbacks in this mode — stay on [mergedEngine] or [dedicatedEngine] + /// if your task depends on channel-callback plugins that you can't + /// extend. `dart:ui` is not available in this mode. + /// + /// > **Note (v9.3.0):** end-to-end support (the nested isolate + /// > dispatcher and the `NativeCallable.listener` relay) ships in a + /// > follow-up release. Selecting this mode today logs a one-shot + /// > warning and falls back to [mergedEngine]. The enum value is + /// > reserved so apps can be prepared for the migration. + /// + /// See [the threading model doc](https://github.com/Dev-hwang/flutter_foreground_task/blob/master/documentation/threading_model.md) + /// and + /// [merged_platform_ui_thread_mitigation.md §2.5](https://github.com/Dev-hwang/flutter_foreground_task/blob/master/documentation/merged_platform_ui_thread_mitigation.md#25-design-revisions-adopted-from-issue-352-maintainer-feedback) + /// for the underlying design. + backgroundIsolate; + + /// Stable string identifier shipped over the method channel to native. + String get rawValue { + switch (this) { + case TaskExecutionMode.dedicatedEngine: + return 'dedicatedEngine'; + case TaskExecutionMode.mergedEngine: + return 'mergedEngine'; + case TaskExecutionMode.backgroundIsolate: + return 'backgroundIsolate'; + } + } + + /// Parses the raw string written by [rawValue]. Unknown values fall back + /// to [mergedEngine] to match the safe default on the native side. + static TaskExecutionMode fromRawValue(String? value) { + switch (value) { + case 'dedicatedEngine': + return TaskExecutionMode.dedicatedEngine; + case 'mergedEngine': + return TaskExecutionMode.mergedEngine; + case 'backgroundIsolate': + return TaskExecutionMode.backgroundIsolate; + default: + return TaskExecutionMode.mergedEngine; + } + } +} diff --git a/lib/utils/background_isolate_bootstrap.dart b/lib/utils/background_isolate_bootstrap.dart new file mode 100644 index 00000000..1c1df351 --- /dev/null +++ b/lib/utils/background_isolate_bootstrap.dart @@ -0,0 +1,738 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:isolate'; +import 'dart:ui'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../flutter_foreground_task_controller.dart'; +import '../task_handler.dart'; + +/// Dart-side plumbing for [TaskExecutionMode.backgroundIsolate]. +/// +/// Invariants of this layer: +/// +/// * [foregroundTaskBackgroundIsolateBootstrap] is the entrypoint the +/// native side launches inside the secondary `FlutterEngine` *instead +/// of* the user's `startCallback`. It runs on the secondary engine's +/// root isolate. +/// * Inside the bootstrap isolate we open the background +/// `MethodChannel`, capture `RootIsolateToken.instance`, and +/// `Isolate.spawn` a nested pure Dart isolate that will host the +/// user's [TaskHandler]. Lifecycle messages arriving on the +/// `MethodChannel` are forwarded to the nested isolate over a +/// [SendPort]; responses (acks) flow back the other way so +/// `MethodChannel.invokeMethod` can `await` the user's async +/// `onStart` etc. +/// * The nested isolate runs [_taskIsolateEntry]. It calls +/// `BackgroundIsolateBinaryMessenger.ensureInitialized` with the +/// bootstrap's token, flips [isInBackgroundTaskIsolate] to `true`, +/// and then invokes the user's original callback. The user's +/// `FlutterForegroundTask.setTaskHandler(handler)` then registers +/// with the dispatcher on *this* isolate (not with the MethodChannel +/// on the bootstrap isolate), and from that point on the dispatcher +/// is in charge of turning incoming `SendPort` messages into +/// `TaskHandler` method calls. +/// +/// This file is intentionally side-effect-free at import time -- +/// neither the bootstrap nor the nested entrypoint runs unless the +/// native side deliberately invokes it as a Dart callback. The +/// functions themselves are annotated with `@pragma('vm:entry-point')` +/// so the Dart compiler keeps them reachable for +/// `PluginUtilities.getCallbackHandle`. +/// +/// Until the Android / iOS native sides are updated to execute the +/// bootstrap entrypoint (see +/// `documentation/merged_platform_ui_thread_mitigation.md` \u00a78 Phase 2), +/// the runtime still downgrades `backgroundIsolate` mode to +/// `mergedEngine`. The Dart plumbing below is therefore exercised only +/// by unit tests in this slice. + +// --------------------------------------------------------------------------- +// Internal protocol between the bootstrap isolate and the task isolate. +// --------------------------------------------------------------------------- + +/// Lifecycle message types forwarded from the bootstrap isolate (which +/// owns the MethodChannel) to the nested task isolate (which hosts the +/// user's TaskHandler). Kept as strings so they survive the isolate-port +/// copy path and stay readable in logs. +class _BootstrapMessage { + _BootstrapMessage._(); + + static const String onServiceIdSet = 'onServiceIdSet'; + static const String onStart = 'onStart'; + static const String onRepeatEvent = 'onRepeatEvent'; + static const String onDestroy = 'onDestroy'; + static const String onReceiveData = 'onReceiveData'; + static const String onNotificationButtonPressed = + 'onNotificationButtonPressed'; + static const String onNotificationDismissed = 'onNotificationDismissed'; + static const String onNotificationPressed = 'onNotificationPressed'; + static const String shutdown = '__shutdown__'; +} + +/// Envelope sent from the bootstrap isolate to the task isolate. The +/// [replyPort] is optional: it is non-null for messages where the +/// bootstrap needs to `await` completion before responding to the +/// native MethodChannel caller (e.g. [ _BootstrapMessage.onStart ]). +class _BootstrapEnvelope { + _BootstrapEnvelope({ + required this.kind, + this.argument, + this.replyPort, + required this.timestampMicros, + }); + + final String kind; + final Object? argument; + final SendPort? replyPort; + + /// Microsecond epoch of when the bootstrap isolate received the + /// underlying native call. Forwarded so the task isolate can produce + /// the same `DateTime.timestamp()` the MethodChannel path uses. + final int timestampMicros; +} + +/// Handshake payload sent from the task isolate to the bootstrap once +/// the user's `setTaskHandler` has registered a handler. The bootstrap +/// only starts draining its inbox after receiving this, so early +/// lifecycle events cannot race with handler registration. +class _DispatcherReady { + _DispatcherReady(this.inboundPort); + + final SendPort inboundPort; +} + +/// Arguments for `Isolate.spawn(_taskIsolateEntry, ...)`. Must be +/// sendable: token is a [RootIsolateToken], the handle is an `int`, +/// the SendPort is sendable by Dart. +class TaskIsolateSpawnArgs { + TaskIsolateSpawnArgs({ + required this.rootIsolateToken, + required this.userCallbackHandle, + required this.bootstrapSendPort, + }); + + final RootIsolateToken rootIsolateToken; + final int userCallbackHandle; + final SendPort bootstrapSendPort; +} + +// --------------------------------------------------------------------------- +// Public (library-internal) flags and accessors used by the +// MethodChannelFlutterForegroundTask routing logic. +// --------------------------------------------------------------------------- + +/// Zone-level flag: `true` if the current isolate is the nested task +/// isolate spawned by [foregroundTaskBackgroundIsolateBootstrap]. The +/// dispatcher sets this once, early, so +/// `MethodChannelFlutterForegroundTask.setTaskHandler` can branch on it. +/// +/// This is isolate-local by virtue of being a plain Dart static: every +/// isolate has its own copy of the field. +bool _isInBackgroundTaskIsolate = false; + +/// Returns `true` when the caller runs inside the nested task isolate. +bool get isInBackgroundTaskIsolate => _isInBackgroundTaskIsolate; + +/// Marks the current isolate as the nested task isolate. Exposed for +/// tests and for the dispatcher itself. +@visibleForTesting +void debugMarkAsBackgroundTaskIsolate({bool value = true}) { + _isInBackgroundTaskIsolate = value; +} + +/// Registry used by `MethodChannelFlutterForegroundTask.setTaskHandler` +/// when [isInBackgroundTaskIsolate] is true. The dispatcher sets its +/// own receiver here before invoking the user's callback, so when the +/// user's callback calls `FlutterForegroundTask.setTaskHandler(handler)` +/// the handler lands on the dispatcher rather than on an unreachable +/// MethodChannel. +BackgroundTaskDispatcher? _activeDispatcher; + +/// The dispatcher protocol exposed to +/// `MethodChannelFlutterForegroundTask`. +abstract class BackgroundTaskDispatcher { + /// Called by `setTaskHandler` when running in the nested task + /// isolate. The dispatcher takes ownership of [handler] and wires it + /// to its inbound SendPort. + void attachTaskHandler(TaskHandler handler); +} + +/// Registers a dispatcher for the current isolate. Must be called +/// exactly once per nested task isolate, from the dispatcher itself. +void registerBackgroundTaskDispatcher(BackgroundTaskDispatcher dispatcher) { + _activeDispatcher = dispatcher; +} + +/// Clears the registered dispatcher. Exposed for tests; the runtime +/// leaves the dispatcher in place for the lifetime of the isolate. +@visibleForTesting +void debugClearBackgroundTaskDispatcher() { + _activeDispatcher = null; +} + +/// Returns the currently-registered dispatcher, or `null` if this +/// isolate has no dispatcher (e.g. it is the UI isolate or the +/// mergedEngine / dedicatedEngine path). +BackgroundTaskDispatcher? get activeBackgroundTaskDispatcher => + _activeDispatcher; + +// --------------------------------------------------------------------------- +// Bootstrap entrypoint (runs inside the secondary FlutterEngine). +// --------------------------------------------------------------------------- + +/// Top-level entrypoint invoked by the native side (Android +/// `DartExecutor.executeDartCallback`, iOS +/// `FlutterEngine.run(withEntrypoint:)`) when a service is starting in +/// [TaskExecutionMode.backgroundIsolate] mode. +/// +/// The native side must have resolved this function's callback handle +/// via `PluginUtilities.getCallbackHandle` on the Dart side beforehand +/// and passed that integer down to the native service so it can look it +/// up with `FlutterCallbackInformation.lookupCallbackInformation`. See +/// `ServiceStartOptions.toJson` for the payload shape. +@pragma('vm:entry-point') +Future foregroundTaskBackgroundIsolateBootstrap() async { + WidgetsFlutterBinding.ensureInitialized(); + DartPluginRegistrant.ensureInitialized(); + + final _BackgroundIsolateBootstrap bootstrap = + _BackgroundIsolateBootstrap._(); + await bootstrap._run(); +} + +/// Test-only hook: runs the bootstrap with an injected method channel +/// and an injected "spawn" function, letting unit tests exercise the +/// SendPort wiring without touching a real `FlutterEngine` or a real +/// `Isolate.spawn`. +/// +/// `spawnTaskIsolate` is given the arguments the real bootstrap would +/// pass to `Isolate.spawn`; tests typically run [_taskIsolateEntry] +/// directly on the same isolate for simplicity. +@visibleForTesting +Future debugRunBootstrap({ + required MethodChannel channel, + required RootIsolateToken rootIsolateToken, + required int userCallbackHandle, + required Future Function(TaskIsolateSpawnArgs args) + spawnTaskIsolate, +}) async { + final _BackgroundIsolateBootstrap bootstrap = + _BackgroundIsolateBootstrap._( + channel: channel, + rootIsolateTokenOverride: rootIsolateToken, + userCallbackHandleOverride: userCallbackHandle, + spawnTaskIsolateOverride: spawnTaskIsolate, + ); + unawaited(bootstrap._run()); + return BackgroundIsolateBootstrapTestHandle._(bootstrap); +} + +/// Test-only handle returned by [debugRunBootstrap]. +@visibleForTesting +class BackgroundIsolateBootstrapTestHandle { + BackgroundIsolateBootstrapTestHandle._(this._bootstrap); + + final _BackgroundIsolateBootstrap _bootstrap; + + /// Waits until the bootstrap has received the dispatcher's `ready` + /// handshake and is prepared to forward lifecycle events. Tests use + /// this to avoid races between `onStart` and handler registration. + Future get ready => _bootstrap._dispatcherReady.future; + + /// Feed a synthetic MethodCall through the same code path the native + /// side would exercise. Returns whatever the handler returns, which + /// is what would be marshalled back to the native caller. + Future debugInjectMethodCall(MethodCall call) { + return _bootstrap._handleChannelCall(call); + } + + Future shutdown() => _bootstrap._shutdown(); +} + +class _BackgroundIsolateBootstrap { + _BackgroundIsolateBootstrap._({ + MethodChannel? channel, + RootIsolateToken? rootIsolateTokenOverride, + int? userCallbackHandleOverride, + Future Function(TaskIsolateSpawnArgs args)? + spawnTaskIsolateOverride, + }) : _channel = channel ?? + const MethodChannel('flutter_foreground_task/background'), + _rootIsolateTokenOverride = rootIsolateTokenOverride, + _userCallbackHandleOverride = userCallbackHandleOverride, + _spawnTaskIsolateOverride = spawnTaskIsolateOverride; + + final MethodChannel _channel; + final RootIsolateToken? _rootIsolateTokenOverride; + final int? _userCallbackHandleOverride; + final Future Function(TaskIsolateSpawnArgs args)? + _spawnTaskIsolateOverride; + + final Completer _dispatcherReady = Completer(); + SendPort? _dispatcherPort; + ReceivePort? _fromDispatcher; + + Future _run() async { + _channel.setMethodCallHandler(_handleChannelCall); + + final RootIsolateToken? token = + _rootIsolateTokenOverride ?? RootIsolateToken.instance; + if (token == null) { + // `RootIsolateToken.instance` can only be null in environments + // that don't support background-isolate messaging (e.g. the + // web). The native side already guarantees we only reach this + // entrypoint on supported platforms, so failing loud here is + // appropriate. + throw StateError( + 'RootIsolateToken.instance is null; backgroundIsolate mode is not ' + 'available in this runtime.', + ); + } + + final int userHandle = _userCallbackHandleOverride ?? + await _fetchUserCallbackHandle(); + + _fromDispatcher = ReceivePort(); + _fromDispatcher!.listen(_handleDispatcherMessage); + + final TaskIsolateSpawnArgs spawnArgs = TaskIsolateSpawnArgs( + rootIsolateToken: token, + userCallbackHandle: userHandle, + bootstrapSendPort: _fromDispatcher!.sendPort, + ); + + final Future Function(TaskIsolateSpawnArgs args)? override = + _spawnTaskIsolateOverride; + if (override != null) { + await override(spawnArgs); + } else { + await Isolate.spawn( + _taskIsolateEntry, + spawnArgs, + debugName: 'flutter_foreground_task::backgroundIsolate', + ); + } + + // From here on the dispatcher is in charge. The bootstrap isolate + // keeps running (owning the MethodChannel) but does nothing + // interesting until the dispatcher signals readiness. + } + + /// Requests the user's callback handle from the native side. The + /// native side exposes it via a `getUserCallbackHandle` MethodCall + /// on the background channel. + Future _fetchUserCallbackHandle() async { + final Object? result = + await _channel.invokeMethod('getUserCallbackHandle'); + if (result is int) { + return result; + } + throw StateError( + 'Native side did not return a user callback handle for ' + 'backgroundIsolate bootstrap (got: $result).', + ); + } + + void _handleDispatcherMessage(dynamic message) { + if (message is _DispatcherReady) { + _dispatcherPort = message.inboundPort; + if (!_dispatcherReady.isCompleted) { + _dispatcherReady.complete(); + } + } + // All other messages flowing from the dispatcher back to the + // bootstrap are handled via per-call replyPorts, not via this + // main receive port. + } + + Future _handleChannelCall(MethodCall call) async { + await _dispatcherReady.future; + + final DateTime timestamp = DateTime.timestamp(); + final int micros = timestamp.microsecondsSinceEpoch; + + switch (call.method) { + case 'onServiceIdSet': + _forward( + kind: _BootstrapMessage.onServiceIdSet, + argument: call.arguments, + micros: micros, + ); + return null; + case 'onStart': + return _forwardAndAwait( + kind: _BootstrapMessage.onStart, + argument: call.arguments, + micros: micros, + ); + case 'onRepeatEvent': + _forward( + kind: _BootstrapMessage.onRepeatEvent, + argument: null, + micros: micros, + ); + return null; + case 'onDestroy': + return _forwardAndAwait( + kind: _BootstrapMessage.onDestroy, + argument: call.arguments, + micros: micros, + ); + case 'onReceiveData': + _forward( + kind: _BootstrapMessage.onReceiveData, + argument: call.arguments, + micros: micros, + ); + return null; + case 'onNotificationButtonPressed': + _forward( + kind: _BootstrapMessage.onNotificationButtonPressed, + argument: call.arguments, + micros: micros, + ); + return null; + case 'onNotificationDismissed': + _forward( + kind: _BootstrapMessage.onNotificationDismissed, + argument: null, + micros: micros, + ); + return null; + case 'onNotificationPressed': + _forward( + kind: _BootstrapMessage.onNotificationPressed, + argument: null, + micros: micros, + ); + return null; + } + return null; + } + + void _forward({ + required String kind, + required Object? argument, + required int micros, + }) { + _dispatcherPort!.send(_BootstrapEnvelope( + kind: kind, + argument: argument, + timestampMicros: micros, + )); + } + + Future _forwardAndAwait({ + required String kind, + required Object? argument, + required int micros, + }) { + final ReceivePort reply = ReceivePort(); + _dispatcherPort!.send(_BootstrapEnvelope( + kind: kind, + argument: argument, + replyPort: reply.sendPort, + timestampMicros: micros, + )); + final Future done = reply.first.then((dynamic value) { + reply.close(); + if (value is _DispatchError) { + // Surface as a platform exception so the native side receives + // the same shape it would for a failing mergedEngine handler. + throw PlatformException( + code: 'background_isolate_dispatch_error', + message: value.message, + details: value.stackTrace?.toString(), + ); + } + return value; + }); + return done; + } + + Future _shutdown() async { + _channel.setMethodCallHandler(null); + final SendPort? port = _dispatcherPort; + if (port != null) { + final ReceivePort reply = ReceivePort(); + port.send(_BootstrapEnvelope( + kind: _BootstrapMessage.shutdown, + replyPort: reply.sendPort, + timestampMicros: DateTime.timestamp().microsecondsSinceEpoch, + )); + try { + await reply.first.timeout(const Duration(seconds: 2)); + } catch (_) { + // Best-effort shutdown; the dispatcher may already be gone. + } finally { + reply.close(); + } + } + _fromDispatcher?.close(); + _dispatcherPort = null; + } +} + +// --------------------------------------------------------------------------- +// Nested task-isolate entrypoint. +// --------------------------------------------------------------------------- + +/// Sentinel wrapper signalling that the dispatcher caught an +/// exception while running a TaskHandler method and wants the +/// bootstrap to surface it as a PlatformException to the native side. +class _DispatchError { + _DispatchError(this.message, this.stackTrace); + + final String message; + final StackTrace? stackTrace; +} + +/// Entrypoint for the nested Dart isolate that actually hosts the +/// user's [TaskHandler]. Spawned by +/// [foregroundTaskBackgroundIsolateBootstrap] on the secondary +/// engine's root isolate. +@pragma('vm:entry-point') +Future _taskIsolateEntry(TaskIsolateSpawnArgs args) async { + BackgroundIsolateBinaryMessenger.ensureInitialized(args.rootIsolateToken); + + _isInBackgroundTaskIsolate = true; + + final _BackgroundIsolateDispatcher dispatcher = + _BackgroundIsolateDispatcher( + bootstrapPort: args.bootstrapSendPort, + ); + registerBackgroundTaskDispatcher(dispatcher); + dispatcher._start(); + + // Invoke the user's original callback. They will call + // `FlutterForegroundTask.setTaskHandler(handler)` somewhere inside, + // which reaches `attachTaskHandler` on *this* dispatcher. + final CallbackHandle handle = CallbackHandle.fromRawHandle( + args.userCallbackHandle, + ); + final Function? userCallback = + PluginUtilities.getCallbackFromHandle(handle); + if (userCallback == null) { + throw StateError( + 'Could not resolve user callback handle ${args.userCallbackHandle} ' + 'in the background task isolate. Make sure your callback is a ' + 'top-level function annotated with @pragma("vm:entry-point").', + ); + } + + final dynamic maybeFuture = Function.apply(userCallback, const []); + if (maybeFuture is Future) { + await maybeFuture; + } +} + +/// Test-only entrypoint that runs the dispatcher on the *current* +/// isolate rather than spawning a new one. Tests can drive it using +/// the returned [BackgroundTaskDispatcherTestHarness]. +@visibleForTesting +BackgroundTaskDispatcherTestHarness debugStartDispatcher({ + required SendPort bootstrapPort, +}) { + _isInBackgroundTaskIsolate = true; + final _BackgroundIsolateDispatcher dispatcher = + _BackgroundIsolateDispatcher(bootstrapPort: bootstrapPort); + registerBackgroundTaskDispatcher(dispatcher); + dispatcher._start(); + return BackgroundTaskDispatcherTestHarness._(dispatcher); +} + +@visibleForTesting +class BackgroundTaskDispatcherTestHarness { + BackgroundTaskDispatcherTestHarness._(this._dispatcher); + + final _BackgroundIsolateDispatcher _dispatcher; + + /// Attach a task handler as if `setTaskHandler` had been invoked + /// from the user's callback. + void attachTaskHandler(TaskHandler handler) { + _dispatcher.attachTaskHandler(handler); + } + + /// Inject a synthetic lifecycle envelope at the dispatcher's inbound + /// port. Accepts the same `kind` strings the bootstrap forwards: + /// `onStart`, `onRepeatEvent`, `onDestroy`, `onReceiveData`, + /// `onServiceIdSet`, `onNotificationButtonPressed`, + /// `onNotificationDismissed`, `onNotificationPressed`. + /// + /// Returns a Future that completes with the reply the dispatcher + /// sends back, or `null` for fire-and-forget kinds (no `replyPort`). + Future debugInjectEnvelope({ + required String kind, + Object? argument, + DateTime? timestamp, + bool expectReply = true, + }) { + final int micros = + (timestamp ?? DateTime.timestamp()).microsecondsSinceEpoch; + if (!expectReply) { + _dispatcher._inbound.sendPort.send(_BootstrapEnvelope( + kind: kind, + argument: argument, + timestampMicros: micros, + )); + return Future.value(null); + } + final ReceivePort reply = ReceivePort(); + _dispatcher._inbound.sendPort.send(_BootstrapEnvelope( + kind: kind, + argument: argument, + replyPort: reply.sendPort, + timestampMicros: micros, + )); + return reply.first.then((dynamic value) { + reply.close(); + if (value is _DispatchError) { + return DispatcherTestError(value.message, value.stackTrace); + } + return value; + }); + } + + /// Stop the dispatcher, release static registration, and clear the + /// isolate flag so later tests start from a clean slate. + void close() { + _dispatcher._stop(); + debugClearBackgroundTaskDispatcher(); + _isInBackgroundTaskIsolate = false; + } +} + +/// Test-only error type returned by [BackgroundTaskDispatcherTestHarness +/// .debugInjectEnvelope] when the dispatcher captured an exception from +/// the TaskHandler. Tests assert against its [message] / [stackTrace] +/// rather than matching the internal `_DispatchError` type. +@visibleForTesting +class DispatcherTestError { + DispatcherTestError(this.message, this.stackTrace); + + final String message; + final StackTrace? stackTrace; + + @override + String toString() => 'DispatcherTestError($message)'; +} + +class _BackgroundIsolateDispatcher implements BackgroundTaskDispatcher { + _BackgroundIsolateDispatcher({required this.bootstrapPort}); + + final SendPort bootstrapPort; + + final ReceivePort _inbound = ReceivePort(); + StreamSubscription? _subscription; + TaskHandler? _handler; + final List<_BootstrapEnvelope> _pending = <_BootstrapEnvelope>[]; + + void _start() { + _subscription = _inbound.listen(_onMessage); + // Announce ourselves to the bootstrap; it won't forward anything + // until it receives this. + bootstrapPort.send(_DispatcherReady(_inbound.sendPort)); + } + + void _stop() { + _subscription?.cancel(); + _subscription = null; + _inbound.close(); + _handler = null; + _pending.clear(); + } + + @override + void attachTaskHandler(TaskHandler handler) { + _handler = handler; + // Drain anything that may have queued up between "dispatcher + // started" and "user called setTaskHandler". + final List<_BootstrapEnvelope> queued = + List<_BootstrapEnvelope>.of(_pending); + _pending.clear(); + for (final _BootstrapEnvelope env in queued) { + _dispatch(env); + } + } + + void _onMessage(dynamic message) { + if (message is! _BootstrapEnvelope) { + return; + } + if (message.kind == _BootstrapMessage.shutdown) { + message.replyPort?.send(null); + _stop(); + return; + } + if (_handler == null) { + // Hold onto early events until the user's callback has + // registered a handler. + _pending.add(message); + return; + } + _dispatch(message); + } + + Future _dispatch(_BootstrapEnvelope env) async { + final TaskHandler handler = _handler!; + // Reconstruct in UTC to match the MethodChannel path, which uses + // `DateTime.timestamp()` in the bootstrap. Using `isUtc: false` + // would silently shift the timezone by whatever the host is set + // to, breaking any TaskHandler that compares incoming timestamps + // against its own `DateTime.timestamp()` calls. + final DateTime timestamp = DateTime.fromMicrosecondsSinceEpoch( + env.timestampMicros, + isUtc: true, + ); + + try { + switch (env.kind) { + case _BootstrapMessage.onServiceIdSet: + final Object? arg = env.argument; + if (arg is String) { + FlutterForegroundTaskController.setCurrentServiceId(arg); + } + break; + case _BootstrapMessage.onStart: + final int starterIndex = (env.argument as int?) ?? 0; + final TaskStarter starter = TaskStarter.fromIndex(starterIndex); + await handler.onStart(timestamp, starter); + break; + case _BootstrapMessage.onRepeatEvent: + handler.onRepeatEvent(timestamp); + break; + case _BootstrapMessage.onDestroy: + final bool isTimeout = (env.argument as bool?) ?? false; + await handler.onDestroy(timestamp, isTimeout); + break; + case _BootstrapMessage.onReceiveData: + dynamic data = env.argument; + if (data is List || data is Map || data is Set) { + try { + data = jsonDecode(jsonEncode(data)); + } catch (_) { + // Fall through with the original value; mirrors the + // MethodChannel path's silent-recovery behaviour. + } + } + handler.onReceiveData(data); + break; + case _BootstrapMessage.onNotificationButtonPressed: + handler.onNotificationButtonPressed(env.argument.toString()); + break; + case _BootstrapMessage.onNotificationDismissed: + handler.onNotificationDismissed(); + break; + case _BootstrapMessage.onNotificationPressed: + handler.onNotificationPressed(); + break; + } + env.replyPort?.send(null); + } catch (e, s) { + env.replyPort?.send(_DispatchError(e.toString(), s)); + } + } +} diff --git a/lib/utils/foreground_task_callback_relay.dart b/lib/utils/foreground_task_callback_relay.dart new file mode 100644 index 00000000..519c2973 --- /dev/null +++ b/lib/utils/foreground_task_callback_relay.dart @@ -0,0 +1,154 @@ +import 'dart:ffi'; +import 'dart:typed_data'; + +/// Native C signature exposed by [ForegroundTaskCallbackRelay]. +/// +/// `void (*)(uint8_t* bytes, int32_t length)`. +/// +/// **Buffer lifetime contract.** `NativeCallable.listener` delivers the +/// invocation to Dart *asynchronously*: the native call returns immediately +/// and the attached Dart closure runs on the Dart isolate's event loop. +/// The bytes are therefore read on the Dart side **after** the native call +/// has returned, which means the native caller must keep `bytes` valid +/// until Dart has had a chance to dispatch the event. +/// +/// Two patterns satisfy this: +/// +/// 1. **Per-event allocation**: the native side allocates a fresh +/// buffer per event, lets the relay consume it, and the *Dart side* +/// frees it after [ForegroundTaskCallbackRelay] has copied the +/// contents. Today the relay itself does not free native memory — +/// wrap the relay with your own free-callback if your native code +/// allocates; see the Risks section of +/// `documentation/merged_platform_ui_thread_mitigation.md`. +/// 2. **Single long-lived buffer**: the native side owns a buffer that +/// lives at least until the relay is closed (or until the native +/// side has received an acknowledgement that the current event has +/// been consumed). The caller must not mutate the buffer between +/// the call and the Dart-side dispatch. +/// +/// If neither pattern fits, use primitive-only callback signatures +/// (e.g. `void (*)(int64_t)`) so the payload travels by value through +/// `NativeCallable.listener`'s message encoding and does not depend on +/// native memory lifetime. +typedef ForegroundTaskCallbackRelaySignature = Void Function( + Pointer bytes, Int32 length); + +/// Dart signature matching [ForegroundTaskCallbackRelaySignature]. +typedef ForegroundTaskCallbackRelayFn = void Function( + Pointer bytes, int length); + +/// A raw-bytes FFI callback relay for delivering events from native code +/// into a Dart isolate. +/// +/// Use this when a plugin's native side normally delivers events via +/// `MethodChannel.setMessageHandler` and you need the same events to +/// reach a background isolate where `BackgroundIsolateBinaryMessenger` +/// cannot deliver them (for example [TaskExecutionMode.backgroundIsolate], +/// or any custom isolate you spawn yourself). +/// +/// The relay wraps [NativeCallable.listener] so that the callback runs on +/// the isolate that created the relay, regardless of which OS thread the +/// native side invoked it on. Each invocation delivers a copy of the +/// bytes the native caller wrote into `bytes`, as a [Uint8List]. See +/// `documentation/merged_platform_ui_thread_mitigation.md` (§2.5.4, §7.3) +/// for the surrounding design. +/// +/// Lifecycle: +/// +/// * Create via [ForegroundTaskCallbackRelay.listener] on the isolate +/// that should receive events. +/// * Hand the native side [nativeFunction] (or its integer [address], +/// e.g. via a method-channel payload). +/// * Call [close] before the isolate exits to release the underlying +/// `NativeCallable`. Invoking the pointer after `close` is undefined +/// behavior on the native side — treat the relay's lifetime as part +/// of the plugin-native contract. +/// +/// The relay is single-consumer: only one native caller should hold the +/// pointer at a time. If multiple native subscribers need delivery, +/// create one relay per subscriber or multiplex on top of a single one. +class ForegroundTaskCallbackRelay { + ForegroundTaskCallbackRelay._(this._callable, this._onEvent); + + /// Creates a relay that forwards each native invocation to [onEvent]. + /// + /// [onEvent] is invoked on the creating isolate's event loop (via the + /// `NativeCallable.listener` machinery). The [Uint8List] it receives + /// is a fresh copy owned by Dart; it is safe to retain or mutate. + /// + /// Throws [UnsupportedError] if FFI callbacks are not available on + /// this runtime (e.g. web). + factory ForegroundTaskCallbackRelay.listener( + void Function(Uint8List bytes) onEvent, + ) { + final NativeCallable callable = + NativeCallable.listener( + (Pointer ptr, int length) { + _copyAndDispatch(ptr, length, onEvent); + }, + ); + return ForegroundTaskCallbackRelay._(callable, onEvent); + } + + final NativeCallable _callable; + + /// Retained only so [onEvent] is reachable for the lifetime of the + /// relay; invoked indirectly through the NativeCallable trampoline. + // ignore: unused_field + final void Function(Uint8List bytes) _onEvent; + + bool _closed = false; + + /// The raw native function pointer. Pass this to native code so it can + /// invoke the relay like any other C callback. + /// + /// Throws [StateError] after [close] has been called. + Pointer> + get nativeFunction { + if (_closed) { + throw StateError( + 'ForegroundTaskCallbackRelay.nativeFunction accessed after close().'); + } + return _callable.nativeFunction; + } + + /// Convenience: the integer address of [nativeFunction], suitable for + /// shipping over a method channel. The native side reconstitutes it as + /// a `void (*)(uint8_t*, int32_t)` C function pointer. + int get address => nativeFunction.address; + + /// Returns `true` once [close] has released the underlying callable. + bool get isClosed => _closed; + + /// Releases the underlying `NativeCallable`. Idempotent. + /// + /// After calling [close], the [nativeFunction] pointer becomes + /// invalid; native code must not invoke it. Callers are responsible + /// for signalling the native side to stop issuing events before + /// closing the relay. + void close() { + if (_closed) { + return; + } + _closed = true; + _callable.close(); + } + + static void _copyAndDispatch( + Pointer ptr, + int length, + void Function(Uint8List bytes) onEvent, + ) { + if (length <= 0 || ptr == nullptr) { + onEvent(Uint8List(0)); + return; + } + // `asTypedList(length)` produces a view over native memory. We take a + // defensive copy so `onEvent` can safely outlive the native call that + // delivered the bytes -- which is the whole point of the listener + // variant of `NativeCallable`. + final Uint8List copy = Uint8List.fromList(ptr.asTypedList(length)); + onEvent(copy); + } +} diff --git a/lib/utils/thread_id_probe.dart b/lib/utils/thread_id_probe.dart new file mode 100644 index 00000000..8dc49dda --- /dev/null +++ b/lib/utils/thread_id_probe.dart @@ -0,0 +1,110 @@ +import 'package:platform/platform.dart' show LocalPlatform; +import 'package:universal_ffi/ffi.dart'; + +const LocalPlatform _platform = LocalPlatform(); + +// Background +// ---------- +// Flutter 3.29+ merges the platform and UI threads by default, and `Flutter +// 3.38` removed the global merge-opt-out flags entirely. To diagnose which +// `TaskExecutionMode` is actually in effect we need to read the OS thread id +// of the *calling Dart isolate* -- i.e. the thread that currently owns the +// Dart VM slot for this isolate. +// +// A `MethodChannel` handler cannot do that: its callback runs on the platform +// task runner of the target engine, which on Android and iOS is always the +// main platform thread (regardless of whether the UI thread is merged with +// it), unless the channel is constructed with a `BinaryMessenger.TaskQueue`. +// See `documentation/threading_model.md` and +// `documentation/merged_platform_ui_thread_mitigation.md` (Bug #1) for the +// full rationale. +// +// This file contains a pure Dart, in-isolate probe built on +// `package:universal_ffi/ffi.dart`. It returns the kernel-level thread +// identifier of whichever isolate calls it: +// +// * Android -> `gettid()` from `libc.so` (bionic). Returns a Linux TID. +// * iOS / macOS -> `pthread_mach_thread_np(pthread_self())` from the main +// process image (libSystem). Returns a `mach_port_t`. +// * Everything else (Linux/Windows desktop, web) -> `null`. The helper is a +// debug utility; a clearly-missing value is strictly better than a +// silently-wrong one. +// +// `pthread_t` on Darwin is `struct _opaque_pthread_t *`, which maps cleanly to +// `Pointer` on every architecture iOS/macOS has shipped against Flutter +// (always 64-bit). Function lookups are cached on first use so repeated +// `debugThreadId()` calls do not hit the dynamic loader. + +typedef _GettidNative = Int32 Function(); +typedef _GettidDart = int Function(); + +typedef _PthreadSelfNative = Pointer Function(); +typedef _PthreadSelfDart = Pointer Function(); + +typedef _PthreadMachThreadNpNative = Uint32 Function(Pointer); +typedef _PthreadMachThreadNpDart = int Function(Pointer); + +_GettidDart? _gettid; +_PthreadSelfDart? _pthreadSelf; +_PthreadMachThreadNpDart? _pthreadMachThreadNp; +Future? _initFuture; + +Future _initProbe() { + return _initFuture ??= () async { + try { + if (_platform.isAndroid) { + // On Android API 21+ `gettid` is a regular libc symbol; `libc.so` is the + // standard library name on bionic. + final DynamicLibrary lib = await DynamicLibrary.open('libc.so'); + _gettid = lib.lookupFunction<_GettidNative, _GettidDart>('gettid'); + } else if (_platform.isIOS || _platform.isMacOS) { + // On Darwin the plugin binary is linked against libSystem, which exposes + // both `pthread_self` and `pthread_mach_thread_np` through the process + // image. Using `DynamicLibrary.process()` keeps this working under the + // App Store's on-device code signing constraints where opening arbitrary + // dylibs is not allowed. + final DynamicLibrary lib = DynamicLibrary.process(); + _pthreadSelf = lib + .lookupFunction<_PthreadSelfNative, _PthreadSelfDart>('pthread_self'); + _pthreadMachThreadNp = lib.lookupFunction<_PthreadMachThreadNpNative, + _PthreadMachThreadNpDart>('pthread_mach_thread_np'); + } + } catch (_) { + // If symbol resolution fails on an exotic runtime we keep `_gettid` / + // `_pthreadSelf` null, which makes `nativeThreadId()` return `null`. The + // caller treats that as "unknown" and surfaces nothing to the user. + } + }(); +} + +/// Returns the OS thread id of the isolate that invoked this function, or +/// `null` on platforms where the probe is not implemented (Linux/Windows +/// desktop, web). +/// +/// Values returned on supported platforms: +/// +/// * Android: the `gettid()` result (a positive `pid_t`). +/// * iOS / macOS: the `mach_port_t` for the current pthread, produced by +/// `pthread_mach_thread_np(pthread_self())` (a positive `uint32_t`). +/// +/// The numbers are not comparable across platforms but are stable within a +/// single process, which is all that the `TaskExecutionMode` verification +/// check needs: same id across two isolates means same OS thread, different +/// ids means different OS threads. +Future nativeThreadId() async { + await _initProbe(); + try { + final gettid = _gettid; + if (gettid != null) { + return gettid(); + } + final pthreadSelf = _pthreadSelf; + final toMach = _pthreadMachThreadNp; + if (pthreadSelf != null && toMach != null) { + return toMach(pthreadSelf()); + } + } catch (_) { + return null; + } + return null; +} diff --git a/pubspec.yaml b/pubspec.yaml index 14ad72b2..8a8d1bfa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,11 +13,13 @@ dependencies: plugin_platform_interface: ^2.1.8 shared_preferences: ^2.5.3 platform: ^3.1.6 + universal_ffi: ^1.2.0 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^5.0.0 + ffi: ^2.1.0 flutter: plugin: diff --git a/test/background_isolate_bootstrap_test.dart b/test/background_isolate_bootstrap_test.dart new file mode 100644 index 00000000..b2041053 --- /dev/null +++ b/test/background_isolate_bootstrap_test.dart @@ -0,0 +1,285 @@ +import 'dart:async'; +import 'dart:isolate'; + +import 'package:flutter/services.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task_method_channel.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task_platform_interface.dart'; +import 'package:flutter_foreground_task/utils/background_isolate_bootstrap.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('setTaskHandler routing when running in a background task isolate', + () { + late MethodChannelFlutterForegroundTask platformChannel; + late _RecordingTaskHandler handler; + BackgroundTaskDispatcherTestHarness? harness; + ReceivePort? bootstrapInbox; + + setUp(() { + platformChannel = MethodChannelFlutterForegroundTask(); + FlutterForegroundTaskPlatform.instance = platformChannel; + FlutterForegroundTask.resetStatic(); + handler = _RecordingTaskHandler(); + }); + + tearDown(() { + harness?.close(); + harness = null; + bootstrapInbox?.close(); + bootstrapInbox = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(platformChannel.mBGChannel, null); + }); + + test( + 'attaches handler to the active dispatcher and skips the background ' + 'method channel', () async { + bool bgChannelTouched = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(platformChannel.mBGChannel, + (MethodCall call) async { + bgChannelTouched = true; + return null; + }); + + bootstrapInbox = ReceivePort(); + harness = debugStartDispatcher(bootstrapPort: bootstrapInbox!.sendPort); + + // Drain the "dispatcher ready" handshake so the receive port + // isn't left dangling. + await bootstrapInbox!.first; + bootstrapInbox = null; + + platformChannel.setTaskHandler(handler); + + // The routing branch must not have installed a MethodChannel + // handler on the background channel. Invoking a method on the + // channel should therefore not fan out to the user's handler. + expect(bgChannelTouched, isFalse); + expect(handler.startStarterIndices, isEmpty); + + // Delivery via the dispatcher is covered by the SendPort protocol + // tests below; here we only assert the routing choice. + }); + + test( + 'throws StateError when called in a background isolate with no ' + 'registered dispatcher', () { + debugMarkAsBackgroundTaskIsolate(); + debugClearBackgroundTaskDispatcher(); + addTearDown(() => debugMarkAsBackgroundTaskIsolate(value: false)); + + expect( + () => platformChannel.setTaskHandler(handler), + throwsA(isA()), + ); + }); + }); + + group('BackgroundTaskDispatcher SendPort protocol', () { + late _RecordingTaskHandler handler; + BackgroundTaskDispatcherTestHarness? harness; + ReceivePort? bootstrapInbox; + + setUp(() async { + handler = _RecordingTaskHandler(); + bootstrapInbox = ReceivePort(); + harness = debugStartDispatcher(bootstrapPort: bootstrapInbox!.sendPort); + // Consume the handshake the dispatcher sends immediately on start. + await bootstrapInbox!.first; + }); + + tearDown(() { + harness?.close(); + harness = null; + bootstrapInbox?.close(); + bootstrapInbox = null; + FlutterForegroundTask.resetStatic(); + }); + + test('queues early events until a handler is attached', () async { + // Fire-and-wait-able onStart before attaching a handler. The + // dispatcher buffers it and replays it once attachTaskHandler + // is called. + final Future reply = harness!.debugInjectEnvelope( + kind: 'onStart', + argument: 0, + ); + + // Give the dispatcher a chance to enqueue. + await Future.delayed(const Duration(milliseconds: 10)); + expect(handler.startStarterIndices, isEmpty); + + // Attach and expect the queued event to be delivered + acked. + harness!.attachTaskHandler(handler); + final dynamic ack = await reply.timeout(const Duration(seconds: 2)); + expect(ack, isNull); + expect(handler.startStarterIndices, equals([0])); + }); + + test( + 'forwards onRepeatEvent without expecting a reply and carries the ' + 'bootstrap-supplied timestamp', () async { + harness!.attachTaskHandler(handler); + + final DateTime frozenAt = DateTime.utc(2030, 1, 2, 3, 4, 5, 6, 7); + await harness!.debugInjectEnvelope( + kind: 'onRepeatEvent', + timestamp: frozenAt, + expectReply: false, + ); + + await _waitFor(() => handler.repeatTimestamps.isNotEmpty); + expect(handler.repeatTimestamps.single, equals(frozenAt)); + }); + + test('forwards onDestroy and propagates the isTimeout flag', () async { + harness!.attachTaskHandler(handler); + + final Object? ack = await harness! + .debugInjectEnvelope(kind: 'onDestroy', argument: true) + .timeout(const Duration(seconds: 2)); + expect(ack, isNull); + expect(handler.destroyTimeoutValues, equals([true])); + }); + + test('forwards onServiceIdSet and updates the controller-wide id', + () async { + harness!.attachTaskHandler(handler); + + await harness!.debugInjectEnvelope( + kind: 'onServiceIdSet', + argument: 'custom_service', + expectReply: false, + ); + + await _waitFor(() => + FlutterForegroundTask.currentServiceId == 'custom_service'); + expect(FlutterForegroundTask.currentServiceId, equals('custom_service')); + }); + + test( + 'surfaces handler exceptions as a DispatcherTestError so the ' + 'bootstrap can rethrow them as PlatformException to the native ' + 'caller', () async { + final _RecordingTaskHandler faulty = _RecordingTaskHandler( + failOnDestroy: true, + ); + harness!.attachTaskHandler(faulty); + + final Object? reply = await harness! + .debugInjectEnvelope(kind: 'onDestroy', argument: false) + .timeout(const Duration(seconds: 2)); + expect(reply, isA()); + expect( + (reply as DispatcherTestError).message, + contains('boom'), + ); + }); + + test('forwards onReceiveData to the handler', () async { + harness!.attachTaskHandler(handler); + + await harness!.debugInjectEnvelope( + kind: 'onReceiveData', + argument: {'key': 'value', 'n': 42}, + expectReply: false, + ); + + await _waitFor(() => handler.receivedData.isNotEmpty); + expect(handler.receivedData.single, equals({'key': 'value', 'n': 42})); + }); + + test('forwards notification lifecycle events', () async { + harness!.attachTaskHandler(handler); + + await harness!.debugInjectEnvelope( + kind: 'onNotificationButtonPressed', + argument: 'action_1', + expectReply: false, + ); + await harness!.debugInjectEnvelope( + kind: 'onNotificationDismissed', + expectReply: false, + ); + await harness!.debugInjectEnvelope( + kind: 'onNotificationPressed', + expectReply: false, + ); + + await _waitFor(() => + handler.notificationButtonIds.isNotEmpty && + handler.notificationDismissedCount == 1 && + handler.notificationPressedCount == 1); + expect(handler.notificationButtonIds, equals(['action_1'])); + expect(handler.notificationDismissedCount, 1); + expect(handler.notificationPressedCount, 1); + }); + }); +} + +Future _waitFor(bool Function() predicate, + {Duration timeout = const Duration(seconds: 2)}) async { + final DateTime deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + if (predicate()) { + return; + } + await Future.delayed(const Duration(milliseconds: 5)); + } + throw TimeoutException('predicate never became true within $timeout'); +} + +class _RecordingTaskHandler extends TaskHandler { + _RecordingTaskHandler({this.failOnDestroy = false}); + + final bool failOnDestroy; + final List startStarterIndices = []; + final List repeatTimestamps = []; + final List destroyTimeoutValues = []; + final List receivedData = []; + final List notificationButtonIds = []; + int notificationDismissedCount = 0; + int notificationPressedCount = 0; + + @override + Future onStart(DateTime timestamp, TaskStarter starter) async { + startStarterIndices.add(starter.index); + } + + @override + void onRepeatEvent(DateTime timestamp) { + repeatTimestamps.add(timestamp); + } + + @override + Future onDestroy(DateTime timestamp, bool isTimeout) async { + destroyTimeoutValues.add(isTimeout); + if (failOnDestroy) { + throw StateError('boom'); + } + } + + @override + void onReceiveData(Object data) { + receivedData.add(data); + } + + @override + void onNotificationButtonPressed(String id) { + notificationButtonIds.add(id); + } + + @override + void onNotificationDismissed() { + notificationDismissedCount++; + } + + @override + void onNotificationPressed() { + notificationPressedCount++; + } +} diff --git a/test/foreground_task_callback_relay_test.dart b/test/foreground_task_callback_relay_test.dart new file mode 100644 index 00000000..694ee4a2 --- /dev/null +++ b/test/foreground_task_callback_relay_test.dart @@ -0,0 +1,183 @@ +import 'dart:async'; +import 'dart:ffi'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; +import 'package:flutter_foreground_task/utils/foreground_task_callback_relay.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('ForegroundTaskCallbackRelay', () { + test( + 'delivers a Uint8List copy to onEvent; the Uint8List is independent ' + 'of the native buffer after dispatch', () async { + final Completer received = Completer(); + final ForegroundTaskCallbackRelay relay = + ForegroundTaskCallbackRelay.listener((Uint8List bytes) { + if (!received.isCompleted) { + received.complete(bytes); + } + }); + + // Simulate the native side: allocate a buffer, fill it, call the + // native function pointer. Per the relay's buffer-lifetime contract, + // the buffer must stay alive until Dart dispatches the event. + final Uint8List payload = Uint8List.fromList([1, 2, 3, 4, 5]); + final Pointer buf = calloc(payload.length); + buf.asTypedList(payload.length).setAll(0, payload); + final ForegroundTaskCallbackRelayFn fn = relay.nativeFunction + .asFunction(); + fn(buf, payload.length); + + final Uint8List delivered = + await received.future.timeout(const Duration(seconds: 2)); + expect(delivered, equals(payload)); + + // After dispatch, mutating / freeing the native buffer must not + // affect the Uint8List the listener received (because the relay + // took a defensive copy). This is the part of the contract the + // relay can enforce by itself. + buf.asTypedList(payload.length).fillRange(0, payload.length, 0); + expect(delivered, equals(payload)); + + calloc.free(buf); + relay.close(); + }); + + test('handles zero-length payloads', () async { + final Completer received = Completer(); + final ForegroundTaskCallbackRelay relay = + ForegroundTaskCallbackRelay.listener((Uint8List bytes) { + if (!received.isCompleted) { + received.complete(bytes); + } + }); + + final Pointer buf = calloc(1); + final ForegroundTaskCallbackRelayFn fn = relay.nativeFunction + .asFunction(); + fn(buf, 0); + + final Uint8List delivered = + await received.future.timeout(const Duration(seconds: 2)); + expect(delivered, isEmpty); + + calloc.free(buf); + relay.close(); + }); + + test('handles null pointer and negative length without crashing', + () async { + final List received = []; + final Completer observedBoth = Completer(); + final ForegroundTaskCallbackRelay relay = + ForegroundTaskCallbackRelay.listener((Uint8List bytes) { + received.add(bytes); + if (received.length == 2 && !observedBoth.isCompleted) { + observedBoth.complete(); + } + }); + + final ForegroundTaskCallbackRelayFn fn = relay.nativeFunction + .asFunction(); + + // length <= 0 branch: we pass a non-null pointer but negative length. + final Pointer buf = calloc(1); + fn(buf, -1); + + // nullptr + length = 0 branch. + fn(nullptr, 0); + + await observedBoth.future.timeout(const Duration(seconds: 2)); + expect(received, hasLength(2)); + expect(received[0], isEmpty); + expect(received[1], isEmpty); + + calloc.free(buf); + relay.close(); + }); + + test( + 'delivers events invoked from a different isolate (simulates a ' + 'native thread)', () async { + final Completer received = Completer(); + final ForegroundTaskCallbackRelay relay = + ForegroundTaskCallbackRelay.listener((Uint8List bytes) { + if (!received.isCompleted) { + received.complete(bytes); + } + }); + + // `Isolate.spawn` is used (rather than `Isolate.run`) because the + // latter serialises the closure, which would implicitly capture + // `relay` / `received` from the enclosing scope and fail at + // send-time. A top-level entrypoint avoids that entirely. + final ReceivePort donePort = ReceivePort(); + final int address = relay.address; + final Isolate isolate = await Isolate.spawn( + _invokeFromForeignIsolate, + _InvokeRequest(address: address, done: donePort.sendPort), + ); + await donePort.first.timeout(const Duration(seconds: 2)); + donePort.close(); + isolate.kill(); + + final Uint8List delivered = + await received.future.timeout(const Duration(seconds: 2)); + expect(delivered, equals(Uint8List.fromList([9, 8, 7]))); + + relay.close(); + }); + + test('close() is idempotent and invalidates nativeFunction', () { + final ForegroundTaskCallbackRelay relay = + ForegroundTaskCallbackRelay.listener((_) {}); + + expect(relay.isClosed, isFalse); + relay.close(); + expect(relay.isClosed, isTrue); + // Calling close again is a no-op. + relay.close(); + + expect( + () => relay.nativeFunction, + throwsA(isA()), + ); + }); + }); +} + +class _InvokeRequest { + _InvokeRequest({required this.address, required this.done}); + + final int address; + final SendPort done; +} + +/// Top-level helper called from a spawned isolate. Using a top-level +/// function keeps the closure that `Isolate.spawn` has to serialise +/// trivial (no captured state) and satisfies `SendPort.send()`'s +/// restrictions on sendable arguments. +void _invokeFromForeignIsolate(_InvokeRequest request) { + final Pointer> fnPtr = + Pointer>.fromAddress( + request.address, + ); + final ForegroundTaskCallbackRelayFn fn = + fnPtr.asFunction(); + + final Uint8List payload = Uint8List.fromList([9, 8, 7]); + final Pointer buf = calloc(payload.length); + buf.asTypedList(payload.length).setAll(0, payload); + fn(buf, payload.length); + // NOTE: we intentionally do NOT free the buffer here. The relay's + // listener runs asynchronously on the originating isolate; freeing + // before dispatch would race with Dart reading `asTypedList`. In a + // real plugin you would either allocate a fresh buffer per event and + // let Dart free it, or keep the buffer alive for the lifetime of the + // relay. For test purposes leaking ~3 bytes is fine. + request.done.send(null); +} diff --git a/test/method_channel_test.dart b/test/method_channel_test.dart index 421a1431..fbe89e39 100644 --- a/test/method_channel_test.dart +++ b/test/method_channel_test.dart @@ -7,6 +7,8 @@ import 'package:flutter_foreground_task/flutter_foreground_task_platform_interfa import 'package:flutter_test/flutter_test.dart'; import 'package:platform/platform.dart'; +const LocalPlatform hostPlatform = LocalPlatform(); + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -100,6 +102,54 @@ void main() { }); }); + group('debugThreadId', () { + // The probe is now implemented via `package:universal_ffi/ffi.dart` + // inside the calling isolate (see `lib/utils/thread_id_probe.dart`). + // These tests assert on the + // shape of the result: on hosts where we resolve the symbol we expect + // a positive tid, on everything else we expect `null` rather than a + // bogus value. + + test('does not hit either method channel', () async { + // Fail loudly if something starts routing `debugThreadId` through the + // plugin's MethodChannels again -- the whole point of the FFI rewrite + // is that the call stays in-process. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + platformChannel.mBGChannel, + (MethodCall call) async { + if (call.method == 'debugThreadId') { + fail('debugThreadId must not invoke the background channel.'); + } + return null; + }, + ); + methodCallHandler.debugTidOverride = null; + methodCallHandler.failIfDebugThreadIdCalled = true; + + await platformChannel.debugThreadId(); + }); + + test('returns a positive tid on supported hosts, null elsewhere', + () async { + final tid = await platformChannel.debugThreadId(); + if (hostPlatform.isAndroid || + hostPlatform.isIOS || + hostPlatform.isMacOS) { + expect(tid, isNotNull); + expect(tid!, greaterThan(0)); + } else { + expect(tid, isNull); + } + }); + + test('returns the same tid across calls on the same isolate', () async { + final first = await platformChannel.debugThreadId(); + final second = await platformChannel.debugThreadId(); + expect(first, equals(second)); + }); + }); + group('sendDataToTask', () { test('invokes sendData with serviceId', () { platformChannel.platform = @@ -270,6 +320,16 @@ class _MethodCallHandler { final List log = []; + // Retained for signature compatibility with older tests; the FFI-based + // `debugThreadId` no longer routes through any MethodChannel, so this + // value is ignored. + int? debugTidOverride; + + // Set by tests that want to assert the helper never hits the main + // MethodChannel. The channel handler fails the test if it sees a + // `debugThreadId` invocation. + bool failIfDebugThreadIdCalled = false; + Future? onMethodCall(MethodCall methodCall) async { log.add(methodCall); @@ -280,6 +340,11 @@ class _MethodCallHandler { return false; case 'sendData': return null; + case 'debugThreadId': + if (failIfDebugThreadIdCalled) { + fail('debugThreadId must not invoke the main method channel.'); + } + return debugTidOverride; default: return null; } diff --git a/test/models_test.dart b/test/models_test.dart index b5141002..26083887 100644 --- a/test/models_test.dart +++ b/test/models_test.dart @@ -193,6 +193,8 @@ void main() { expect(options.allowWifiLock, false); expect(options.allowAutoRestart, true); expect(options.stopWithTask, isNull); + // Default matches Flutter 3.29+ behavior; see TaskExecutionMode docs. + expect(options.executionMode, TaskExecutionMode.mergedEngine); }); test('toJson includes all fields', () { @@ -204,6 +206,7 @@ void main() { allowWifiLock: true, allowAutoRestart: false, stopWithTask: true, + executionMode: TaskExecutionMode.dedicatedEngine, ); final json = options.toJson(); expect(json['autoRunOnBoot'], true); @@ -213,6 +216,25 @@ void main() { expect(json['allowAutoRestart'], false); expect(json['stopWithTask'], true); expect(json['taskEventAction'], isA()); + expect(json['executionMode'], 'dedicatedEngine'); + }); + + test('toJson defaults executionMode to mergedEngine', () { + final options = ForegroundTaskOptions( + eventAction: ForegroundTaskEventAction.nothing(), + ); + expect(options.toJson()['executionMode'], 'mergedEngine'); + }); + + test('copyWith replaces executionMode', () { + final original = ForegroundTaskOptions( + eventAction: ForegroundTaskEventAction.nothing(), + ); + final copy = original.copyWith( + executionMode: TaskExecutionMode.backgroundIsolate, + ); + expect(copy.executionMode, TaskExecutionMode.backgroundIsolate); + expect(copy.eventAction.type, original.eventAction.type); }); test('toJson omits stopWithTask when null', () { @@ -283,6 +305,31 @@ void main() { }); }); + group('TaskExecutionMode', () { + test('rawValues round-trip through fromRawValue', () { + for (final mode in TaskExecutionMode.values) { + expect(TaskExecutionMode.fromRawValue(mode.rawValue), mode); + } + }); + + test('fromRawValue falls back to mergedEngine for null/unknown', () { + expect(TaskExecutionMode.fromRawValue(null), + TaskExecutionMode.mergedEngine); + expect(TaskExecutionMode.fromRawValue('bogus'), + TaskExecutionMode.mergedEngine); + expect(TaskExecutionMode.fromRawValue(''), + TaskExecutionMode.mergedEngine); + }); + + test('rawValues are stable strings shared with native', () { + // These values are the public contract with the native side; changing + // them breaks persisted preferences. + expect(TaskExecutionMode.dedicatedEngine.rawValue, 'dedicatedEngine'); + expect(TaskExecutionMode.mergedEngine.rawValue, 'mergedEngine'); + expect(TaskExecutionMode.backgroundIsolate.rawValue, 'backgroundIsolate'); + }); + }); + group('AndroidNotificationOptions', () { test('constructor defaults', () { final options = AndroidNotificationOptions( diff --git a/test/platform_interface_test.dart b/test/platform_interface_test.dart index 4425fecb..256b90d3 100644 --- a/test/platform_interface_test.dart +++ b/test/platform_interface_test.dart @@ -197,6 +197,13 @@ void main() { throwsUnimplementedError, ); }); + + test('debugThreadId throws UnimplementedError', () { + expect( + () => platform.debugThreadId(), + throwsUnimplementedError, + ); + }); }); group('FlutterForegroundTaskPlatform instance', () { diff --git a/test/service_options_test.dart b/test/service_options_test.dart new file mode 100644 index 00000000..ef802194 --- /dev/null +++ b/test/service_options_test.dart @@ -0,0 +1,126 @@ +import 'dart:ui'; + +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; +import 'package:flutter_foreground_task/models/service_options.dart'; +import 'package:flutter_foreground_task/utils/background_isolate_bootstrap.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:platform/platform.dart'; + +// Dummy top-level function used as the user's startCallback. Must be +// annotated with @pragma('vm:entry-point') so PluginUtilities can +// resolve its handle. +@pragma('vm:entry-point') +void _dummyStartCallback() {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final AndroidNotificationOptions android = AndroidNotificationOptions( + channelId: 'test_channel', + channelName: 'Test Channel', + ); + const IOSNotificationOptions ios = IOSNotificationOptions(); + + final FakePlatform androidHost = + FakePlatform(operatingSystem: Platform.android); + + group('ServiceStartOptions.toJson bootstrapCallbackHandle', () { + test( + 'omits bootstrapCallbackHandle for mergedEngine (the current ' + 'runtime default)', () { + final ServiceStartOptions opts = ServiceStartOptions( + androidNotificationOptions: android, + iosNotificationOptions: ios, + foregroundTaskOptions: ForegroundTaskOptions( + eventAction: ForegroundTaskEventAction.nothing(), + ), + notificationContentTitle: 't', + notificationContentText: 'x', + callback: _dummyStartCallback, + ); + final Map json = opts.toJson(androidHost); + expect(json.containsKey('callbackHandle'), isTrue); + expect(json.containsKey('bootstrapCallbackHandle'), isFalse); + expect(json['executionMode'], TaskExecutionMode.mergedEngine.rawValue); + }); + + test('omits bootstrapCallbackHandle for dedicatedEngine', () { + final ServiceStartOptions opts = ServiceStartOptions( + androidNotificationOptions: android, + iosNotificationOptions: ios, + foregroundTaskOptions: ForegroundTaskOptions( + eventAction: ForegroundTaskEventAction.nothing(), + executionMode: TaskExecutionMode.dedicatedEngine, + ), + notificationContentTitle: 't', + notificationContentText: 'x', + callback: _dummyStartCallback, + ); + final Map json = opts.toJson(androidHost); + expect(json.containsKey('bootstrapCallbackHandle'), isFalse); + }); + + test( + 'includes bootstrapCallbackHandle for backgroundIsolate and it ' + 'resolves to the bootstrap entrypoint', () { + final ServiceStartOptions opts = ServiceStartOptions( + androidNotificationOptions: android, + iosNotificationOptions: ios, + foregroundTaskOptions: ForegroundTaskOptions( + eventAction: ForegroundTaskEventAction.nothing(), + executionMode: TaskExecutionMode.backgroundIsolate, + ), + notificationContentTitle: 't', + notificationContentText: 'x', + callback: _dummyStartCallback, + ); + final Map json = opts.toJson(androidHost); + + final int expected = + PluginUtilities.getCallbackHandle( + foregroundTaskBackgroundIsolateBootstrap) + !.toRawHandle(); + expect(json['bootstrapCallbackHandle'], equals(expected)); + + // The user's handle still goes under the existing `callbackHandle` + // key so the native side can hand it to the bootstrap once the + // bootstrap is running. + final int userExpected = + PluginUtilities.getCallbackHandle(_dummyStartCallback)! + .toRawHandle(); + expect(json['callbackHandle'], equals(userExpected)); + expect(json['bootstrapCallbackHandle'], + isNot(equals(json['callbackHandle']))); + }); + }); + + group('ServiceUpdateOptions.toJson bootstrapCallbackHandle', () { + test( + 'includes bootstrapCallbackHandle when executionMode switches to ' + 'backgroundIsolate mid-flight', () { + final ServiceUpdateOptions opts = ServiceUpdateOptions( + foregroundTaskOptions: ForegroundTaskOptions( + eventAction: ForegroundTaskEventAction.nothing(), + executionMode: TaskExecutionMode.backgroundIsolate, + ), + notificationContentTitle: 't', + notificationContentText: 'x', + callback: _dummyStartCallback, + ); + final Map json = opts.toJson(androidHost); + expect(json.containsKey('bootstrapCallbackHandle'), isTrue); + }); + + test( + 'omits bootstrapCallbackHandle when foregroundTaskOptions is not ' + 'supplied (pure notification update)', () { + const ServiceUpdateOptions opts = ServiceUpdateOptions( + foregroundTaskOptions: null, + notificationContentTitle: 't', + notificationContentText: 'x', + ); + final Map json = opts.toJson(androidHost); + expect(json.containsKey('bootstrapCallbackHandle'), isFalse); + }); + }); +}