diff --git a/CHANGELOG.md b/CHANGELOG.md index 24a2e8de..3ee361f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- **Android**: `getNotificationSettings()` now reports `android.fullScreenIntent`, an `AndroidNotificationSetting` describing whether a notification posted with a `fullScreenAction` will actually be shown full screen. On Android 14 / API 34 and above, `USE_FULL_SCREEN_INTENT` is a user-revocable special app access that the Play Store revokes on install for apps outside the calling and alarm categories — and a denial was previously undetectable, because the notification still posts and no error is raised. Below API 34 the value is always `ENABLED`, matching the existing `android.alarm` field. + ## [10.5.0] - 2026-07-24 ### Fixed diff --git a/packages/react-native/__tests__/NotifeeApiModule.test.ts b/packages/react-native/__tests__/NotifeeApiModule.test.ts index 01fd677e..cb25c053 100644 --- a/packages/react-native/__tests__/NotifeeApiModule.test.ts +++ b/packages/react-native/__tests__/NotifeeApiModule.test.ts @@ -298,6 +298,7 @@ describe('Notifee Api Module', () => { authorizationStatus: AuthorizationStatus.AUTHORIZED, android: { alarm: AndroidNotificationSetting.DISABLED, + fullScreenIntent: AndroidNotificationSetting.ENABLED, }, }); const settings = await apiModule.getNotificationSettings(); @@ -305,6 +306,7 @@ describe('Notifee Api Module', () => { authorizationStatus: AuthorizationStatus.AUTHORIZED, android: { alarm: 0, + fullScreenIntent: 1, }, ios: { alert: 1, @@ -322,6 +324,23 @@ describe('Notifee Api Module', () => { web: {}, }); }); + + test('passes a denied fullScreenIntent through from the native module', async () => { + mockNotifeeNativeModule.getNotificationSettings.mockResolvedValue({ + authorizationStatus: AuthorizationStatus.AUTHORIZED, + android: { + alarm: AndroidNotificationSetting.ENABLED, + fullScreenIntent: AndroidNotificationSetting.DISABLED, + }, + }); + + const settings = await apiModule.getNotificationSettings(); + + expect(settings.android).toEqual({ + alarm: AndroidNotificationSetting.ENABLED, + fullScreenIntent: AndroidNotificationSetting.DISABLED, + }); + }); }); describe('on iOS', () => { @@ -352,6 +371,7 @@ describe('Notifee Api Module', () => { authorizationStatus: AuthorizationStatus.NOT_DETERMINED, android: { alarm: AndroidNotificationSetting.ENABLED, + fullScreenIntent: AndroidNotificationSetting.ENABLED, }, ios: { alert: 1, diff --git a/packages/react-native/android/src/main/java/app/notifee/core/Notifee.java b/packages/react-native/android/src/main/java/app/notifee/core/Notifee.java index d70526e7..f59478a9 100644 --- a/packages/react-native/android/src/main/java/app/notifee/core/Notifee.java +++ b/packages/react-native/android/src/main/java/app/notifee/core/Notifee.java @@ -36,6 +36,7 @@ import app.notifee.core.model.ChannelModel; import app.notifee.core.model.NotificationModel; import app.notifee.core.utility.AlarmUtils; +import app.notifee.core.utility.FullScreenIntentUtils; import app.notifee.core.utility.PowerManagerUtils; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; @@ -508,6 +509,14 @@ public void getNotificationSettings(MethodCallResult result) { androidSettingsBundle.putInt("alarm", 0); } + boolean canUseFullScreenIntent = FullScreenIntentUtils.canUseFullScreenIntent(); + + if (canUseFullScreenIntent) { + androidSettingsBundle.putInt("fullScreenIntent", 1); + } else { + androidSettingsBundle.putInt("fullScreenIntent", 0); + } + notificationSettingsBundle.putBundle("android", androidSettingsBundle); result.onComplete(null, notificationSettingsBundle); } diff --git a/packages/react-native/android/src/main/java/app/notifee/core/utility/FullScreenIntentUtils.java b/packages/react-native/android/src/main/java/app/notifee/core/utility/FullScreenIntentUtils.java new file mode 100644 index 00000000..63cd557a --- /dev/null +++ b/packages/react-native/android/src/main/java/app/notifee/core/utility/FullScreenIntentUtils.java @@ -0,0 +1,71 @@ +package app.notifee.core.utility; + +/* + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import static app.notifee.core.ContextHolder.getApplicationContext; + +import android.app.NotificationManager; +import android.content.Context; +import android.os.Build; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +public class FullScreenIntentUtils { + + @Nullable + private static NotificationManager getNotificationManager() { + Context context = getApplicationContext(); + if (context == null) { + return null; + } + return (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + } + + /** + * Whether a notification posted with a full screen action will actually be shown full screen. + * + *

