Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -367,6 +370,15 @@ private Task<Map<String, Integer>> 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);
},
Expand Down Expand Up @@ -399,14 +411,51 @@ private Task<Map<String, Integer>> getPermissions() {
() -> {
try {
final Map<String, Integer> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ void main() {
};
case 'Messaging#hasPermission':
case 'Messaging#requestPermission':
case 'Messaging#getNotificationSettings':
return {
'authorizationStatus': 1,
'alert': 1,
Expand Down Expand Up @@ -168,6 +169,90 @@ void main() {
]);
});

test('getNotificationSettings', () async {
final settings = await messaging.getNotificationSettings();
expect(settings, isA<NotificationSettings>());
expect(
settings.authorizationStatus, equals(AuthorizationStatus.authorized));

// check native method was called
expect(log, <Matcher>[
isMethodCall(
'Messaging#getNotificationSettings',
arguments: <String, dynamic>{
'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 <String, dynamic>{};
});

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 <String, dynamic>{};
}
});
});

test('requestPermission', () async {
// test android response
final androidPermissions = await messaging.requestPermission();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>("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<String>("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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> grantAndroidPermission(String permission) async {
try {
return await _permissionsChannel
.invokeMethod<bool>('grant', {'permission': permission}) ??
false;
} catch (_) {
return false;
}
}

Future<bool> clearSharedPrefs([String name = 'FlutterFirebaseMessaging']) async {
try {
return await _permissionsChannel
.invokeMethod<bool>('clearSharedPrefs', {'name': name}) ??
false;
} catch (_) {
return false;
}
}

// ignore: do_not_use_environment
const bool skipTestsOnCI = bool.fromEnvironment('CI');

Expand Down Expand Up @@ -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<NotificationSettings>());
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<NotificationSettings>());
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<NotificationSettings>());
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,
);
});

Expand Down
Loading