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(), + ) + } +}