diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aa0c240..2e63ed76 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().android.fullScreenIntent` now reports whether the app currently has the Android access required to use full-screen intents. API < 29 reports `ENABLED`, API 29–33 reflects `USE_FULL_SCREEN_INTENT`, and API 34+ reflects Android's full-screen intent special app access. `ENABLED` does not guarantee that a particular notification will be presented full-screen. + ### Fixed - **Android**: fixed a failure path that could drop `MessagingStyle` notifications when `Person` resolution timed out or an icon could not be loaded or created. These recoverable failures now use an icon-less fallback that preserves the remaining `Person` fields and allows notification construction to continue. @@ -24,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - **Android**: added deterministic regression coverage for `MessagingStyle` `Person` timeouts and icon failures, verifying an icon-less fallback that preserves the remaining `Person` fields while unexpected person-construction failures continue to propagate. - **Android**: added deterministic Robolectric regression coverage for Fresco bitmap ownership, verifying that the source bitmap can be released while the bitmap returned by `ResourceUtils` remains independent and usable. +- **Android**: added Robolectric coverage for full-screen intent access before API 29, permission grant and denial on API 29–33, and platform access results on API 34+. - **Android**: added deterministic Robolectric regression coverage for `ReceiverService` `PendingIntent` identity and a physical-device smoke harness covering a notification surviving a process restart, a second notification posted from the new process, real action-button presses, and real dismiss routing; the device scenarios were validated on a Pixel 9 Pro XL running Android 17/API 37. diff --git a/packages/react-native/__tests__/NotifeeApiModule.test.ts b/packages/react-native/__tests__/NotifeeApiModule.test.ts index 01fd677e..54ca0681 100644 --- a/packages/react-native/__tests__/NotifeeApiModule.test.ts +++ b/packages/react-native/__tests__/NotifeeApiModule.test.ts @@ -287,6 +287,54 @@ describe('Notifee Api Module', () => { }); }); + describe('requestPermission', () => { + test('passes Android full-screen intent access through from the native module', async () => { + setPlatform('android'); + mockNotifeeNativeModule.requestPermission.mockResolvedValue({ + authorizationStatus: AuthorizationStatus.AUTHORIZED, + android: { + alarm: AndroidNotificationSetting.ENABLED, + fullScreenIntent: AndroidNotificationSetting.DISABLED, + }, + }); + + const settings = await apiModule.requestPermission(); + + expect(settings.android).toEqual({ + alarm: AndroidNotificationSetting.ENABLED, + fullScreenIntent: AndroidNotificationSetting.DISABLED, + }); + }); + + test('includes Android full-screen intent defaults on iOS', async () => { + setPlatform('ios'); + mockNotifeeNativeModule.requestPermission.mockResolvedValue({ + authorizationStatus: AuthorizationStatus.AUTHORIZED, + ios: {}, + }); + + const settings = await apiModule.requestPermission(); + + expect(settings.android).toEqual({ + alarm: AndroidNotificationSetting.ENABLED, + fullScreenIntent: AndroidNotificationSetting.ENABLED, + }); + expect(mockNotifeeNativeModule.requestPermission).toBeCalledTimes(1); + }); + + test('includes Android full-screen intent defaults on web', async () => { + setPlatform('web'); + + const settings = await apiModule.requestPermission(); + + expect(settings.android).toEqual({ + alarm: AndroidNotificationSetting.ENABLED, + fullScreenIntent: AndroidNotificationSetting.ENABLED, + }); + expect(mockNotifeeNativeModule.requestPermission).not.toBeCalled(); + }); + }); + describe('getNotificationSettings', () => { describe('on Android', () => { beforeEach(() => { @@ -298,6 +346,7 @@ describe('Notifee Api Module', () => { authorizationStatus: AuthorizationStatus.AUTHORIZED, android: { alarm: AndroidNotificationSetting.DISABLED, + fullScreenIntent: AndroidNotificationSetting.ENABLED, }, }); const settings = await apiModule.getNotificationSettings(); @@ -305,6 +354,7 @@ describe('Notifee Api Module', () => { authorizationStatus: AuthorizationStatus.AUTHORIZED, android: { alarm: 0, + fullScreenIntent: 1, }, ios: { alert: 1, @@ -322,14 +372,31 @@ 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', () => { beforeEach(() => { - setPlatform('iOS'); + setPlatform('ios'); }); - test('return web settings with AndroidNotificationSettings set to default values', async () => { + test('returns iOS settings with AndroidNotificationSettings set to default values', async () => { mockNotifeeNativeModule.getNotificationSettings.mockResolvedValue({ authorizationStatus: AuthorizationStatus.NOT_DETERMINED, ios: { @@ -352,6 +419,7 @@ describe('Notifee Api Module', () => { authorizationStatus: AuthorizationStatus.NOT_DETERMINED, android: { alarm: AndroidNotificationSetting.ENABLED, + fullScreenIntent: AndroidNotificationSetting.ENABLED, }, ios: { alert: 1, @@ -368,6 +436,23 @@ describe('Notifee Api Module', () => { }, web: {}, }); + expect(mockNotifeeNativeModule.getNotificationSettings).toBeCalledTimes(1); + }); + }); + + describe('on web', () => { + beforeEach(() => { + setPlatform('web'); + }); + + test('returns AndroidNotificationSettings default values', async () => { + const settings = await apiModule.getNotificationSettings(); + + expect(settings.android).toEqual({ + alarm: AndroidNotificationSetting.ENABLED, + fullScreenIntent: AndroidNotificationSetting.ENABLED, + }); + expect(mockNotifeeNativeModule.getNotificationSettings).not.toBeCalled(); }); }); }); 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..32f9a9d6 --- /dev/null +++ b/packages/react-native/android/src/main/java/app/notifee/core/utility/FullScreenIntentUtils.java @@ -0,0 +1,53 @@ +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.content.Context; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.core.app.NotificationManagerCompat; + +public class FullScreenIntentUtils { + + /** + * Whether the app currently has the Android access required to use full-screen intents. + * + *

API levels below 29 do not use the full-screen intent permission model. API levels 29 + * through 33 require {@code USE_FULL_SCREEN_INTENT}. API level 34 and above use Android's + * full-screen intent special app access. + * + * @return true when the required access is currently available. This does not guarantee that a + * particular notification will be presented full-screen. + */ + public static boolean canUseFullScreenIntent() { + return canUseFullScreenIntent(getApplicationContext()); + } + + @VisibleForTesting + static boolean canUseFullScreenIntent(@Nullable Context context) { + // The application context is expected to exist on the public path. Preserve the existing + // defensive fail-open behavior for an internal state that cannot prove access is denied. + if (context == null) { + return true; + } + + return NotificationManagerCompat.from(context).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..9d38f7ac --- /dev/null +++ b/packages/react-native/android/src/test/java/app/notifee/core/utility/FullScreenIntentUtilsTest.java @@ -0,0 +1,83 @@ +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.Manifest; +import android.app.NotificationManager; +import android.content.Context; +import android.content.pm.PackageManager; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * Unit coverage for the cross-version access contract backing the {@code android.fullScreenIntent} + * field of {@code getNotificationSettings()}. + */ +@RunWith(RobolectricTestRunner.class) +public class FullScreenIntentUtilsTest { + + @Test + @Config(sdk = 28) + public void returnsTrueBelowApi29WithoutPermission() { + Context context = mock(Context.class); + when(context.checkSelfPermission(Manifest.permission.USE_FULL_SCREEN_INTENT)) + .thenReturn(PackageManager.PERMISSION_DENIED); + + assertTrue(FullScreenIntentUtils.canUseFullScreenIntent(context)); + } + + @Test + @Config(sdk = 33) + public void reflectsPermissionAndRereadsItFromApi29Through33() { + Context context = mock(Context.class); + when(context.checkSelfPermission(Manifest.permission.USE_FULL_SCREEN_INTENT)) + .thenReturn(PackageManager.PERMISSION_GRANTED, PackageManager.PERMISSION_DENIED); + + assertTrue(FullScreenIntentUtils.canUseFullScreenIntent(context)); + assertFalse(FullScreenIntentUtils.canUseFullScreenIntent(context)); + } + + @Test + @Config(sdk = 34) + public void reflectsAccessChangesOnApi34() { + Context context = mock(Context.class); + NotificationManager notificationManager = mock(NotificationManager.class); + when(context.getSystemService(Context.NOTIFICATION_SERVICE)).thenReturn(notificationManager); + when(notificationManager.canUseFullScreenIntent()).thenReturn(true, false); + + assertTrue(FullScreenIntentUtils.canUseFullScreenIntent(context)); + assertFalse(FullScreenIntentUtils.canUseFullScreenIntent(context)); + } + + @Test + @Config(sdk = 35) + public void keepsConsultingNotificationManagerAboveApi34() { + Context context = mock(Context.class); + NotificationManager notificationManager = mock(NotificationManager.class); + when(context.getSystemService(Context.NOTIFICATION_SERVICE)).thenReturn(notificationManager); + when(notificationManager.canUseFullScreenIntent()).thenReturn(true); + + assertTrue(FullScreenIntentUtils.canUseFullScreenIntent(context)); + } +} 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 e032b840..03be37fc 100644 --- a/packages/react-native/src/types/NotificationAndroid.ts +++ b/packages/react-native/src/types/NotificationAndroid.ts @@ -501,6 +501,17 @@ export interface AndroidNotificationSettings { * View the [Trigger](/react-native/android/triggers#android-12-limitations) documentation for more information. */ alarm: AndroidNotificationSetting; + + /** + * Enum describing whether the app currently has the Android access required to use full-screen intents. + * + * API < 29 reports `ENABLED` because the later permission model does not apply. API 29–33 reflects + * `USE_FULL_SCREEN_INTENT`. API 34+ reflects Android's full-screen intent special app access, + * which may be restricted or revoked by the system, installer, or user. + * + * `ENABLED` does not guarantee that a particular notification will be presented full-screen. + */ + fullScreenIntent?: AndroidNotificationSetting; } /**