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 @@ -357,6 +360,16 @@ private Task<Map<String, Object>> 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<Map<String, Integer>> requestPermissions() {
TaskCompletionSource<Map<String, Integer>> taskCompletionSource = new TaskCompletionSource<>();
Expand All @@ -369,14 +382,24 @@ private Task<Map<String, Integer>> 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);
}

Expand All @@ -395,21 +418,74 @@ 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.
*
* <p>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.
*
* <ul>
* <li>granted → authorized (1)
* <li>never asked → notDetermined (-1)
* <li>soft deny (rationale can be shown) → denied (0)
* <li>asked before, no rationale → deniedPermanently (3)
* </ul>
*
* <p>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<Map<String, Integer>> getPermissions() {
TaskCompletionSource<Map<String, Integer>> taskCompletionSource = new TaskCompletionSource<>();

cachedThreadPool.execute(
() -> {
try {
final Map<String, Integer> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
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,156 @@ 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(
'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 <String, dynamic>{};
});

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 <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
Expand Up @@ -135,6 +135,8 @@ void main() {
expect(convertToAuthorizationStatus(1), AuthorizationStatus.authorized);
expect(
convertToAuthorizationStatus(2), AuthorizationStatus.provisional);
expect(convertToAuthorizationStatus(3),
AuthorizationStatus.deniedPermanently);
});

test(
Expand All @@ -143,7 +145,7 @@ void main() {
expect(convertToAuthorizationStatus(-2),
AuthorizationStatus.notDetermined);
expect(
convertToAuthorizationStatus(3), AuthorizationStatus.notDetermined);
convertToAuthorizationStatus(4), AuthorizationStatus.notDetermined);
});

test(
Expand Down
Loading
Loading