On Android 14 / API 34 and above, {@code USE_FULL_SCREEN_INTENT} is a user-revocable special + * app access rather than a normal install-time permission: the Play Store revokes it on install + * for apps outside the calling and alarm categories, and the user can toggle it at any time. When + * it is denied the notification still posts normally and nothing throws — only the full screen + * presentation is dropped — so this is the sole reliable way to detect it. + * + *

Below API 34 the manifest permission is granted at install and always honoured. + * + * @return true when a full screen intent will be honoured, false when the user or the system has + * denied it. + */ + public static boolean canUseFullScreenIntent() { + return canUseFullScreenIntent(getNotificationManager()); + } + + @VisibleForTesting + static boolean canUseFullScreenIntent(@Nullable NotificationManager notificationManager) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + return true; + } + + // Fail open: an absent NotificationManager tells us nothing, and reporting a denial we + // cannot prove would have apps nag the user about a setting that may well be granted. + if (notificationManager == null) { + return true; + } + + return notificationManager.canUseFullScreenIntent(); + } +} diff --git a/packages/react-native/android/src/test/java/app/notifee/core/utility/FullScreenIntentUtilsTest.java b/packages/react-native/android/src/test/java/app/notifee/core/utility/FullScreenIntentUtilsTest.java new file mode 100644 index 00000000..90d32932 --- /dev/null +++ b/packages/react-native/android/src/test/java/app/notifee/core/utility/FullScreenIntentUtilsTest.java @@ -0,0 +1,86 @@ +package app.notifee.core.utility; + +/* + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.app.NotificationManager; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * Unit coverage for {@link FullScreenIntentUtils#canUseFullScreenIntent(NotificationManager)}, + * which backs the {@code android.fullScreenIntent} field of {@code getNotificationSettings()}. + * + *

