Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -53,6 +57,7 @@ data class ForegroundTaskOptions(
allowWifiLock = allowWifiLock,
allowAutoRestart = allowAutoRestart,
stopWithTask = stopWithTask,
executionMode = executionMode,
)
}

Expand All @@ -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)
Expand All @@ -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()
}
}
Expand All @@ -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) }
Expand All @@ -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()
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -38,15 +41,31 @@ 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
private var repeatTask: Job? = null
private var isDestroyed: Boolean = false

init {
effectiveExecutionMode = resolveExecutionMode(context, requestedExecutionMode)

// create flutter engine
flutterEngine = FlutterEngine(context)
flutterLoader = FlutterInjector.instance().flutterLoader()
Expand Down Expand Up @@ -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?) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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`
* `<meta-data>` 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
* <meta-data
* android:name="io.flutter.embedding.android.DisableMergedPlatformUIThread"
* android:value="true" />
* ```
*
* 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)

Check warning on line 65 in android/src/main/kotlin/com/pravera/flutter_foreground_task/utils/MergedThreadOptOutDetector.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace "equals" with binary operator "==".

See more on https://sonarcloud.io/project/issues?id=T-Pro_flutter_foreground_task&issues=AZ2gzbQwxD0pevEjWqer&open=AZ2gzbQwxD0pevEjWqer&pullRequest=10
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
}
}
Loading
Loading