diff --git a/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingPlugin.java b/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingPlugin.java index 44c7dad077bb..a02d0317ecbd 100644 --- a/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingPlugin.java +++ b/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingPlugin.java @@ -8,7 +8,9 @@ import android.Manifest; import android.app.Activity; +import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.os.Build; import android.os.Bundle; @@ -16,6 +18,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; +import androidx.core.app.ActivityCompat; import androidx.annotation.VisibleForTesting; import androidx.core.app.NotificationManagerCompat; import androidx.lifecycle.LiveData; @@ -373,6 +376,16 @@ private Task> getInitialMessage() { return taskCompletionSource.getTask(); } + // Wire values for Dart AuthorizationStatus (see convertToAuthorizationStatus). + private static final int AUTH_NOT_DETERMINED = -1; + private static final int AUTH_DENIED = 0; + private static final int AUTH_AUTHORIZED = 1; + private static final int AUTH_DENIED_PERMANENTLY = 3; + + private static final String PERMISSIONS_PREFERENCES_FILE = + "io.flutter.plugins.firebase.messaging.permissions"; + private static final String KEY_PERMISSION_REQUESTED = "notification_permission_requested"; + @RequiresApi(api = 33) private Task> requestPermissions() { TaskCompletionSource> taskCompletionSource = new TaskCompletionSource<>(); @@ -385,14 +398,24 @@ private Task> requestPermissions() { if (!areNotificationsEnabled) { permissionManager.requestPermissions( mainActivity, - (notificationsEnabled) -> { - permissions.put("authorizationStatus", notificationsEnabled); + (grantResult) -> { + // Record that the OS has now asked the user, so a later + // getNotificationSettings() can tell a permanent denial apart from + // "never asked". + markNotificationPermissionRequested(); + // After the OS dialog, resolve the full status (soft vs permanent deny) + // instead of returning only the raw grant result. + int status = + grantResult == 1 + ? AUTH_AUTHORIZED + : resolveNotificationAuthorizationStatus(); + permissions.put("authorizationStatus", status); taskCompletionSource.setResult(permissions); }, (String errorDescription) -> taskCompletionSource.setException(new Exception(errorDescription))); } else { - permissions.put("authorizationStatus", 1); + permissions.put("authorizationStatus", AUTH_AUTHORIZED); taskCompletionSource.setResult(permissions); } @@ -411,6 +434,58 @@ private Boolean checkPermissions() { == PackageManager.PERMISSION_GRANTED; } + private SharedPreferences getPermissionsPreferences() { + return ContextHolder.getApplicationContext() + .getSharedPreferences(PERMISSIONS_PREFERENCES_FILE, Context.MODE_PRIVATE); + } + + private void markNotificationPermissionRequested() { + getPermissionsPreferences().edit().putBoolean(KEY_PERMISSION_REQUESTED, true).apply(); + } + + /** + * Resolves Android 13+ notification permission into Dart authorization codes. + * + *