Android 14 / API 34 turned {@code USE_FULL_SCREEN_INTENT} into a user-revocable special app + * access. The interesting behavior is therefore the API level boundary, so each case pins the SDK + * with {@link Config} and injects the {@link NotificationManager} rather than relying on a shadow — + * Robolectric does not implement {@code canUseFullScreenIntent()}. + */ +@RunWith(RobolectricTestRunner.class) +public class FullScreenIntentUtilsTest { + + @Test + @Config(sdk = 33) + public void returnsTrueBelowApi34WithoutConsultingNotificationManager() { + // Deliberately unstubbed: an unstubbed boolean mock answers false, so a true result proves + // the version guard short-circuited. The method is not referenced by name here because it + // does not exist on the API 33 android-all jar Robolectric loads for this case. + NotificationManager notificationManager = mock(NotificationManager.class); + + assertTrue(FullScreenIntentUtils.canUseFullScreenIntent(notificationManager)); + } + + @Test + @Config(sdk = 34) + public void returnsTrueOnApi34WhenGranted() { + NotificationManager notificationManager = mock(NotificationManager.class); + when(notificationManager.canUseFullScreenIntent()).thenReturn(true); + + assertTrue(FullScreenIntentUtils.canUseFullScreenIntent(notificationManager)); + } + + @Test + @Config(sdk = 34) + public void returnsFalseOnApi34WhenDenied() { + NotificationManager notificationManager = mock(NotificationManager.class); + when(notificationManager.canUseFullScreenIntent()).thenReturn(false); + + assertFalse(FullScreenIntentUtils.canUseFullScreenIntent(notificationManager)); + } + + @Test + @Config(sdk = 35) + public void keepsConsultingNotificationManagerAboveApi34() { + // Guards against the version check regressing into an equality test against 34. + NotificationManager notificationManager = mock(NotificationManager.class); + when(notificationManager.canUseFullScreenIntent()).thenReturn(false); + + assertFalse(FullScreenIntentUtils.canUseFullScreenIntent(notificationManager)); + } + + @Test + @Config(sdk = 34) + public void failsOpenWhenNotificationManagerIsUnavailable() { + assertTrue(FullScreenIntentUtils.canUseFullScreenIntent(null)); + } +} diff --git a/packages/react-native/jest-mock.js b/packages/react-native/jest-mock.js index ecf12482..91246e51 100644 --- a/packages/react-native/jest-mock.js +++ b/packages/react-native/jest-mock.js @@ -83,6 +83,7 @@ export const testNotificationSettings = { authorizationStatus: Notification.AuthorizationStatus.AUTHORIZED, android: { alarm: NotificationAndroid.AndroidNotificationSetting.ENABLED, + fullScreenIntent: NotificationAndroid.AndroidNotificationSetting.ENABLED, }, ios: { alert: true, diff --git a/packages/react-native/src/NotifeeApiModule.ts b/packages/react-native/src/NotifeeApiModule.ts index 99b78799..c0932e42 100644 --- a/packages/react-native/src/NotifeeApiModule.ts +++ b/packages/react-native/src/NotifeeApiModule.ts @@ -602,6 +602,7 @@ export default class NotifeeApiModule extends NotifeeNativeModule implements Mod ios, android: { alarm: AndroidNotificationSetting.ENABLED, + fullScreenIntent: AndroidNotificationSetting.ENABLED, }, web: {}, }; @@ -614,6 +615,7 @@ export default class NotifeeApiModule extends NotifeeNativeModule implements Mod authorizationStatus: AuthorizationStatus.NOT_DETERMINED, android: { alarm: AndroidNotificationSetting.ENABLED, + fullScreenIntent: AndroidNotificationSetting.ENABLED, }, ios: { alert: 1, @@ -725,6 +727,7 @@ export default class NotifeeApiModule extends NotifeeNativeModule implements Mod ios, android: { alarm: AndroidNotificationSetting.ENABLED, + fullScreenIntent: AndroidNotificationSetting.ENABLED, }, web: {}, }; @@ -737,6 +740,7 @@ export default class NotifeeApiModule extends NotifeeNativeModule implements Mod authorizationStatus: AuthorizationStatus.NOT_DETERMINED, android: { alarm: AndroidNotificationSetting.ENABLED, + fullScreenIntent: AndroidNotificationSetting.ENABLED, }, ios: { alert: 1, diff --git a/packages/react-native/src/types/NotificationAndroid.ts b/packages/react-native/src/types/NotificationAndroid.ts index ced6c8f8..f35bb377 100644 --- a/packages/react-native/src/types/NotificationAndroid.ts +++ b/packages/react-native/src/types/NotificationAndroid.ts @@ -501,6 +501,21 @@ export interface AndroidNotificationSettings { * View the [Trigger](/react-native/android/triggers#android-12-limitations) documentation for more information. */ alarm: AndroidNotificationSetting; + + /** + * Enum describing if a notification with a `fullScreenAction` will actually be shown full screen. + * + * For Android < 14 / API < 34, this will default to enabled. + * + * On Android 14 / API 34 and above, `USE_FULL_SCREEN_INTENT` is a user-revocable special app + * access rather than a normal install-time permission. It is revoked on install for apps outside + * the calling and alarm categories, and the user can toggle it at any time. While it is disabled + * the notification still posts and no error is raised — only the full screen presentation is + * dropped, so this setting is the only way to detect it. + * + * Send the user to `Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT` to have it granted. + */ + fullScreenIntent: AndroidNotificationSetting; } /**