From 345e7d46a4859832241009f41c741f31e63d301d Mon Sep 17 00:00:00 2001 From: Sebastian Roth Date: Tue, 4 Aug 2026 19:20:16 +0100 Subject: [PATCH 1/4] fix(android): make FOREGROUND_SERVICE_DATA_SYNC opt-in (fixes #725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin always declared FOREGROUND_SERVICE_DATA_SYNC and merged the dataSync type onto SystemForegroundService, forcing Play Console to ask every app for a dataSync foreground-service declaration + demonstration video — even apps that only run plain periodic background tasks. dataSync is now opt-in: the default manifest declares only FOREGROUND_SERVICE + FOREGROUND_SERVICE_SHORT_SERVICE (expedited work, not a Play special type). Apps running long-running workers with the dataSync type enable it via the workmanager.enableDataSyncForegroundService Gradle property, which swaps in AndroidManifest.dataSync.xml (permission + dataSync|shortService type). Verified with aapt2 dump on the final APK: default has no DATA_SYNC permission, opt-in has permission + type 0x801 (dataSync|shortService). 50 unit tests pass. --- workmanager_android/README.md | 19 +++++++++++++ workmanager_android/android/build.gradle | 14 ++++++++++ .../src/main/AndroidManifest.dataSync.xml | 24 +++++++++++++++++ .../android/src/main/AndroidManifest.xml | 27 ++++++++++++------- 4 files changed, 74 insertions(+), 10 deletions(-) create mode 100644 workmanager_android/android/src/main/AndroidManifest.dataSync.xml diff --git a/workmanager_android/README.md b/workmanager_android/README.md index e1aba902..0a70c941 100644 --- a/workmanager_android/README.md +++ b/workmanager_android/README.md @@ -27,5 +27,24 @@ dependencies: For detailed setup instructions, usage examples, and API documentation, please refer to the main [`workmanager`][workmanager] package documentation. +## Foreground service permissions + +Expedited work runs as a `shortService` foreground service, so the plugin +always declares `FOREGROUND_SERVICE` and `FOREGROUND_SERVICE_SHORT_SERVICE`. + +`FOREGROUND_SERVICE_DATA_SYNC` (the `dataSync` foreground service type, for +long-running workers) is **opt-in**: it is a Play Console "special type" that +requires a declaration form and a demonstration video even when unused, so it +is only added to your merged manifest when you enable it in your app's +`gradle.properties` (or pass it on the command line): + +```properties +workmanager.enableDataSyncForegroundService=true +``` + +Only set this if your app actually runs long-running workers with the +`dataSync` foreground service type — see the `ForegroundServiceConfig` API +in the main package. See [issue #725](https://github.com/fluttercommunity/flutter_workmanager/issues/725). + [workmanager]: https://pub.dartlang.org/packages/workmanager [federated_plugin_docs]: https://flutter.dev/go/federated-plugins \ No newline at end of file diff --git a/workmanager_android/android/build.gradle b/workmanager_android/android/build.gradle index 6ec2f03c..158714cb 100644 --- a/workmanager_android/android/build.gradle +++ b/workmanager_android/android/build.gradle @@ -32,6 +32,20 @@ android { sourceSets { main.java.srcDirs += 'src/main/kotlin' test.java.srcDirs += 'src/test/kotlin' + // FOREGROUND_SERVICE_DATA_SYNC is a Play Console "special type" that + // requires a declaration + demonstration video even when unused, so it + // is opt-in (see AndroidManifest.xml). Apps that run long-running + // workers with the `dataSync` foreground service type must set + // `workmanager.enableDataSyncForegroundService=true` in their + // gradle.properties (or via -P). See #725. + def wmDataSyncEnabled = + project.findProperty('workmanager.enableDataSyncForegroundService') + ?.toString() == 'true' + if (wmDataSyncEnabled) { + // Swap in the opt-in manifest (adds the DATA_SYNC permission and + // the `dataSync` service type). See #725. + main.manifest.srcFile 'src/main/AndroidManifest.dataSync.xml' + } } defaultConfig { compileSdk 35 diff --git a/workmanager_android/android/src/main/AndroidManifest.dataSync.xml b/workmanager_android/android/src/main/AndroidManifest.dataSync.xml new file mode 100644 index 00000000..f6cc8338 --- /dev/null +++ b/workmanager_android/android/src/main/AndroidManifest.dataSync.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + diff --git a/workmanager_android/android/src/main/AndroidManifest.xml b/workmanager_android/android/src/main/AndroidManifest.xml index 1796da1c..028710ec 100644 --- a/workmanager_android/android/src/main/AndroidManifest.xml +++ b/workmanager_android/android/src/main/AndroidManifest.xml @@ -3,21 +3,28 @@ - - - - + From 7481834c5b87b42c873c8057c8a0921b0a0e10c9 Mon Sep 17 00:00:00 2001 From: Sebastian Roth Date: Tue, 4 Aug 2026 19:34:11 +0100 Subject: [PATCH 2/4] feat(android): fail loudly when dataSync FGS is used without the opt-in Runtime guard in resolveForegroundServiceType: when a worker requests the dataSync foreground service type but FOREGROUND_SERVICE_DATA_SYNC is not in the merged manifest (the app did not set workmanager.enableDataSyncForegroundService=true), throw a descriptive IllegalStateException instead of a cryptic SecurityException on Android 14+ when the service starts. shortService (always declared) is unaffected. Adds ForegroundServicePermissionTest (Robolectric): dataSync without the permission throws; shortService builds with the shortService type. --- .../workmanager/ForegroundServiceUtils.kt | 27 ++++++++-- .../ForegroundServicePermissionTest.kt | 51 +++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 workmanager_android/android/src/test/kotlin/dev/fluttercommunity/workmanager/ForegroundServicePermissionTest.kt diff --git a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/ForegroundServiceUtils.kt b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/ForegroundServiceUtils.kt index ad8168ea..063a86e2 100644 --- a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/ForegroundServiceUtils.kt +++ b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/ForegroundServiceUtils.kt @@ -46,7 +46,7 @@ fun createForegroundInfo( ForegroundInfo( config.notificationId?.toInt() ?: DEFAULT_NOTIFICATION_ID, notification, - resolveForegroundServiceType(config), + resolveForegroundServiceType(context, config), ) } else { ForegroundInfo(config.notificationId?.toInt() ?: DEFAULT_NOTIFICATION_ID, notification) @@ -75,12 +75,33 @@ private fun createNotificationChannel( .createNotificationChannel(channel) } -private fun resolveForegroundServiceType(config: ForegroundServiceConfig): Int = +private fun resolveForegroundServiceType( + context: Context, + config: ForegroundServiceConfig, +): Int = when (config.foregroundServiceType) { ForegroundServiceType.SHORT_SERVICE -> ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE - else -> + else -> { + // dataSync is opt-in (see README, issue #725): the manifest only + // declares FOREGROUND_SERVICE_DATA_SYNC when the app sets + // workmanager.enableDataSyncForegroundService=true. Fail loudly + // here instead of a cryptic SecurityException on Android 14+. + val granted = + context.packageManager.checkPermission( + android.Manifest.permission.FOREGROUND_SERVICE_DATA_SYNC, + context.packageName, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + if (!granted) { + throw IllegalStateException( + "workmanager_android: foregroundServiceType=dataSync is used, but " + + "FOREGROUND_SERVICE_DATA_SYNC is missing from the merged manifest. " + + "Add 'workmanager.enableDataSyncForegroundService=true' to your " + + "gradle.properties (see the workmanager_android README, issue #725).", + ) + } ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } } /** diff --git a/workmanager_android/android/src/test/kotlin/dev/fluttercommunity/workmanager/ForegroundServicePermissionTest.kt b/workmanager_android/android/src/test/kotlin/dev/fluttercommunity/workmanager/ForegroundServicePermissionTest.kt new file mode 100644 index 00000000..18f66e6b --- /dev/null +++ b/workmanager_android/android/src/test/kotlin/dev/fluttercommunity/workmanager/ForegroundServicePermissionTest.kt @@ -0,0 +1,51 @@ +package dev.fluttercommunity.workmanager + +import android.content.pm.ServiceInfo +import org.robolectric.RuntimeEnvironment +import dev.fluttercommunity.workmanager.pigeon.ForegroundServiceConfig +import dev.fluttercommunity.workmanager.pigeon.ForegroundServiceType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ForegroundServicePermissionTest { + @Test + fun `dataSync foreground service throws when the permission is not declared`() { + // The default plugin manifest does not declare + // FOREGROUND_SERVICE_DATA_SYNC (it is opt-in, see issue #725), so + // checkPermission reports DENIED and the guard fails loudly. + val config = + ForegroundServiceConfig( + notificationTitle = "Syncing", + notificationText = "Uploading 42 files", + foregroundServiceType = ForegroundServiceType.DATA_SYNC, + ) + + assertThrows(IllegalStateException::class.java) { + createForegroundInfo(RuntimeEnvironment.getApplication(), config) + } + } + + @Test + fun `shortService foreground service builds without the dataSync permission`() { + // FOREGROUND_SERVICE_SHORT_SERVICE is always declared by the plugin, + // so the shortService path must not require the opt-in permission. + val config = + ForegroundServiceConfig( + notificationTitle = "Task", + notificationText = "Working", + foregroundServiceType = ForegroundServiceType.SHORT_SERVICE, + ) + + val info = createForegroundInfo(RuntimeEnvironment.getApplication(), config) + assertEquals( + ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE.toLong(), + info.foregroundServiceType.toLong(), + ) + } +} From a1ac28986d2022491b4bae12b74dc1575bdff42f Mon Sep 17 00:00:00 2001 From: Sebastian Roth Date: Tue, 4 Aug 2026 19:39:57 +0100 Subject: [PATCH 3/4] feat(android): same loud-failure guard for expedited work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expedited work runs as a shortService FGS on Android 14+, which needs FOREGROUND_SERVICE_SHORT_SERVICE in the merged manifest. The plugin declares it by default, but if an app strips it, fail at registration with a descriptive error instead of a SecurityException at task time. Shared requireForegroundServicePermission helper (also used by the dataSync guard). Note: the SDK stubs expose no Manifest.permission constant for SHORT_SERVICE — the manifest literal string is used. --- .../workmanager/ForegroundServiceUtils.kt | 43 +++++++++++++------ .../workmanager/WorkManagerUtils.kt | 15 +++++++ 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/ForegroundServiceUtils.kt b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/ForegroundServiceUtils.kt index 063a86e2..5fd32a3f 100644 --- a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/ForegroundServiceUtils.kt +++ b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/ForegroundServiceUtils.kt @@ -3,6 +3,7 @@ package dev.fluttercommunity.workmanager import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context +import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.os.Build import androidx.core.app.NotificationCompat @@ -75,6 +76,28 @@ private fun createNotificationChannel( .createNotificationChannel(channel) } +/** + * Fails loudly when a foreground-service feature is used without the manifest + * declaration it needs (see issue #725). Normal permissions are granted at + * install when declared, so a missing declaration surfaces as DENIED here. + */ +internal fun requireForegroundServicePermission( + context: Context, + permission: String, + featureDescription: String, + fixHint: String, +) { + val granted = + context.packageManager.checkPermission(permission, context.packageName) == + PackageManager.PERMISSION_GRANTED + if (!granted) { + throw IllegalStateException( + "workmanager_android: $featureDescription requires the '$permission' permission " + + "in the merged manifest, but it is missing. $fixHint", + ) + } +} + private fun resolveForegroundServiceType( context: Context, config: ForegroundServiceConfig, @@ -87,19 +110,13 @@ private fun resolveForegroundServiceType( // declares FOREGROUND_SERVICE_DATA_SYNC when the app sets // workmanager.enableDataSyncForegroundService=true. Fail loudly // here instead of a cryptic SecurityException on Android 14+. - val granted = - context.packageManager.checkPermission( - android.Manifest.permission.FOREGROUND_SERVICE_DATA_SYNC, - context.packageName, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (!granted) { - throw IllegalStateException( - "workmanager_android: foregroundServiceType=dataSync is used, but " + - "FOREGROUND_SERVICE_DATA_SYNC is missing from the merged manifest. " + - "Add 'workmanager.enableDataSyncForegroundService=true' to your " + - "gradle.properties (see the workmanager_android README, issue #725).", - ) - } + requireForegroundServicePermission( + context, + android.Manifest.permission.FOREGROUND_SERVICE_DATA_SYNC, + "foregroundServiceType=dataSync", + "Add 'workmanager.enableDataSyncForegroundService=true' to your " + + "gradle.properties (see the workmanager_android README, issue #725).", + ) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC } } diff --git a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkManagerUtils.kt b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkManagerUtils.kt index 54955c70..88d73909 100644 --- a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkManagerUtils.kt +++ b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkManagerUtils.kt @@ -242,6 +242,21 @@ class WorkManagerWrapper( fun enqueueOneOffTask(request: dev.fluttercommunity.workmanager.pigeon.OneOffTaskRequest) { try { + if (request.expedited == true) { + // Expedited work runs as a shortService FGS on Android 14+. + // The plugin declares the permission by default; if the app + // stripped it from the merged manifest, fail loudly now. + requireForegroundServicePermission( + context, + // No Manifest.permission constant exists for this one in + // the SDK stubs; the manifest uses the literal string. + "android.permission.FOREGROUND_SERVICE_SHORT_SERVICE", + "expedited work (setExpedited)", + "FOREGROUND_SERVICE_SHORT_SERVICE is declared by the plugin by default; " + + "keep it if you use expedited work (see the workmanager_android " + + "README, issue #725).", + ) + } val oneOffTaskRequest = createOneOffWorkRequest(request) workManager.enqueueUniqueWork( request.uniqueName, From a26b14e5c5717b60f2547ecc3b606991b327332a Mon Sep 17 00:00:00 2001 From: Sebastian Roth Date: Tue, 4 Aug 2026 19:45:24 +0100 Subject: [PATCH 4/4] style: fix import ordering in ForegroundServicePermissionTest --- .../workmanager/ForegroundServicePermissionTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workmanager_android/android/src/test/kotlin/dev/fluttercommunity/workmanager/ForegroundServicePermissionTest.kt b/workmanager_android/android/src/test/kotlin/dev/fluttercommunity/workmanager/ForegroundServicePermissionTest.kt index 18f66e6b..a4e7e834 100644 --- a/workmanager_android/android/src/test/kotlin/dev/fluttercommunity/workmanager/ForegroundServicePermissionTest.kt +++ b/workmanager_android/android/src/test/kotlin/dev/fluttercommunity/workmanager/ForegroundServicePermissionTest.kt @@ -1,7 +1,6 @@ package dev.fluttercommunity.workmanager import android.content.pm.ServiceInfo -import org.robolectric.RuntimeEnvironment import dev.fluttercommunity.workmanager.pigeon.ForegroundServiceConfig import dev.fluttercommunity.workmanager.pigeon.ForegroundServiceType import org.junit.Assert.assertEquals @@ -9,6 +8,7 @@ import org.junit.Assert.assertThrows import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class)