diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 51fe75f9c..524a28eed 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -104,6 +104,11 @@ const hostBridgeNetworkTestSource = fs.readFileSync( ); const notificationsPath = new URL('../src/host-bridge/notifications.ts', import.meta.url); const notificationsSource = fs.readFileSync(notificationsPath, 'utf8'); +const notificationsTestPath = new URL( + '../src/host-bridge/notifications.test.ts', + import.meta.url, +); +const notificationsTestSource = fs.readFileSync(notificationsTestPath, 'utf8'); const protocolPath = new URL('../src/host-bridge/protocol.ts', import.meta.url); const protocolSource = fs.readFileSync(protocolPath, 'utf8'); const protocolTestPath = new URL( @@ -1936,6 +1941,7 @@ for (const notificationSnippet of [ 'request: HostBridgeRequest', 'normalizeHostBridgeLocalNotification(request.payload)', "invalidRequest('title is required')", + "message: 'notification permission unavailable'", 'showMobileLocalNotification(notification)', 'ok(request, await showMobileLocalNotification(notification))', 'HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT', @@ -1945,6 +1951,18 @@ for (const notificationSnippet of [ } } +for (const snippet of [ + 'rejects delivery when current permission lookup is unavailable', + 'rejects delivery when permission request is unavailable', + "message: 'notification permission unavailable'", + 'expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled()', + 'expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled()', +]) { + if (!notificationsTestSource.includes(snippet)) { + throw new Error(`mobile shell notification tests missing ${snippet}`); + } +} + for (const snippet of [ 'file.exportText', 'file.importText', diff --git a/apps/mobile-shell/src/host-bridge/notifications.test.ts b/apps/mobile-shell/src/host-bridge/notifications.test.ts index ee94c8455..7c59dca6f 100644 --- a/apps/mobile-shell/src/host-bridge/notifications.test.ts +++ b/apps/mobile-shell/src/host-bridge/notifications.test.ts @@ -152,6 +152,42 @@ describe('mobile local notification helpers', () => { expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled(); }); + test('rejects delivery when current permission lookup is unavailable', async () => { + vi.mocked(Notifications.getPermissionsAsync).mockRejectedValueOnce( + new Error('native permission failed'), + ); + + await expect( + showMobileLocalNotification({ title: '生成完成' }), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'notification permission unavailable', + }); + + expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled(); + expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled(); + expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled(); + }); + + test('rejects delivery when permission request is unavailable', async () => { + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( + DENIED_NOTIFICATION_PERMISSION, + ); + vi.mocked(Notifications.requestPermissionsAsync).mockRejectedValueOnce( + new Error('native permission failed'), + ); + + await expect( + showMobileLocalNotification({ title: '生成完成' }), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'notification permission unavailable', + }); + + expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled(); + expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled(); + }); + test('schedules iOS notification without channel trigger', async () => { await showMobileLocalNotification({ title: '生成完成', diff --git a/apps/mobile-shell/src/host-bridge/notifications.ts b/apps/mobile-shell/src/host-bridge/notifications.ts index 7c219583e..6b6ed7526 100644 --- a/apps/mobile-shell/src/host-bridge/notifications.ts +++ b/apps/mobile-shell/src/host-bridge/notifications.ts @@ -30,18 +30,38 @@ function hasNotificationPermission( } async function ensureNotificationPermission() { - const currentPermission = await Notifications.getPermissionsAsync(); + let currentPermission: Awaited< + ReturnType + >; + try { + currentPermission = await Notifications.getPermissionsAsync(); + } catch { + throw { + code: 'host_error', + message: 'notification permission unavailable', + } satisfies HostBridgeError; + } if (hasNotificationPermission(currentPermission)) { return; } - const requestedPermission = await Notifications.requestPermissionsAsync({ - ios: { - allowAlert: true, - allowBadge: false, - allowSound: false, - }, - }); + let requestedPermission: Awaited< + ReturnType + >; + try { + requestedPermission = await Notifications.requestPermissionsAsync({ + ios: { + allowAlert: true, + allowBadge: false, + allowSound: false, + }, + }); + } catch { + throw { + code: 'host_error', + message: 'notification permission unavailable', + } satisfies HostBridgeError; + } if (!hasNotificationPermission(requestedPermission)) { throw { code: 'host_error',