A denied {@code POST_NOTIFICATIONS} is ambiguous: Android reports the same state for "never + * asked" and "permanently denied", and it exposes no public API for reading the underlying + * permission flags. {@link ActivityCompat#shouldShowRequestPermissionRationale} combined with a + * SharedPreferences record of whether we ever showed the prompt breaks the tie. This mirrors how + * {@code permission_handler} solves the same problem. + * + *

    + *
  • granted → authorized (1) + *
  • never asked → notDetermined (-1) + *
  • soft deny (rationale can be shown) → denied (0) + *
  • asked before, no rationale → deniedPermanently (3) + *
+ * + *

Known limitation: if another plugin requested {@code POST_NOTIFICATIONS} and the user denied + * it permanently, we have no record of the prompt and report notDetermined. Calling {@link + * #requestPermissions()} in that state is a no-op that resolves to the correct status. + */ + @RequiresApi(api = 33) + private int resolveNotificationAuthorizationStatus() { + if (checkPermissions()) { + return AUTH_AUTHORIZED; + } + + if (mainActivity != null + && ActivityCompat.shouldShowRequestPermissionRationale( + mainActivity, Manifest.permission.POST_NOTIFICATIONS)) { + // Denied at least once, but the OS will still show another prompt. + return AUTH_DENIED; + } + + if (!getPermissionsPreferences().getBoolean(KEY_PERMISSION_REQUESTED, false)) { + return AUTH_NOT_DETERMINED; + } + + // Asked before and no rationale is available. Without an Activity we cannot call + // shouldShowRequestPermissionRationale at all, so report the softer status and let + // callers retry once an Activity is attached. + return mainActivity == null ? AUTH_DENIED : AUTH_DENIED_PERMANENTLY; + } + private Task> getPermissions() { TaskCompletionSource> taskCompletionSource = new TaskCompletionSource<>(); @@ -418,14 +493,15 @@ private Task> getPermissions() { () -> { try { final Map permissions = new HashMap<>(); - final boolean areNotificationsEnabled; if (Build.VERSION.SDK_INT >= 33) { - areNotificationsEnabled = checkPermissions(); + permissions.put("authorizationStatus", resolveNotificationAuthorizationStatus()); } else { - areNotificationsEnabled = - NotificationManagerCompat.from(mainActivity).areNotificationsEnabled(); + final boolean areNotificationsEnabled = + NotificationManagerCompat.from(ContextHolder.getApplicationContext()) + .areNotificationsEnabled(); + permissions.put( + "authorizationStatus", areNotificationsEnabled ? AUTH_AUTHORIZED : AUTH_DENIED); } - permissions.put("authorizationStatus", areNotificationsEnabled ? 1 : 0); taskCompletionSource.setResult(permissions); } catch (Exception e) { taskCompletionSource.setException(e); diff --git a/packages/firebase_messaging/firebase_messaging/example/android/app/src/main/kotlin/io/flutter/plugins/firebase/messaging/example/MainActivity.kt b/packages/firebase_messaging/firebase_messaging/example/android/app/src/main/kotlin/io/flutter/plugins/firebase/messaging/example/MainActivity.kt index c5b5e08c88f8..52011aed166a 100644 --- a/packages/firebase_messaging/firebase_messaging/example/android/app/src/main/kotlin/io/flutter/plugins/firebase/messaging/example/MainActivity.kt +++ b/packages/firebase_messaging/firebase_messaging/example/android/app/src/main/kotlin/io/flutter/plugins/firebase/messaging/example/MainActivity.kt @@ -1,5 +1,115 @@ package io.flutter.plugins.firebase.messaging.example +import android.content.Context +import android.os.Build +import android.os.ParcelFileDescriptor import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import java.io.FileInputStream -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + // Test-only channel for manipulating runtime permissions during + // integration tests. Uses reflection to access InstrumentationRegistry + // so the code compiles without an androidTest dependency. + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "tests/permissions") + .setMethodCallHandler { call, result -> + when (call.method) { + "getSdkInt" -> result.success(Build.VERSION.SDK_INT) + "grant" -> { + val permission = call.argument("permission") + if (permission == null) { + result.error("INVALID_ARG", "permission is required", null) + return@setMethodCallHandler + } + try { + mutatePermission(permission, grant = true) + result.success(true) + } catch (e: Exception) { + result.error("GRANT_FAILED", e.message, null) + } + } + "revoke" -> { + val permission = call.argument("permission") + if (permission == null) { + result.error("INVALID_ARG", "permission is required", null) + return@setMethodCallHandler + } + try { + mutatePermission(permission, grant = false) + result.success(true) + } catch (e: Exception) { + result.error("REVOKE_FAILED", e.message, null) + } + } + "resetPermission" -> { + val permission = call.argument("permission") + if (permission == null) { + result.error("INVALID_ARG", "permission is required", null) + return@setMethodCallHandler + } + try { + // Revoke and clear user-set/user-fixed flags so the + // permission returns to a true "never asked" state. + mutatePermission(permission, grant = false) + executeShell( + "pm clear-permission-flags $packageName $permission user-set user-fixed", + ) + // firebase_messaging also records whether it ever showed the + // prompt; clear it so "never asked" is reproducible across tests. + getSharedPreferences( + MESSAGING_PERMISSIONS_PREFERENCES, + Context.MODE_PRIVATE, + ).edit().clear().commit() + result.success(true) + } catch (e: Exception) { + result.error("RESET_FAILED", e.message, null) + } + } + else -> result.notImplemented() + } + } + } + + private fun mutatePermission(permission: String, grant: Boolean) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return + val uiAutomation = uiAutomation() + val methodName = if (grant) "grantRuntimePermission" else "revokeRuntimePermission" + uiAutomation.javaClass + .getMethod(methodName, String::class.java, String::class.java) + .invoke(uiAutomation, packageName, permission) + } + + private fun executeShell(command: String) { + val uiAutomation = uiAutomation() + val pfd = + uiAutomation.javaClass + .getMethod("executeShellCommand", String::class.java) + .invoke(uiAutomation, command) as ParcelFileDescriptor + // Drain/close so the command is not left hanging. + FileInputStream(pfd.fileDescriptor).use { input -> + val buffer = ByteArray(1024) + while (input.read(buffer) != -1) { + // discard + } + } + pfd.close() + } + + private fun uiAutomation(): Any { + // Use reflection so this compiles without an androidTest dependency. + // At runtime under instrumentation, InstrumentationRegistry is available. + val registry = Class.forName("androidx.test.platform.app.InstrumentationRegistry") + val instrumentation = registry.getMethod("getInstrumentation").invoke(null) + return instrumentation.javaClass.getMethod("getUiAutomation").invoke(instrumentation) + } + + private companion object { + // Keep in sync with FlutterFirebaseMessagingPlugin.PERMISSIONS_PREFERENCES_FILE. + const val MESSAGING_PERMISSIONS_PREFERENCES = + "io.flutter.plugins.firebase.messaging.permissions" + } +} diff --git a/packages/firebase_messaging/firebase_messaging/example/integration_test/e2e_test.dart b/packages/firebase_messaging/firebase_messaging/example/integration_test/e2e_test.dart index 85f37fbd5f95..923f773de2c7 100644 --- a/packages/firebase_messaging/firebase_messaging/example/integration_test/e2e_test.dart +++ b/packages/firebase_messaging/firebase_messaging/example/integration_test/e2e_test.dart @@ -7,12 +7,51 @@ import 'dart:async'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:firebase_messaging_example/firebase_options.dart'; import 'report_test_results.dart'; +/// Test helpers that use UiAutomation to mutate runtime permissions during +/// integration tests. Falls back gracefully on platforms that don't support it. +const _permissionsChannel = MethodChannel('tests/permissions'); +const _postNotifications = 'android.permission.POST_NOTIFICATIONS'; + +Future androidSdkInt() async { + try { + return await _permissionsChannel.invokeMethod('getSdkInt'); + } catch (_) { + return null; + } +} + +Future grantAndroidPermission(String permission) async { + try { + return await _permissionsChannel + .invokeMethod('grant', {'permission': permission}) ?? + false; + } catch (_) { + return false; + } +} + +/// Revokes [permission], clears its user-set/user-fixed flags, and clears the +/// prompt state firebase_messaging records, so the permission is reported as +/// never asked again. +Future resetAndroidPermission(String permission) async { + try { + final reset = await _permissionsChannel.invokeMethod( + 'resetPermission', + {'permission': permission}, + ); + return reset ?? false; + } catch (_) { + return false; + } +} + // ignore: do_not_use_environment const bool skipTestsOnCI = bool.fromEnvironment('CI'); @@ -25,12 +64,16 @@ void main() { () { late FirebaseApp app; late FirebaseMessaging messaging; + int? sdkInt; setUpAll(() async { app = await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); messaging = FirebaseMessaging.instance; + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + sdkInt = await androidSdkInt(); + } }); test('instance', () { @@ -88,6 +131,109 @@ void main() { }); }); + group('getNotificationSettings', () { + bool android13Plus() => + !kIsWeb && + defaultTargetPlatform == TargetPlatform.android && + (sdkInt ?? 0) >= 33; + + setUp(() async { + if (!android13Plus()) { + return; + } + // Ensure a true "never asked" state between tests and runs. + // revoke alone leaves USER_SET flags and would look like a denial. + final reset = await resetAndroidPermission(_postNotifications); + if (!reset) { + fail('Could not reset POST_NOTIFICATIONS via UiAutomation'); + } + }); + + test( + 'returns notDetermined on Android 13+ before permission is granted', + () async { + if (!android13Plus()) { + markTestSkipped('Requires Android API 33+'); + return; + } + // On Android 13+, getNotificationSettings() should return + // notDetermined when POST_NOTIFICATIONS has never been granted, + // allowing callers to decide whether to show the OS prompt or + // direct the user to app settings. + final settings = await messaging.getNotificationSettings(); + expect(settings, isA()); + expect( + settings.authorizationStatus, + AuthorizationStatus.notDetermined, + ); + }, + skip: kIsWeb || defaultTargetPlatform != TargetPlatform.android, + ); + + test( + 'returns authorized on Android 13+ after permission is granted', + () async { + if (!android13Plus()) { + markTestSkipped('Requires Android API 33+'); + return; + } + final granted = await grantAndroidPermission(_postNotifications); + if (!granted) { + fail('Could not grant POST_NOTIFICATIONS via UiAutomation'); + } + + final settings = await messaging.getNotificationSettings(); + expect(settings, isA()); + expect( + settings.authorizationStatus, + AuthorizationStatus.authorized, + ); + }, + skip: kIsWeb || defaultTargetPlatform != TargetPlatform.android, + ); + }); + + group('requestPermission', () { + test( + 'authorizationStatus returns AuthorizationStatus.authorized on Android 13+', + () async { + final isAndroid13Plus = !kIsWeb && + defaultTargetPlatform == TargetPlatform.android && + (sdkInt ?? 0) >= 33; + if (!isAndroid13Plus) { + markTestSkipped('Requires Android API 33+'); + return; + } + // Pre-grant the permission so requestPermission() returns + // authorized without showing a system dialog. + final granted = await grantAndroidPermission(_postNotifications); + if (!granted) { + fail('Could not grant POST_NOTIFICATIONS via UiAutomation'); + } + + final result = await messaging.requestPermission(); + expect(result, isA()); + expect(result.authorizationStatus, AuthorizationStatus.authorized); + }, + skip: kIsWeb || defaultTargetPlatform != TargetPlatform.android, + ); + + test( + 'authorizationStatus returns AuthorizationStatus.notDetermined on Web', + () async { + final result = await messaging.requestPermission(); + + expect(result, isA()); + expect( + result.authorizationStatus, + AuthorizationStatus.notDetermined, + ); + }, + // This requires interaction with the browser's permission dialog, it no longer returns `notDetermined` on web + skip: true, + ); + }); + group('getAPNSToken', () { test( 'resolves null on android', diff --git a/packages/firebase_messaging/firebase_messaging/example/lib/permissions.dart b/packages/firebase_messaging/firebase_messaging/example/lib/permissions.dart index 2717a6af6178..122874ade164 100644 --- a/packages/firebase_messaging/firebase_messaging/example/lib/permissions.dart +++ b/packages/firebase_messaging/firebase_messaging/example/lib/permissions.dart @@ -108,6 +108,7 @@ const statusMap = { AuthorizationStatus.denied: 'Denied', AuthorizationStatus.notDetermined: 'Not Determined', AuthorizationStatus.provisional: 'Provisional', + AuthorizationStatus.deniedPermanently: 'Denied Permanently', }; /// Maps a [AppleNotificationSetting] to a string value. diff --git a/packages/firebase_messaging/firebase_messaging_platform_interface/lib/src/types.dart b/packages/firebase_messaging/firebase_messaging_platform_interface/lib/src/types.dart index 9e9cc25c80aa..837b20a660a9 100644 --- a/packages/firebase_messaging/firebase_messaging_platform_interface/lib/src/types.dart +++ b/packages/firebase_messaging/firebase_messaging_platform_interface/lib/src/types.dart @@ -47,6 +47,10 @@ enum AuthorizationStatus { authorized, /// The app is not authorized to create notifications. + /// + /// On Android 13+, this means the user denied the permission at least once + /// but the OS may still show another permission prompt. Prefer + /// [requestPermission] over sending the user to system settings. denied, /// The app user has not yet chosen whether to allow the application to create @@ -56,6 +60,14 @@ enum AuthorizationStatus { /// The app is currently authorized to post non-interrupting user notifications. provisional, + + /// The app is not authorized to create notifications and the OS will not show + /// another permission prompt. + /// + /// On Android 13+, the user must enable notifications from system settings. + /// On Apple platforms this status is not used; permanent denial is reported + /// as [denied]. + deniedPermanently, } /// An enum representing a notification priority on Android. diff --git a/packages/firebase_messaging/firebase_messaging_platform_interface/lib/src/utils.dart b/packages/firebase_messaging/firebase_messaging_platform_interface/lib/src/utils.dart index 439993cf568f..eb4a4fc54271 100644 --- a/packages/firebase_messaging/firebase_messaging_platform_interface/lib/src/utils.dart +++ b/packages/firebase_messaging/firebase_messaging_platform_interface/lib/src/utils.dart @@ -88,6 +88,8 @@ AuthorizationStatus convertToAuthorizationStatus(int? status) { return AuthorizationStatus.authorized; case 2: return AuthorizationStatus.provisional; + case 3: + return AuthorizationStatus.deniedPermanently; default: return AuthorizationStatus.notDetermined; } diff --git a/packages/firebase_messaging/firebase_messaging_platform_interface/test/method_channel_tests/method_channel_messaging_test.dart b/packages/firebase_messaging/firebase_messaging_platform_interface/test/method_channel_tests/method_channel_messaging_test.dart index 4e868706acfa..87755024f8f1 100644 --- a/packages/firebase_messaging/firebase_messaging_platform_interface/test/method_channel_tests/method_channel_messaging_test.dart +++ b/packages/firebase_messaging/firebase_messaging_platform_interface/test/method_channel_tests/method_channel_messaging_test.dart @@ -39,6 +39,7 @@ void main() { }; case 'Messaging#hasPermission': case 'Messaging#requestPermission': + case 'Messaging#getNotificationSettings': return { 'authorizationStatus': 1, 'alert': 1, @@ -168,6 +169,156 @@ void main() { ]); }); + test('getNotificationSettings', () async { + final settings = await messaging.getNotificationSettings(); + expect(settings, isA()); + expect( + settings.authorizationStatus, equals(AuthorizationStatus.authorized)); + + // check native method was called + expect(log, [ + isMethodCall( + 'Messaging#getNotificationSettings', + arguments: { + 'appName': defaultFirebaseAppName, + }, + ), + ]); + }); + + test( + 'getNotificationSettings returns notDetermined when authorizationStatus is -1', + () async { + // Override the method handler to return notDetermined (-1) + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(MethodChannelFirebaseMessaging.channel, + (call) async { + log.add(call); + if (call.method == 'Messaging#getNotificationSettings') { + return { + 'authorizationStatus': -1, + 'alert': -1, + 'announcement': -1, + 'badge': -1, + 'carPlay': -1, + 'criticalAlert': -1, + 'provisional': -1, + 'sound': -1, + 'providesAppNotificationSettings': -1, + }; + } + return {}; + }); + + final settings = await messaging.getNotificationSettings(); + expect(settings.authorizationStatus, + equals(AuthorizationStatus.notDetermined)); + + // Restore original handler + handleMethodCall((call) async { + log.add(call); + switch (call.method) { + case 'Messaging#deleteToken': + case 'Messaging#subscribeToTopic': + case 'Messaging#unsubscribeFromTopic': + return null; + case 'Messaging#getAPNSToken': + case 'Messaging#getToken': + return { + 'token': 'test_token', + }; + case 'Messaging#hasPermission': + case 'Messaging#requestPermission': + case 'Messaging#getNotificationSettings': + return { + 'authorizationStatus': 1, + 'alert': 1, + 'announcement': 0, + 'badge': 1, + 'carPlay': 0, + 'criticalAlert': 0, + 'provisional': 0, + 'sound': 1, + 'providesAppNotificationSettings': 0, + }; + case 'Messaging#setAutoInitEnabled': + return { + 'isAutoInitEnabled': call.arguments['enabled'], + }; + case 'Messaging#deleteInstanceID': + return true; + default: + return {}; + } + }); + }); + + test( + 'getNotificationSettings returns deniedPermanently when authorizationStatus is 3', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(MethodChannelFirebaseMessaging.channel, + (call) async { + log.add(call); + if (call.method == 'Messaging#getNotificationSettings') { + return { + 'authorizationStatus': 3, + 'alert': 0, + 'announcement': 0, + 'badge': 0, + 'carPlay': 0, + 'criticalAlert': 0, + 'provisional': 0, + 'sound': 0, + 'providesAppNotificationSettings': 0, + }; + } + return {}; + }); + + final settings = await messaging.getNotificationSettings(); + expect(settings.authorizationStatus, + equals(AuthorizationStatus.deniedPermanently)); + + // Restore original handler + handleMethodCall((call) async { + log.add(call); + switch (call.method) { + case 'Messaging#deleteToken': + case 'Messaging#subscribeToTopic': + case 'Messaging#unsubscribeFromTopic': + return null; + case 'Messaging#getAPNSToken': + case 'Messaging#getToken': + return { + 'token': 'test_token', + }; + case 'Messaging#hasPermission': + case 'Messaging#requestPermission': + case 'Messaging#getNotificationSettings': + return { + 'authorizationStatus': 1, + 'alert': 1, + 'announcement': 0, + 'badge': 1, + 'carPlay': 0, + 'criticalAlert': 0, + 'provisional': 0, + 'sound': 1, + 'providesAppNotificationSettings': 0, + }; + case 'Messaging#setAutoInitEnabled': + return { + 'isAutoInitEnabled': call.arguments['enabled'], + }; + case 'Messaging#deleteInstanceID': + return true; + default: + return {}; + } + }); + }); + test('requestPermission', () async { // test android response final androidPermissions = await messaging.requestPermission(); diff --git a/packages/firebase_messaging/firebase_messaging_platform_interface/test/utils_test.dart b/packages/firebase_messaging/firebase_messaging_platform_interface/test/utils_test.dart index 0aadb296ae60..1677b5056d21 100644 --- a/packages/firebase_messaging/firebase_messaging_platform_interface/test/utils_test.dart +++ b/packages/firebase_messaging/firebase_messaging_platform_interface/test/utils_test.dart @@ -135,6 +135,8 @@ void main() { expect(convertToAuthorizationStatus(1), AuthorizationStatus.authorized); expect( convertToAuthorizationStatus(2), AuthorizationStatus.provisional); + expect(convertToAuthorizationStatus(3), + AuthorizationStatus.deniedPermanently); }); test( @@ -143,7 +145,7 @@ void main() { expect(convertToAuthorizationStatus(-2), AuthorizationStatus.notDetermined); expect( - convertToAuthorizationStatus(3), AuthorizationStatus.notDetermined); + convertToAuthorizationStatus(4), AuthorizationStatus.notDetermined); }); test( diff --git a/tests/android/app/src/main/kotlin/io/flutter/plugins/firebase/tests/MainActivity.kt b/tests/android/app/src/main/kotlin/io/flutter/plugins/firebase/tests/MainActivity.kt index 57b5fca33169..bd190c7898a0 100644 --- a/tests/android/app/src/main/kotlin/io/flutter/plugins/firebase/tests/MainActivity.kt +++ b/tests/android/app/src/main/kotlin/io/flutter/plugins/firebase/tests/MainActivity.kt @@ -1,5 +1,115 @@ package io.flutter.plugins.firebase.tests +import android.content.Context +import android.os.Build +import android.os.ParcelFileDescriptor import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import java.io.FileInputStream -class MainActivity: FlutterActivity() +class MainActivity : FlutterActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + // Test-only channel for manipulating runtime permissions during + // integration tests. Uses reflection to access InstrumentationRegistry + // so the code compiles without an androidTest dependency. + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "tests/permissions") + .setMethodCallHandler { call, result -> + when (call.method) { + "getSdkInt" -> result.success(Build.VERSION.SDK_INT) + "grant" -> { + val permission = call.argument("permission") + if (permission == null) { + result.error("INVALID_ARG", "permission is required", null) + return@setMethodCallHandler + } + try { + mutatePermission(permission, grant = true) + result.success(true) + } catch (e: Exception) { + result.error("GRANT_FAILED", e.message, null) + } + } + "revoke" -> { + val permission = call.argument("permission") + if (permission == null) { + result.error("INVALID_ARG", "permission is required", null) + return@setMethodCallHandler + } + try { + mutatePermission(permission, grant = false) + result.success(true) + } catch (e: Exception) { + result.error("REVOKE_FAILED", e.message, null) + } + } + "resetPermission" -> { + val permission = call.argument("permission") + if (permission == null) { + result.error("INVALID_ARG", "permission is required", null) + return@setMethodCallHandler + } + try { + // Revoke and clear user-set/user-fixed flags so the + // permission returns to a true "never asked" state. + mutatePermission(permission, grant = false) + executeShell( + "pm clear-permission-flags $packageName $permission user-set user-fixed", + ) + // firebase_messaging also records whether it ever showed the + // prompt; clear it so "never asked" is reproducible across tests. + getSharedPreferences( + MESSAGING_PERMISSIONS_PREFERENCES, + Context.MODE_PRIVATE, + ).edit().clear().commit() + result.success(true) + } catch (e: Exception) { + result.error("RESET_FAILED", e.message, null) + } + } + else -> result.notImplemented() + } + } + } + + private fun mutatePermission(permission: String, grant: Boolean) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return + val uiAutomation = uiAutomation() + val methodName = if (grant) "grantRuntimePermission" else "revokeRuntimePermission" + uiAutomation.javaClass + .getMethod(methodName, String::class.java, String::class.java) + .invoke(uiAutomation, packageName, permission) + } + + private fun executeShell(command: String) { + val uiAutomation = uiAutomation() + val pfd = + uiAutomation.javaClass + .getMethod("executeShellCommand", String::class.java) + .invoke(uiAutomation, command) as ParcelFileDescriptor + // Drain/close so the command is not left hanging. + FileInputStream(pfd.fileDescriptor).use { input -> + val buffer = ByteArray(1024) + while (input.read(buffer) != -1) { + // discard + } + } + pfd.close() + } + + private fun uiAutomation(): Any { + // Use reflection so this compiles without an androidTest dependency. + // At runtime under instrumentation, InstrumentationRegistry is available. + val registry = Class.forName("androidx.test.platform.app.InstrumentationRegistry") + val instrumentation = registry.getMethod("getInstrumentation").invoke(null) + return instrumentation.javaClass.getMethod("getUiAutomation").invoke(instrumentation) + } + + private companion object { + // Keep in sync with FlutterFirebaseMessagingPlugin.PERMISSIONS_PREFERENCES_FILE. + const val MESSAGING_PERMISSIONS_PREFERENCES = + "io.flutter.plugins.firebase.messaging.permissions" + } +}