Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .fvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"flutter": "stable"
}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.DS_Store
.dart_tool/
.idea/
.fvm/
.packages
.pub/

Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
## Unreleased

* [**FEAT**] Adds Swift Package Manager (SPM) support to the iOS plugin.
* [**FEAT**] Dockerized Android integration tests for native ForegroundService lifecycle.
* [**FEAT**] Support multiple simultaneous foreground services.
* [**FEAT**] Add storage abstraction for Android SharedPreferences & iOS UserDefaults, stop storing data in the default bundle.
* [**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` (`mergedEngine`, `dedicatedEngine`, `backgroundIsolate`) to `ForegroundTaskOptions`. `mergedEngine` is now the default; `backgroundIsolate` is reserved and currently falls back to `mergedEngine`. See [threading_model.md](./documentation/threading_model.md).
* [**FEAT**] Add `MergedThreadOptOutDetector` on Android and iOS so `dedicatedEngine` warns once and falls back to `mergedEngine` when the platform opt-out flag is missing.
* [**FEAT**] Add `ForegroundTaskCallbackRelay`, an FFI helper for delivering native callbacks into Dart isolates. See [merged_platform_ui_thread_mitigation.md](./documentation/merged_platform_ui_thread_mitigation.md).
* [**REFACTOR**] Add internal bootstrap wiring for the future `backgroundIsolate` execution mode. Native platforms still downgrade it to `mergedEngine`.
* [**FEAT**] Add `FlutterForegroundTask.debugThreadId()` to inspect the calling isolate's OS thread id.
* [**DOCS**] Add threading migration notes to [README.md](./README.md) and expand [threading_model.md](./documentation/threading_model.md).

## 9.2.2

Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,16 @@ An example of a background geofencing service implementation using `flutter_fore
#### [`pedometer_service`](https://github.com/Dev-hwang/flutter_foreground_task_example/tree/main/pedometer_service)
An example of a pedometer service implementation using `flutter_foreground_task` and `pedometer`.

## Flutter Threading Migration

Flutter 3.29+ changed how `FlutterEngine` threads work. If you are upgrading an existing app and used this plugin for "run background Dart work off the UI thread", use this quick guide:

1. If your task is light or you already offload heavy work to native code / isolates, keep the default `TaskExecutionMode.mergedEngine`. No extra setup is needed.
2. If you need the legacy separate-engine behavior on Flutter `3.37` or older, set `TaskExecutionMode.dedicatedEngine` and add the Android/iOS opt-out flags described in `documentation/threading_model.md`.
3. If you are on Flutter `3.38+`, those opt-out flags are no longer honored. Stay on `mergedEngine` for now and avoid heavy work on the task isolate unless you explicitly move it elsewhere.

For the internal background, mitigation plan, and why this changed, see [merged_platform_ui_thread_mitigation.md](./documentation/merged_platform_ui_thread_mitigation.md).

## More Documentation

Go [here](./documentation/models_documentation.md) to learn about the `models` provided by this plugin.
Expand All @@ -677,6 +687,10 @@ 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()`.

Go [here](./documentation/merged_platform_ui_thread_mitigation.md) for the internal mitigation plan behind the threading migration.

> [!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
}
}
Binary file removed coverage/html/amber.png
Binary file not shown.
1 change: 0 additions & 1 deletion coverage/html/cmd_line

This file was deleted.

Binary file removed coverage/html/emerald.png
Binary file not shown.
Loading
Loading