From 89821d95a406181c8d5b77a09b2b0c994065796c Mon Sep 17 00:00:00 2001 From: Sebastian Roth Date: Mon, 17 Aug 2026 22:27:59 +0100 Subject: [PATCH] fix(android): check manifest declaration instead of grant state for FGS permission (fixes #731) --- .../workmanager/ForegroundServiceUtils.kt | 21 ++- .../ForegroundServicePermissionTest.kt | 121 +++++++++++++++++- 2 files changed, 134 insertions(+), 8 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 5fd32a3f..d2109ec6 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 @@ -78,19 +78,28 @@ private fun createNotificationChannel( /** * 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. + * declaration it needs (see issues #725 and #731). + * + * The merged manifest declaration is the contract to check: on Android 16+ a + * foreground-service permission can be revoked or denied at runtime even when + * it is declared, so [PackageManager.checkPermission] (which reports the + * runtime grant state) false-positives and rejects valid declarations. See + * #731. */ +@Suppress("DEPRECATION") // GET_PERMISSIONS is deprecated on API 33+; the PackageInfoFlags overload needs API 33 at runtime (minSdk 23). internal fun requireForegroundServicePermission( context: Context, permission: String, featureDescription: String, fixHint: String, ) { - val granted = - context.packageManager.checkPermission(permission, context.packageName) == - PackageManager.PERMISSION_GRANTED - if (!granted) { + val declared = + context + .packageManager + .getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS) + .requestedPermissions + ?.contains(permission) == true + if (!declared) { throw IllegalStateException( "workmanager_android: $featureDescription requires the '$permission' permission " + "in the merged manifest, but it is missing. $fixHint", 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 a4e7e834..04355ab9 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,12 +1,18 @@ package dev.fluttercommunity.workmanager +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager 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.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config @@ -17,8 +23,9 @@ 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. + // FOREGROUND_SERVICE_DATA_SYNC (it is opt-in, see issue #725), so the + // merged-manifest check reports the permission as missing and the + // guard fails loudly. val config = ForegroundServiceConfig( notificationTitle = "Syncing", @@ -48,4 +55,114 @@ class ForegroundServicePermissionTest { info.foregroundServiceType.toLong(), ) } + + @Test + fun `permission check passes when the shortService permission is declared`() { + // The #731 regression: the plugin's default manifest always declares + // FOREGROUND_SERVICE_SHORT_SERVICE, so the expedited-work path must + // pass based on that declaration alone — even when the runtime grant + // state would report DENIED (Android 16+ can revoke foreground-service + // permissions). The runtime grant state must not matter. + val context = + contextWithRequestedPermissions( + "android.permission.FOREGROUND_SERVICE", + "android.permission.FOREGROUND_SERVICE_SHORT_SERVICE", + ) + + // Must not throw. + requireForegroundServicePermission( + context, + "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).", + ) + } + + @Test + fun `permission check passes when the dataSync permission is declared`() { + // Simulates the opt-in manifest swapped in when + // workmanager.enableDataSyncForegroundService=true declares + // FOREGROUND_SERVICE_DATA_SYNC. + val context = + contextWithRequestedPermissions( + "android.permission.FOREGROUND_SERVICE", + "android.permission.FOREGROUND_SERVICE_DATA_SYNC", + "android.permission.FOREGROUND_SERVICE_SHORT_SERVICE", + ) + + // Must not throw. + requireForegroundServicePermission( + context, + "android.permission.FOREGROUND_SERVICE_DATA_SYNC", + "foregroundServiceType=dataSync", + "Add 'workmanager.enableDataSyncForegroundService=true' to your " + + "gradle.properties (see the workmanager_android README, issue #725).", + ) + } + + @Test + fun `permission check 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 the + // guard must fail loudly. + val context = + contextWithRequestedPermissions( + "android.permission.FOREGROUND_SERVICE", + "android.permission.FOREGROUND_SERVICE_SHORT_SERVICE", + ) + + val exception = + assertThrows(IllegalStateException::class.java) { + requireForegroundServicePermission( + context, + "android.permission.FOREGROUND_SERVICE_DATA_SYNC", + "foregroundServiceType=dataSync", + "Add 'workmanager.enableDataSyncForegroundService=true' to your " + + "gradle.properties (see the workmanager_android README, issue #725).", + ) + } + assertTrue(exception.message!!.contains("android.permission.FOREGROUND_SERVICE_DATA_SYNC")) + } + + @Test + fun `permission check throws when the permission list is unavailable`() { + // Defensive edge case: a PackageInfo without requestedPermissions + // (e.g. from a package manager that did not surface the list) must be + // treated as "not declared" and fail loudly instead of crashing. + val context = contextWithRequestedPermissions() + + assertThrows(IllegalStateException::class.java) { + requireForegroundServicePermission( + context, + "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).", + ) + } + } + + /** + * Returns a mocked [Context] whose package manager reports the given + * permissions as the manifest's requestedPermissions. Mirrors what + * [requireForegroundServicePermission] reads via + * `getPackageInfo(packageName, GET_PERMISSIONS)`. + */ + private fun contextWithRequestedPermissions(vararg permissions: String): Context { + val packageInfo = + PackageInfo().apply { + packageName = "com.example.app" + requestedPermissions = if (permissions.isEmpty()) null else permissions + } + val packageManager = mock() + whenever(packageManager.getPackageInfo("com.example.app", PackageManager.GET_PERMISSIONS)) + .thenReturn(packageInfo) + val context = mock() + whenever(context.packageName).thenReturn("com.example.app") + whenever(context.packageManager).thenReturn(packageManager) + return context + } }