收紧移动壳角标真实结果
移动 iOS 角标改为请求 badge 权限后调用 Expo Notifications 角标设置以 setBadgeCountAsync 返回值作为成功依据 测试和门禁覆盖权限拒绝与系统拒绝设置失败 文档和项目记忆同步角标能力边界
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user