Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand Down
89 changes: 87 additions & 2 deletions packages/react-native/__tests__/NotifeeApiModule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -298,13 +346,15 @@ describe('Notifee Api Module', () => {
authorizationStatus: AuthorizationStatus.AUTHORIZED,
android: {
alarm: AndroidNotificationSetting.DISABLED,
fullScreenIntent: AndroidNotificationSetting.ENABLED,
},
});
const settings = await apiModule.getNotificationSettings();
expect(settings).toEqual({
authorizationStatus: AuthorizationStatus.AUTHORIZED,
android: {
alarm: 0,
fullScreenIntent: 1,
},
ios: {
alert: 1,
Expand All @@ -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: {
Expand All @@ -352,6 +419,7 @@ describe('Notifee Api Module', () => {
authorizationStatus: AuthorizationStatus.NOT_DETERMINED,
android: {
alarm: AndroidNotificationSetting.ENABLED,
fullScreenIntent: AndroidNotificationSetting.ENABLED,
},
ios: {
alert: 1,
Expand All @@ -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();
});
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -508,6 +509,14 @@ public void getNotificationSettings(MethodCallResult<Bundle> 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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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();
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
1 change: 1 addition & 0 deletions packages/react-native/jest-mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export const testNotificationSettings = {
authorizationStatus: Notification.AuthorizationStatus.AUTHORIZED,
android: {
alarm: NotificationAndroid.AndroidNotificationSetting.ENABLED,
fullScreenIntent: NotificationAndroid.AndroidNotificationSetting.ENABLED,
},
ios: {
alert: true,
Expand Down
4 changes: 4 additions & 0 deletions packages/react-native/src/NotifeeApiModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,7 @@ export default class NotifeeApiModule extends NotifeeNativeModule implements Mod
ios,
android: {
alarm: AndroidNotificationSetting.ENABLED,
fullScreenIntent: AndroidNotificationSetting.ENABLED,
},
web: {},
};
Expand All @@ -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,
Expand Down Expand Up @@ -725,6 +727,7 @@ export default class NotifeeApiModule extends NotifeeNativeModule implements Mod
ios,
android: {
alarm: AndroidNotificationSetting.ENABLED,
fullScreenIntent: AndroidNotificationSetting.ENABLED,
},
web: {},
};
Expand All @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions packages/react-native/src/types/NotificationAndroid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
Loading