收紧移动壳角标真实结果

移动 iOS 角标改为请求 badge 权限后调用 Expo Notifications

角标设置以 setBadgeCountAsync 返回值作为成功依据

测试和门禁覆盖权限拒绝与系统拒绝设置失败

文档和项目记忆同步角标能力边界
This commit is contained in:
2026-06-20 18:00:51 +08:00
parent 33e56fb56f
commit ae3cdf4e69
8 changed files with 335 additions and 78 deletions
+166 -45
View File
@@ -1,4 +1,5 @@
import { Platform, PushNotificationIOS } from 'react-native';
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import {
@@ -9,13 +10,41 @@ import {
} from '../../../../packages/shared/src/contracts/hostBridge';
import { setMobileAppBadgeCount } from './badge';
type NotificationPermissionStatus =
Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>;
const BADGE_GRANTED_PERMISSION = {
status: 'granted',
granted: true,
canAskAgain: true,
expires: 'never',
ios: {
status: 2,
allowsBadge: true,
},
} as unknown as NotificationPermissionStatus;
const BADGE_DENIED_PERMISSION = {
status: 'denied',
granted: false,
canAskAgain: false,
expires: 'never',
ios: {
status: 1,
allowsBadge: false,
},
} as unknown as NotificationPermissionStatus;
vi.mock('expo-notifications', () => ({
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
setBadgeCountAsync: vi.fn(),
}));
vi.mock('react-native', () => ({
Platform: {
OS: 'ios',
},
PushNotificationIOS: {
setApplicationIconBadgeNumber: vi.fn(),
},
}));
function request(payload?: unknown): HostBridgeRequest {
@@ -34,13 +63,22 @@ function setPlatformOS(os: 'ios' | 'android') {
beforeEach(() => {
setPlatformOS('ios');
vi.mocked(PushNotificationIOS.setApplicationIconBadgeNumber).mockReset();
vi.mocked(Notifications.getPermissionsAsync).mockReset();
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
BADGE_GRANTED_PERMISSION,
);
vi.mocked(Notifications.requestPermissionsAsync).mockReset();
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
BADGE_GRANTED_PERMISSION,
);
vi.mocked(Notifications.setBadgeCountAsync).mockReset();
vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(true);
});
describe('mobile app badge helper', () => {
test('sets and clears the iOS app badge count through PushNotificationIOS', () => {
test('sets and clears the iOS app badge count through Expo Notifications', async () => {
expect(
setMobileAppBadgeCount(
await setMobileAppBadgeCount(
request({
count: 12,
}),
@@ -52,12 +90,11 @@ describe('mobile app badge helper', () => {
ok: true,
result: true,
});
expect(
PushNotificationIOS.setApplicationIconBadgeNumber,
).toHaveBeenLastCalledWith(12);
expect(Notifications.setBadgeCountAsync).toHaveBeenLastCalledWith(12);
expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled();
expect(
setMobileAppBadgeCount(
await setMobileAppBadgeCount(
request({
count: 0,
}),
@@ -66,60 +103,144 @@ describe('mobile app badge helper', () => {
ok: true,
result: true,
});
expect(
PushNotificationIOS.setApplicationIconBadgeNumber,
).toHaveBeenLastCalledWith(0);
expect(Notifications.setBadgeCountAsync).toHaveBeenLastCalledWith(0);
});
test('rejects invalid badge counts before touching the system badge', () => {
for (const count of [-1, 1.5, HOST_BRIDGE_BADGE_COUNT_MAX + 1]) {
expect(() =>
setMobileAppBadgeCount(
request({
count,
}),
),
).toThrowError(
`count must be an integer between 0 and ${HOST_BRIDGE_BADGE_COUNT_MAX}`,
);
}
expect(
PushNotificationIOS.setApplicationIconBadgeNumber,
).not.toHaveBeenCalled();
});
test('rejects missing badge payload before touching the system badge', () => {
expect(() => setMobileAppBadgeCount(request({}))).toThrowError(
`count must be an integer between 0 and ${HOST_BRIDGE_BADGE_COUNT_MAX}`,
test('requests iOS badge permission before updating when it is missing', async () => {
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
BADGE_DENIED_PERMISSION,
);
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
BADGE_GRANTED_PERMISSION,
);
expect(
PushNotificationIOS.setApplicationIconBadgeNumber,
).not.toHaveBeenCalled();
await expect(
setMobileAppBadgeCount(
request({
count: 3,
}),
),
).resolves.toMatchObject({
ok: true,
result: true,
});
expect(Notifications.requestPermissionsAsync).toHaveBeenCalledWith({
ios: {
allowAlert: false,
allowBadge: true,
allowSound: false,
},
});
expect(Notifications.setBadgeCountAsync).toHaveBeenCalledWith(3);
});
test('returns unsupported on Android before validating payload or touching badge APIs', () => {
setPlatformOS('android');
test('rejects denied iOS badge permission before touching the system badge', async () => {
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
BADGE_DENIED_PERMISSION,
);
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
BADGE_DENIED_PERMISSION,
);
expect(() =>
await expect(
setMobileAppBadgeCount(
request({
count: 1,
}),
),
).toThrowError('app badge count is only supported on iOS mobile shell');
).rejects.toMatchObject({
code: 'host_error',
message: 'app badge permission denied',
});
expect(() =>
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
});
test('maps native badge update false results to a stable HostBridge error', async () => {
vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(false);
await expect(
setMobileAppBadgeCount(
request({
count: 1,
}),
),
).rejects.toMatchObject({
code: 'host_error',
message: 'app badge update unavailable',
});
});
test('maps native badge update rejections to a stable HostBridge error', async () => {
vi.mocked(Notifications.setBadgeCountAsync).mockRejectedValue(
new Error('native badge failed'),
);
await expect(
setMobileAppBadgeCount(
request({
count: 1,
}),
),
).rejects.toMatchObject({
code: 'host_error',
message: 'app badge update unavailable',
});
});
test('rejects invalid badge counts before touching the system badge', async () => {
for (const count of [-1, 1.5, HOST_BRIDGE_BADGE_COUNT_MAX + 1]) {
await expect(
setMobileAppBadgeCount(
request({
count,
}),
),
).rejects.toThrowError(
`count must be an integer between 0 and ${HOST_BRIDGE_BADGE_COUNT_MAX}`,
);
}
expect(
Notifications.setBadgeCountAsync,
).not.toHaveBeenCalled();
expect(Notifications.getPermissionsAsync).not.toHaveBeenCalled();
});
test('rejects missing badge payload before touching the system badge', async () => {
await expect(setMobileAppBadgeCount(request({}))).rejects.toThrowError(
`count must be an integer between 0 and ${HOST_BRIDGE_BADGE_COUNT_MAX}`,
);
expect(
Notifications.setBadgeCountAsync,
).not.toHaveBeenCalled();
expect(Notifications.getPermissionsAsync).not.toHaveBeenCalled();
});
test('returns unsupported on Android before validating payload or touching badge APIs', async () => {
setPlatformOS('android');
await expect(
setMobileAppBadgeCount(
request({
count: 1,
}),
),
).rejects.toThrowError('app badge count is only supported on iOS mobile shell');
await expect(
setMobileAppBadgeCount(
request({
count: HOST_BRIDGE_BADGE_COUNT_MAX + 1,
}),
),
).toThrowError('app badge count is only supported on iOS mobile shell');
).rejects.toThrowError('app badge count is only supported on iOS mobile shell');
expect(
PushNotificationIOS.setApplicationIconBadgeNumber,
Notifications.setBadgeCountAsync,
).not.toHaveBeenCalled();
expect(Notifications.getPermissionsAsync).not.toHaveBeenCalled();
});
});
+64 -3
View File
@@ -1,4 +1,5 @@
import { Platform, PushNotificationIOS } from 'react-native';
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
import {
HOST_BRIDGE_BADGE_COUNT_MAX,
@@ -9,7 +10,52 @@ import {
} from '../../../../packages/shared/src/contracts/hostBridge';
import { invalidRequest, ok } from './protocol';
export function setMobileAppBadgeCount(request: HostBridgeRequest) {
type NotificationPermissionStatus = Awaited<
ReturnType<typeof Notifications.getPermissionsAsync>
>;
function hasBadgePermission(permission: NotificationPermissionStatus) {
return permission.ios?.allowsBadge === true;
}
async function ensureMobileBadgePermission() {
let currentPermission: NotificationPermissionStatus;
try {
currentPermission = await Notifications.getPermissionsAsync();
} catch {
throw {
code: 'host_error',
message: 'app badge permission unavailable',
} satisfies HostBridgeError;
}
if (hasBadgePermission(currentPermission)) {
return;
}
let requestedPermission: NotificationPermissionStatus;
try {
requestedPermission = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: false,
allowBadge: true,
allowSound: false,
},
});
} catch {
throw {
code: 'host_error',
message: 'app badge permission unavailable',
} satisfies HostBridgeError;
}
if (!hasBadgePermission(requestedPermission)) {
throw {
code: 'host_error',
message: 'app badge permission denied',
} satisfies HostBridgeError;
}
}
export async function setMobileAppBadgeCount(request: HostBridgeRequest) {
if (Platform.OS !== 'ios') {
throw {
code: 'unsupported_capability',
@@ -26,6 +72,21 @@ export function setMobileAppBadgeCount(request: HostBridgeRequest) {
);
}
PushNotificationIOS.setApplicationIconBadgeNumber(count);
await ensureMobileBadgePermission();
let updated = false;
try {
updated = await Notifications.setBadgeCountAsync(count);
} catch {
throw {
code: 'host_error',
message: 'app badge update unavailable',
} satisfies HostBridgeError;
}
if (!updated) {
throw {
code: 'host_error',
message: 'app badge update unavailable',
} satisfies HostBridgeError;
}
return ok(request, true);
}
@@ -9,7 +9,6 @@ import * as Sharing from 'expo-sharing';
import {
Appearance,
Platform,
PushNotificationIOS,
Share,
} from 'react-native';
import { afterEach, describe, expect, test, vi } from 'vitest';
@@ -46,6 +45,10 @@ const GRANTED_NOTIFICATION_PERMISSION = {
granted: true,
canAskAgain: true,
expires: 'never',
ios: {
status: 2,
allowsBadge: true,
},
} as NotificationPermissionStatus;
const DENIED_NOTIFICATION_PERMISSION = {
@@ -53,6 +56,10 @@ const DENIED_NOTIFICATION_PERMISSION = {
granted: false,
canAskAgain: false,
expires: 'never',
ios: {
status: 1,
allowsBadge: false,
},
} as NotificationPermissionStatus;
let requestSequence = 0;
@@ -195,6 +202,7 @@ vi.mock('expo-notifications', () => ({
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
setBadgeCountAsync: vi.fn(),
setNotificationChannelAsync: vi.fn(),
setNotificationHandler: vi.fn(),
}));
@@ -211,9 +219,6 @@ vi.mock('react-native', () => ({
Platform: {
OS: 'ios',
},
PushNotificationIOS: {
setApplicationIconBadgeNumber: vi.fn(),
},
Share: {
share: vi.fn(),
},
@@ -343,7 +348,8 @@ afterEach(() => {
);
vi.mocked(Notifications.setNotificationChannelAsync).mockReset();
vi.mocked(Notifications.setNotificationChannelAsync).mockResolvedValue(null);
vi.mocked(PushNotificationIOS.setApplicationIconBadgeNumber).mockReset();
vi.mocked(Notifications.setBadgeCountAsync).mockReset();
vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(true);
setPlatformOS('ios');
vi.mocked(Sharing.isAvailableAsync).mockReset();
vi.mocked(Sharing.isAvailableAsync).mockResolvedValue(true);
@@ -587,14 +593,11 @@ describe('handleMobileHostBridgeMessage', () => {
});
test('原生异常对象不会透传非协议错误码', async () => {
vi.mocked(PushNotificationIOS.setApplicationIconBadgeNumber)
.mockImplementationOnce(() => {
throw {
code: 'native_badge_failure',
message: 'native badge failed',
nativeStackIOS: ['private native frame'],
};
});
vi.mocked(Notifications.setBadgeCountAsync).mockRejectedValueOnce({
code: 'native_badge_failure',
message: 'native badge failed',
nativeStackIOS: ['private native frame'],
});
const response = await send(
request('app.setBadgeCount', {
@@ -606,7 +609,7 @@ describe('handleMobileHostBridgeMessage', () => {
expect(failedResponse.error).toEqual({
code: 'host_error',
message: 'mobile host bridge request failed',
message: 'app badge update unavailable',
});
});
@@ -878,8 +881,72 @@ describe('handleMobileHostBridgeMessage', () => {
expectOk(response);
expect(
PushNotificationIOS.setApplicationIconBadgeNumber,
Notifications.setBadgeCountAsync,
).toHaveBeenCalledWith(12);
expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled();
});
test('app.setBadgeCount 在 iOS 角标权限缺失时请求 badge 权限', async () => {
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
DENIED_NOTIFICATION_PERMISSION,
);
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
GRANTED_NOTIFICATION_PERMISSION,
);
const response = await send(
request('app.setBadgeCount', {
count: 2,
}),
);
expectOk(response);
expect(Notifications.requestPermissionsAsync).toHaveBeenCalledWith({
ios: {
allowAlert: false,
allowBadge: true,
allowSound: false,
},
});
expect(
Notifications.setBadgeCountAsync,
).toHaveBeenCalledWith(2);
});
test('app.setBadgeCount 在 iOS 角标权限拒绝时不返回成功', async () => {
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
DENIED_NOTIFICATION_PERMISSION,
);
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
DENIED_NOTIFICATION_PERMISSION,
);
const response = await send(
request('app.setBadgeCount', {
count: 1,
}),
);
expect(expectFailed(response).error).toEqual({
code: 'host_error',
message: 'app badge permission denied',
});
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
});
test('app.setBadgeCount 在 iOS 系统拒绝设置时不返回成功', async () => {
vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(false);
const response = await send(
request('app.setBadgeCount', {
count: 1,
}),
);
expect(expectFailed(response).error).toEqual({
code: 'host_error',
message: 'app badge update unavailable',
});
});
test('app.setBadgeCount 拒绝非法数量并在 Android 返回 unsupported', async () => {
@@ -892,7 +959,7 @@ describe('handleMobileHostBridgeMessage', () => {
const invalidError = expectFailed(invalid).error;
expect(invalidError.code).toBe('invalid_request');
expect(invalidError.message).toContain(String(HOST_BRIDGE_BADGE_COUNT_MAX));
expect(PushNotificationIOS.setApplicationIconBadgeNumber).not.toHaveBeenCalled();
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
setPlatformOS('android');
const unsupported = await send(
@@ -902,7 +969,7 @@ describe('handleMobileHostBridgeMessage', () => {
);
expect(expectFailed(unsupported).error.code).toBe('unsupported_capability');
expect(PushNotificationIOS.setApplicationIconBadgeNumber).not.toHaveBeenCalled();
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
});
test('share.open 使用直接分享 payload 调起系统分享', async () => {
@@ -90,9 +90,6 @@ vi.mock('react-native', () => ({
Platform: {
OS: 'android',
},
PushNotificationIOS: {
setApplicationIconBadgeNumber: vi.fn(),
},
}));
function request(method: HostBridgeMethod): HostBridgeRequest {