From 6991243922da7314236c187b8470c3a2d5fb1390 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Mon, 9 Mar 2026 15:43:21 +0100 Subject: [PATCH 1/3] 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/3] 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/3] 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".