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 @@
-
-
-
-
+
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..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
@@ -46,7 +47,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 +76,49 @@ private fun createNotificationChannel(
.createNotificationChannel(channel)
}
-private fun resolveForegroundServiceType(config: ForegroundServiceConfig): Int =
+/**
+ * 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,
+): 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+.
+ 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,
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..a4e7e834
--- /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 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.RuntimeEnvironment
+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(),
+ )
+ }
+}