From 6991243922da7314236c187b8470c3a2d5fb1390 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Mon, 9 Mar 2026 15:43:21 +0100 Subject: [PATCH 1/6] feat(messaging,android): improve how messaging is determining permission on Android --- .../FlutterFirebaseMessagingPlugin.java | 60 +++++++++++-- .../method_channel_messaging_test.dart | 85 +++++++++++++++++++ .../plugins/firebase/tests/MainActivity.kt | 49 ++++++++++- .../firebase_messaging_e2e_test.dart | 76 ++++++++++++++++- 4 files changed, 262 insertions(+), 8 deletions(-) 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 63d1e0dfac0a..c1b66af5f87b 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,12 +8,15 @@ 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 androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; +import androidx.core.app.ActivityCompat; import androidx.core.app.NotificationManagerCompat; import androidx.lifecycle.LiveData; import androidx.lifecycle.Observer; @@ -367,6 +370,15 @@ private Task> requestPermissions() { permissionManager.requestPermissions( mainActivity, (notificationsEnabled) -> { + if (notificationsEnabled == 0) { + // User denied — record this so getNotificationSettings() + // can later distinguish "permanently denied" from "never asked". + SharedPreferences prefs = + ContextHolder.getApplicationContext() + .getSharedPreferences( + "FlutterFirebaseMessaging", Context.MODE_PRIVATE); + prefs.edit().putBoolean("notification_permission_denied", true).apply(); + } permissions.put("authorizationStatus", notificationsEnabled); taskCompletionSource.setResult(permissions); }, @@ -399,14 +411,52 @@ private Task> getPermissions() { () -> { try { final Map permissions = new HashMap<>(); - final boolean areNotificationsEnabled; if (Build.VERSION.SDK_INT >= 33) { - areNotificationsEnabled = checkPermissions(); + final boolean areNotificationsEnabled = checkPermissions(); + if (areNotificationsEnabled) { + permissions.put("authorizationStatus", 1); + } else { + // Permission is not granted. Use shouldShowRequestPermissionRationale + // combined with a SharedPreferences flag to distinguish three states: + // + // 1. shouldShowRationale=true → user denied once, can still prompt + // 2. shouldShowRationale=false + wasDeniedBefore=false → never asked + // 3. shouldShowRationale=false + wasDeniedBefore=true → permanently denied + // + // This mirrors how permission_handler solves the same ambiguity. + boolean shouldShowRationale = mainActivity != null + && ActivityCompat.shouldShowRequestPermissionRationale( + mainActivity, Manifest.permission.POST_NOTIFICATIONS); + + SharedPreferences prefs = + ContextHolder.getApplicationContext() + .getSharedPreferences( + "FlutterFirebaseMessaging", Context.MODE_PRIVATE); + boolean wasDeniedBefore = + prefs.getBoolean("notification_permission_denied", false); + + if (shouldShowRationale) { + // User denied at least once but didn't select "Don't ask again". + // Record the denial if not already recorded. + if (!wasDeniedBefore) { + prefs.edit().putBoolean("notification_permission_denied", true).apply(); + } + // Denied but can still be prompted again. + permissions.put("authorizationStatus", 0); + } else if (wasDeniedBefore) { + // No rationale + previously denied = permanently denied. + permissions.put("authorizationStatus", 0); + } else { + // No rationale + never denied = never asked. + permissions.put("authorizationStatus", -1); + } + } } else { - areNotificationsEnabled = - NotificationManagerCompat.from(mainActivity).areNotificationsEnabled(); + final boolean areNotificationsEnabled = + NotificationManagerCompat.from(ContextHolder.getApplicationContext()) + .areNotificationsEnabled(); + permissions.put("authorizationStatus", areNotificationsEnabled ? 1 : 0); } - permissions.put("authorizationStatus", areNotificationsEnabled ? 1 : 0); taskCompletionSource.setResult(permissions); } catch (Exception e) { taskCompletionSource.setException(e); 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..efeab2d3b465 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,90 @@ 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('requestPermission', () async { // test android response final androidPermissions = await messaging.requestPermission(); 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..1a3c810dcb59 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,52 @@ package io.flutter.plugins.firebase.tests +import android.os.Build import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel -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) { + "grant" -> { + val permission = call.argument("permission") + if (permission == null) { + result.error("INVALID_ARG", "permission is required", null) + return@setMethodCallHandler + } + try { + grantPermission(permission) + result.success(true) + } catch (e: Exception) { + result.error("GRANT_FAILED", e.message, null) + } + } + "clearSharedPrefs" -> { + val name = call.argument("name") ?: "FlutterFirebaseMessaging" + getSharedPreferences(name, MODE_PRIVATE).edit().clear().apply() + result.success(true) + } + else -> result.notImplemented() + } + } + } + + private fun grantPermission(permission: String) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return + // 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) + val uiAutomation = instrumentation.javaClass.getMethod("getUiAutomation").invoke(instrumentation) + uiAutomation.javaClass + .getMethod("grantRuntimePermission", String::class.java, String::class.java) + .invoke(uiAutomation, packageName, permission) + } +} diff --git a/tests/integration_test/firebase_messaging/firebase_messaging_e2e_test.dart b/tests/integration_test/firebase_messaging/firebase_messaging_e2e_test.dart index bf94039983fc..33964364ab5b 100644 --- a/tests/integration_test/firebase_messaging/firebase_messaging_e2e_test.dart +++ b/tests/integration_test/firebase_messaging/firebase_messaging_e2e_test.dart @@ -7,10 +7,36 @@ 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:tests/firebase_options.dart'; +/// Test helper that uses [UiAutomation.grantRuntimePermission] to grant +/// Android runtime permissions programmatically during integration tests. +/// Falls back gracefully on platforms that don't support it. +const _permissionsChannel = MethodChannel('tests/permissions'); + +Future grantAndroidPermission(String permission) async { + try { + return await _permissionsChannel + .invokeMethod('grant', {'permission': permission}) ?? + false; + } catch (_) { + return false; + } +} + +Future clearSharedPrefs([String name = 'FlutterFirebaseMessaging']) async { + try { + return await _permissionsChannel + .invokeMethod('clearSharedPrefs', {'name': name}) ?? + false; + } catch (_) { + return false; + } +} + // ignore: do_not_use_environment const bool skipTestsOnCI = bool.fromEnvironment('CI'); @@ -85,16 +111,62 @@ void main() { }); }); + group('getNotificationSettings', () { + test( + 'returns notDetermined on Android before requestPermission() is called', + () async { + // On Android 13+, getNotificationSettings() should return + // notDetermined when requestPermission() has never been called, + // 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: defaultTargetPlatform != TargetPlatform.android, + ); + + test( + 'returns authorized on Android after permission is granted', + () async { + // Use UiAutomation.grantRuntimePermission() to grant the + // notification permission without showing a dialog. + final granted = await grantAndroidPermission( + 'android.permission.POST_NOTIFICATIONS', + ); + if (!granted) { + fail('Could not grant POST_NOTIFICATIONS via UiAutomation'); + } + + final settings = await messaging.getNotificationSettings(); + expect(settings, isA()); + expect( + settings.authorizationStatus, + AuthorizationStatus.authorized, + ); + }, + skip: defaultTargetPlatform != TargetPlatform.android, + ); + }); + group('requestPermission', () { test( 'authorizationStatus returns AuthorizationStatus.authorized on Android', () async { + // Pre-grant the permission so requestPermission() returns + // authorized without showing a system dialog. + await grantAndroidPermission( + 'android.permission.POST_NOTIFICATIONS', + ); + final result = await messaging.requestPermission(); expect(result, isA()); expect(result.authorizationStatus, AuthorizationStatus.authorized); }, - // TODO(Lyokone): since moving to SDK 33+ on Android, this test fails, we need to integrate with patrol to control native permissions - skip: true, + skip: defaultTargetPlatform != TargetPlatform.android, ); }); From 9c0518ce18e854b87e3e92c4c9296b17805fdaf4 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Mon, 9 Mar 2026 15:58:27 +0100 Subject: [PATCH 2/6] format --- .../method_channel_tests/method_channel_messaging_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 efeab2d3b465..b04ccdcc6a0c 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 @@ -172,8 +172,8 @@ void main() { test('getNotificationSettings', () async { final settings = await messaging.getNotificationSettings(); expect(settings, isA()); - expect(settings.authorizationStatus, - equals(AuthorizationStatus.authorized)); + expect( + settings.authorizationStatus, equals(AuthorizationStatus.authorized)); // check native method was called expect(log, [ From b20b501f5b9874bd4fffe1a364f3f3f384e49243 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Mon, 9 Mar 2026 15:58:31 +0100 Subject: [PATCH 3/6] format --- .../messaging/FlutterFirebaseMessagingPlugin.java | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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 c1b66af5f87b..8c14c0fef3bc 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 @@ -424,16 +424,15 @@ private Task> getPermissions() { // 3. shouldShowRationale=false + wasDeniedBefore=true → permanently denied // // This mirrors how permission_handler solves the same ambiguity. - boolean shouldShowRationale = mainActivity != null - && ActivityCompat.shouldShowRequestPermissionRationale( - mainActivity, Manifest.permission.POST_NOTIFICATIONS); + boolean shouldShowRationale = + mainActivity != null + && ActivityCompat.shouldShowRequestPermissionRationale( + mainActivity, Manifest.permission.POST_NOTIFICATIONS); SharedPreferences prefs = ContextHolder.getApplicationContext() - .getSharedPreferences( - "FlutterFirebaseMessaging", Context.MODE_PRIVATE); - boolean wasDeniedBefore = - prefs.getBoolean("notification_permission_denied", false); + .getSharedPreferences("FlutterFirebaseMessaging", Context.MODE_PRIVATE); + boolean wasDeniedBefore = prefs.getBoolean("notification_permission_denied", false); if (shouldShowRationale) { // User denied at least once but didn't select "Don't ask again". From 4f2a4db0c511435b585afa1b08b7879bfd0e2da6 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Mon, 3 Aug 2026 09:55:57 +0200 Subject: [PATCH 4/6] fix(messaging,android): harden notification permission status resolution Use PackageManager permission flags instead of SharedPreferences so soft deny, permanent deny, and never-asked are distinguished correctly even when another plugin requested POST_NOTIFICATIONS. Add AuthorizationStatus.deniedPermanently and reset Android 13+ e2e permission state between tests. --- .../FlutterFirebaseMessagingPlugin.java | 130 +++++++++++------- .../example/lib/permissions.dart | 1 + .../lib/src/types.dart | 12 ++ .../lib/src/utils.dart | 2 + .../method_channel_messaging_test.dart | 66 +++++++++ .../test/utils_test.dart | 4 +- .../plugins/firebase/tests/MainActivity.kt | 70 ++++++++-- .../firebase_messaging_e2e_test.dart | 83 ++++++++--- 8 files changed, 285 insertions(+), 83 deletions(-) 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 dc6fff26c4c8..fdacaad1cb04 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 @@ -10,9 +10,9 @@ 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.Process; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @@ -360,6 +360,12 @@ 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; + @RequiresApi(api = 33) private Task> requestPermissions() { TaskCompletionSource> taskCompletionSource = new TaskCompletionSource<>(); @@ -372,23 +378,20 @@ private Task> requestPermissions() { if (!areNotificationsEnabled) { permissionManager.requestPermissions( mainActivity, - (notificationsEnabled) -> { - if (notificationsEnabled == 0) { - // User denied — record this so getNotificationSettings() - // can later distinguish "permanently denied" from "never asked". - SharedPreferences prefs = - ContextHolder.getApplicationContext() - .getSharedPreferences( - "FlutterFirebaseMessaging", Context.MODE_PRIVATE); - prefs.edit().putBoolean("notification_permission_denied", true).apply(); - } - permissions.put("authorizationStatus", notificationsEnabled); + (grantResult) -> { + // 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); } @@ -407,6 +410,64 @@ private Boolean checkPermissions() { == PackageManager.PERMISSION_GRANTED; } + /** + * Resolves Android 13+ notification permission into Dart authorization codes. + * + *

Uses {@link PackageManager#getPermissionFlags} so the result is correct even when another + * plugin requested {@code POST_NOTIFICATIONS} (no SharedPreferences bookkeeping). + * + *

    + *
  • granted → authorized (1) + *
  • never asked → notDetermined (-1) + *
  • soft deny (can show rationale) → denied (0) + *
  • user-fixed / no-rationale after a decision → deniedPermanently (3) + *
+ */ + @RequiresApi(api = 33) + private int resolveNotificationAuthorizationStatus() { + if (checkPermissions()) { + return AUTH_AUTHORIZED; + } + + Context context = ContextHolder.getApplicationContext(); + int flags = + context + .getPackageManager() + .getPermissionFlags( + Manifest.permission.POST_NOTIFICATIONS, + context.getPackageName(), + Process.myUserHandle()); + + boolean userFixed = (flags & PackageManager.FLAG_PERMISSION_USER_FIXED) != 0; + boolean userSet = (flags & PackageManager.FLAG_PERMISSION_USER_SET) != 0; + + if (userFixed) { + return AUTH_DENIED_PERMANENTLY; + } + + boolean shouldShowRationale = + mainActivity != null + && ActivityCompat.shouldShowRequestPermissionRationale( + mainActivity, Manifest.permission.POST_NOTIFICATIONS); + + if (shouldShowRationale) { + // Denied once; the OS will still show another prompt. + return AUTH_DENIED; + } + + if (userSet) { + // User already decided and rationale is not shown. Without an Activity we cannot + // confirm permanence via shouldShowRationale, so keep this as soft denied so callers + // may still attempt requestPermission() when an Activity is available. + if (mainActivity == null) { + return AUTH_DENIED; + } + return AUTH_DENIED_PERMANENTLY; + } + + return AUTH_NOT_DETERMINED; + } + private Task> getPermissions() { TaskCompletionSource> taskCompletionSource = new TaskCompletionSource<>(); @@ -415,49 +476,14 @@ private Task> getPermissions() { try { final Map permissions = new HashMap<>(); if (Build.VERSION.SDK_INT >= 33) { - final boolean areNotificationsEnabled = checkPermissions(); - if (areNotificationsEnabled) { - permissions.put("authorizationStatus", 1); - } else { - // Permission is not granted. Use shouldShowRequestPermissionRationale - // combined with a SharedPreferences flag to distinguish three states: - // - // 1. shouldShowRationale=true → user denied once, can still prompt - // 2. shouldShowRationale=false + wasDeniedBefore=false → never asked - // 3. shouldShowRationale=false + wasDeniedBefore=true → permanently denied - // - // This mirrors how permission_handler solves the same ambiguity. - boolean shouldShowRationale = - mainActivity != null - && ActivityCompat.shouldShowRequestPermissionRationale( - mainActivity, Manifest.permission.POST_NOTIFICATIONS); - - SharedPreferences prefs = - ContextHolder.getApplicationContext() - .getSharedPreferences("FlutterFirebaseMessaging", Context.MODE_PRIVATE); - boolean wasDeniedBefore = prefs.getBoolean("notification_permission_denied", false); - - if (shouldShowRationale) { - // User denied at least once but didn't select "Don't ask again". - // Record the denial if not already recorded. - if (!wasDeniedBefore) { - prefs.edit().putBoolean("notification_permission_denied", true).apply(); - } - // Denied but can still be prompted again. - permissions.put("authorizationStatus", 0); - } else if (wasDeniedBefore) { - // No rationale + previously denied = permanently denied. - permissions.put("authorizationStatus", 0); - } else { - // No rationale + never denied = never asked. - permissions.put("authorizationStatus", -1); - } - } + permissions.put("authorizationStatus", resolveNotificationAuthorizationStatus()); } else { final boolean areNotificationsEnabled = NotificationManagerCompat.from(ContextHolder.getApplicationContext()) .areNotificationsEnabled(); - permissions.put("authorizationStatus", areNotificationsEnabled ? 1 : 0); + permissions.put( + "authorizationStatus", + areNotificationsEnabled ? AUTH_AUTHORIZED : AUTH_DENIED); } taskCompletionSource.setResult(permissions); } catch (Exception e) { 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 b04ccdcc6a0c..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 @@ -253,6 +253,72 @@ void main() { }); }); + 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 1a3c810dcb59..d402681a086b 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,9 +1,11 @@ package io.flutter.plugins.firebase.tests 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() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { @@ -15,6 +17,7 @@ class MainActivity : FlutterActivity() { 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) { @@ -22,31 +25,78 @@ class MainActivity : FlutterActivity() { return@setMethodCallHandler } try { - grantPermission(permission) + mutatePermission(permission, grant = true) result.success(true) } catch (e: Exception) { result.error("GRANT_FAILED", e.message, null) } } - "clearSharedPrefs" -> { - val name = call.argument("name") ?: "FlutterFirebaseMessaging" - getSharedPreferences(name, MODE_PRIVATE).edit().clear().apply() - result.success(true) + "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", + ) + result.success(true) + } catch (e: Exception) { + result.error("RESET_FAILED", e.message, null) + } } else -> result.notImplemented() } } } - private fun grantPermission(permission: String) { + 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) - val uiAutomation = instrumentation.javaClass.getMethod("getUiAutomation").invoke(instrumentation) - uiAutomation.javaClass - .getMethod("grantRuntimePermission", String::class.java, String::class.java) - .invoke(uiAutomation, packageName, permission) + return instrumentation.javaClass.getMethod("getUiAutomation").invoke(instrumentation) } } diff --git a/tests/integration_test/firebase_messaging/firebase_messaging_e2e_test.dart b/tests/integration_test/firebase_messaging/firebase_messaging_e2e_test.dart index db6195f0166b..81122aad273d 100644 --- a/tests/integration_test/firebase_messaging/firebase_messaging_e2e_test.dart +++ b/tests/integration_test/firebase_messaging/firebase_messaging_e2e_test.dart @@ -12,10 +12,18 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:tests/firebase_options.dart'; -/// Test helper that uses [UiAutomation.grantRuntimePermission] to grant -/// Android runtime permissions programmatically during integration tests. -/// Falls back gracefully on platforms that don't support it. +/// 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 { @@ -27,10 +35,12 @@ Future grantAndroidPermission(String permission) async { } } -Future clearSharedPrefs([String name = 'FlutterFirebaseMessaging']) async { +/// Revokes [permission] and clears user-set/user-fixed flags so Android +/// reports the permission as never asked again. +Future resetAndroidPermission(String permission) async { try { return await _permissionsChannel - .invokeMethod('clearSharedPrefs', {'name': name}) ?? + .invokeMethod('resetPermission', {'permission': permission}) ?? false; } catch (_) { return false; @@ -48,14 +58,26 @@ 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(); + } }); + bool get isAndroid13Plus => + !kIsWeb && + defaultTargetPlatform == TargetPlatform.android && + (sdkInt ?? 0) >= 33; + + bool get skipNonAndroid => + kIsWeb || defaultTargetPlatform != TargetPlatform.android; + test('instance', () { expect(messaging, isA()); expect(messaging.app, isA()); @@ -112,11 +134,27 @@ void main() { }); group('getNotificationSettings', () { + setUp(() async { + if (!isAndroid13Plus) { + 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 before requestPermission() is called', + 'returns notDetermined on Android 13+ before permission is granted', () async { + if (!isAndroid13Plus) { + markTestSkipped('Requires Android API 33+'); + return; + } // On Android 13+, getNotificationSettings() should return - // notDetermined when requestPermission() has never been called, + // 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(); @@ -126,17 +164,17 @@ void main() { AuthorizationStatus.notDetermined, ); }, - skip: defaultTargetPlatform != TargetPlatform.android, + skip: skipNonAndroid, ); test( - 'returns authorized on Android after permission is granted', + 'returns authorized on Android 13+ after permission is granted', () async { - // Use UiAutomation.grantRuntimePermission() to grant the - // notification permission without showing a dialog. - final granted = await grantAndroidPermission( - 'android.permission.POST_NOTIFICATIONS', - ); + if (!isAndroid13Plus) { + markTestSkipped('Requires Android API 33+'); + return; + } + final granted = await grantAndroidPermission(_postNotifications); if (!granted) { fail('Could not grant POST_NOTIFICATIONS via UiAutomation'); } @@ -148,25 +186,30 @@ void main() { AuthorizationStatus.authorized, ); }, - skip: defaultTargetPlatform != TargetPlatform.android, + skip: skipNonAndroid, ); }); group('requestPermission', () { test( - 'authorizationStatus returns AuthorizationStatus.authorized on Android', + 'authorizationStatus returns AuthorizationStatus.authorized on Android 13+', () async { + if (!isAndroid13Plus) { + markTestSkipped('Requires Android API 33+'); + return; + } // Pre-grant the permission so requestPermission() returns // authorized without showing a system dialog. - await grantAndroidPermission( - 'android.permission.POST_NOTIFICATIONS', - ); + 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: defaultTargetPlatform != TargetPlatform.android, + skip: skipNonAndroid, ); }); From 0728f88052e7b448835774129001b761d8c2cac1 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Mon, 3 Aug 2026 10:17:26 +0200 Subject: [PATCH 5/6] fix(messaging): use local functions in Android permission e2e helpers Getters are not valid inside the group() callback body, which broke dart analyze in the tests package. --- .../firebase_messaging_e2e_test.dart | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/integration_test/firebase_messaging/firebase_messaging_e2e_test.dart b/tests/integration_test/firebase_messaging/firebase_messaging_e2e_test.dart index 81122aad273d..5e8b184aaae6 100644 --- a/tests/integration_test/firebase_messaging/firebase_messaging_e2e_test.dart +++ b/tests/integration_test/firebase_messaging/firebase_messaging_e2e_test.dart @@ -70,14 +70,6 @@ void main() { } }); - bool get isAndroid13Plus => - !kIsWeb && - defaultTargetPlatform == TargetPlatform.android && - (sdkInt ?? 0) >= 33; - - bool get skipNonAndroid => - kIsWeb || defaultTargetPlatform != TargetPlatform.android; - test('instance', () { expect(messaging, isA()); expect(messaging.app, isA()); @@ -134,8 +126,13 @@ void main() { }); group('getNotificationSettings', () { + bool android13Plus() => + !kIsWeb && + defaultTargetPlatform == TargetPlatform.android && + (sdkInt ?? 0) >= 33; + setUp(() async { - if (!isAndroid13Plus) { + if (!android13Plus()) { return; } // Ensure a true "never asked" state between tests and runs. @@ -149,7 +146,7 @@ void main() { test( 'returns notDetermined on Android 13+ before permission is granted', () async { - if (!isAndroid13Plus) { + if (!android13Plus()) { markTestSkipped('Requires Android API 33+'); return; } @@ -164,13 +161,13 @@ void main() { AuthorizationStatus.notDetermined, ); }, - skip: skipNonAndroid, + skip: kIsWeb || defaultTargetPlatform != TargetPlatform.android, ); test( 'returns authorized on Android 13+ after permission is granted', () async { - if (!isAndroid13Plus) { + if (!android13Plus()) { markTestSkipped('Requires Android API 33+'); return; } @@ -186,7 +183,7 @@ void main() { AuthorizationStatus.authorized, ); }, - skip: skipNonAndroid, + skip: kIsWeb || defaultTargetPlatform != TargetPlatform.android, ); }); @@ -194,6 +191,9 @@ void main() { 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; @@ -209,7 +209,7 @@ void main() { expect(result, isA()); expect(result.authorizationStatus, AuthorizationStatus.authorized); }, - skip: skipNonAndroid, + skip: kIsWeb || defaultTargetPlatform != TargetPlatform.android, ); }); From aa3cee7aa6f97dffa45281f3fe0e48b2b50a38ab Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Mon, 3 Aug 2026 10:54:05 +0200 Subject: [PATCH 6/6] fix(messaging,android): resolve permission status without hidden platform APIs PackageManager.getPermissionFlags() and the FLAG_PERMISSION_USER_SET / FLAG_PERMISSION_USER_FIXED constants are @hide/@SystemApi and are not part of the public SDK at any API level, so the plugin failed to compile. Resolve the status with public API instead: shouldShowRequestPermissionRationale() plus a SharedPreferences record of whether the prompt was ever shown. The flag is written when the permission is requested rather than only on denial, so a grant followed by a revoke from system settings still reports deniedPermanently. Also clear that record from the e2e permission reset helper so "never asked" is reproducible across tests, and fix a google-java-format violation. --- .../FlutterFirebaseMessagingPlugin.java | 79 ++++++++++--------- .../plugins/firebase/tests/MainActivity.kt | 13 +++ .../firebase_messaging_e2e_test.dart | 13 +-- 3 files changed, 61 insertions(+), 44 deletions(-) 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 fdacaad1cb04..7cb9423f0abc 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 @@ -10,9 +10,9 @@ 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.Process; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @@ -366,6 +366,10 @@ private Task> getInitialMessage() { 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<>(); @@ -379,6 +383,10 @@ private Task> requestPermissions() { permissionManager.requestPermissions( mainActivity, (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 = @@ -410,18 +418,34 @@ 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. * - *

Uses {@link PackageManager#getPermissionFlags} so the result is correct even when another - * plugin requested {@code POST_NOTIFICATIONS} (no SharedPreferences bookkeeping). + *

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 (can show rationale) → denied (0) - *
  • user-fixed / no-rationale after a decision → deniedPermanently (3) + *
  • 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() { @@ -429,43 +453,21 @@ private int resolveNotificationAuthorizationStatus() { return AUTH_AUTHORIZED; } - Context context = ContextHolder.getApplicationContext(); - int flags = - context - .getPackageManager() - .getPermissionFlags( - Manifest.permission.POST_NOTIFICATIONS, - context.getPackageName(), - Process.myUserHandle()); - - boolean userFixed = (flags & PackageManager.FLAG_PERMISSION_USER_FIXED) != 0; - boolean userSet = (flags & PackageManager.FLAG_PERMISSION_USER_SET) != 0; - - if (userFixed) { - return AUTH_DENIED_PERMANENTLY; - } - - boolean shouldShowRationale = - mainActivity != null - && ActivityCompat.shouldShowRequestPermissionRationale( - mainActivity, Manifest.permission.POST_NOTIFICATIONS); - - if (shouldShowRationale) { - // Denied once; the OS will still show another prompt. + 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 (userSet) { - // User already decided and rationale is not shown. Without an Activity we cannot - // confirm permanence via shouldShowRationale, so keep this as soft denied so callers - // may still attempt requestPermission() when an Activity is available. - if (mainActivity == null) { - return AUTH_DENIED; - } - return AUTH_DENIED_PERMANENTLY; + if (!getPermissionsPreferences().getBoolean(KEY_PERMISSION_REQUESTED, false)) { + return AUTH_NOT_DETERMINED; } - 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() { @@ -482,8 +484,7 @@ private Task> getPermissions() { NotificationManagerCompat.from(ContextHolder.getApplicationContext()) .areNotificationsEnabled(); permissions.put( - "authorizationStatus", - areNotificationsEnabled ? AUTH_AUTHORIZED : AUTH_DENIED); + "authorizationStatus", areNotificationsEnabled ? AUTH_AUTHORIZED : AUTH_DENIED); } taskCompletionSource.setResult(permissions); } catch (Exception e) { 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 d402681a086b..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,6 @@ package io.flutter.plugins.firebase.tests +import android.content.Context import android.os.Build import android.os.ParcelFileDescriptor import io.flutter.embedding.android.FlutterActivity @@ -57,6 +58,12 @@ class MainActivity : FlutterActivity() { 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) @@ -99,4 +106,10 @@ class MainActivity : FlutterActivity() { 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/tests/integration_test/firebase_messaging/firebase_messaging_e2e_test.dart b/tests/integration_test/firebase_messaging/firebase_messaging_e2e_test.dart index 5e8b184aaae6..ce1cc7089ca4 100644 --- a/tests/integration_test/firebase_messaging/firebase_messaging_e2e_test.dart +++ b/tests/integration_test/firebase_messaging/firebase_messaging_e2e_test.dart @@ -35,13 +35,16 @@ Future grantAndroidPermission(String permission) async { } } -/// Revokes [permission] and clears user-set/user-fixed flags so Android -/// reports the permission as never asked again. +/// 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 { - return await _permissionsChannel - .invokeMethod('resetPermission', {'permission': permission}) ?? - false; + final reset = await _permissionsChannel.invokeMethod( + 'resetPermission', + {'permission': permission}, + ); + return reset ?? false; } catch (_) { return false; }