diff --git a/frontend/src/infrastructure/notifications/ExpoSystemNotification.ts b/frontend/src/infrastructure/notifications/ExpoSystemNotification.ts new file mode 100644 index 00000000..c72d1265 --- /dev/null +++ b/frontend/src/infrastructure/notifications/ExpoSystemNotification.ts @@ -0,0 +1,82 @@ +import type { + SystemNotificationPort, + SystemNotificationReceipt, + SystemNotificationRequest, +} from '../../features/reminder/application/interfaces'; + +type NotificationsModule = typeof import('expo-notifications'); + +let channelReady: Promise | null = null; + +async function loadNotifications(): Promise { + try { + return await import('expo-notifications'); + } catch { + return null; + } +} + +async function ensureAndroidChannel(Notifications: NotificationsModule): Promise { + if (channelReady != null) { + await channelReady; + return; + } + channelReady = (async () => { + await Notifications.setNotificationChannelAsync('timeflow-reminders', { + name: '日程提醒', + importance: Notifications.AndroidImportance.DEFAULT, + vibrationPattern: [0, 180], + lightColor: '#D7F36A', + }); + })().catch((error) => { + // 失败别永久缓存住:清掉 channelReady,让下一条提醒重试,而不是这次失败 + // 之后整个 App 生命周期里 show() 都跟着抛。 + channelReady = null; + throw error; + }); + await channelReady; +} + +/** 基于 expo-notifications 的轻度提醒系统通知。 */ +export class ExpoSystemNotification implements SystemNotificationPort { + /** 默认走真实的动态 import;测试注入一个假实现,绕开 expo-notifications 这个原生模块。 */ + constructor( + private readonly loadNotificationsModule: () => Promise = loadNotifications, + ) {} + + async show(request: SystemNotificationRequest): Promise { + const Notifications = await this.loadNotificationsModule(); + if (Notifications == null) { + return { notification_id: request.notification_id, shown: false }; + } + + await ensureAndroidChannel(Notifications); + Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: false, + shouldSetBadge: false, + }), + }); + + await Notifications.scheduleNotificationAsync({ + identifier: request.notification_id, + content: { + title: request.title, + body: request.body, + sound: false, + }, + trigger: null, + }); + + return { notification_id: request.notification_id, shown: true }; + } + + async cancel(notificationId: string): Promise { + const Notifications = await this.loadNotificationsModule(); + if (Notifications == null) return; + await Notifications.dismissNotificationAsync(notificationId).catch(() => undefined); + await Notifications.cancelScheduledNotificationAsync(notificationId).catch(() => undefined); + } +} diff --git a/frontend/src/infrastructure/notifications/MockAlarmScheduler.ts b/frontend/src/infrastructure/notifications/MockAlarmScheduler.ts deleted file mode 100644 index 68b447cf..00000000 --- a/frontend/src/infrastructure/notifications/MockAlarmScheduler.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { - AlarmScheduleReceipt, - AlarmScheduleRequest, - AlarmSchedulerPort, -} from '../../features/reminder/application/interfaces'; - -/** 固定闹钟适配器,始终标记为已调度(进程内占位 id)。 */ -export class MockAlarmScheduler implements AlarmSchedulerPort { - async schedule(request: AlarmScheduleRequest): Promise { - return { - alarm_id: `mock-alarm-${request.schedule_id}`, - schedule_id: request.schedule_id, - scheduled: true, - }; - } - - async cancel(_alarmId: string | null): Promise<{ cancelled: boolean }> { - return { cancelled: true }; - } - - async rebuild( - requests: readonly AlarmScheduleRequest[], - ): Promise { - return Promise.all(requests.map((request) => this.schedule(request))); - } -} diff --git a/frontend/src/infrastructure/notifications/MockDeviceCapability.ts b/frontend/src/infrastructure/notifications/MockDeviceCapability.ts deleted file mode 100644 index 6e7a50dc..00000000 --- a/frontend/src/infrastructure/notifications/MockDeviceCapability.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { - DeviceCapabilityPort, - DeviceCapabilityStatus, - DevicePermission, -} from '../../features/reminder/application/interfaces'; - -const MOCK_PERMISSIONS: Readonly> = { - notifications: true, - exact_alarm: true, - overlay: false, - full_screen: true, - battery_optimization: false, - location_foreground: true, - location_background: false, -}; - -const MOCK_STATUS: DeviceCapabilityStatus = { - platform: 'android', - supported: true, - permissions: MOCK_PERMISSIONS, - background_execution: false, -}; - -/** 原生模块接入前使用的固定设备能力状态。 */ -export class MockDeviceCapability implements DeviceCapabilityPort { - async getStatus(): Promise { - return { ...MOCK_STATUS, permissions: { ...MOCK_STATUS.permissions } }; - } - - async requestPermission(permission: DevicePermission): Promise { - // 与 getStatus() 保持一致:返回该权限的固定值,不假装 grant 成功。 - return MOCK_PERMISSIONS[permission]; - } - - async openSettings(_permission: DevicePermission): Promise { - return true; - } - - onAppActive(_listener: () => void): () => void { - return () => {}; - } -} - -export { MOCK_STATUS as MOCK_DEVICE_CAPABILITY_STATUS }; diff --git a/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts b/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts index 7b549514..b87d11e4 100644 --- a/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts +++ b/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts @@ -1,16 +1,23 @@ import type { + AlarmNativeDisposition, + AlarmNativeEvent, AlarmScheduleReceipt, AlarmScheduleRequest, AlarmSchedulerPort, } from '../../features/reminder/application/interfaces'; import { isTimeflowAlarmAvailable, + nativeAckAlarmDispositions, nativeAreAlarmPermissionsGranted, nativeCancelAlarm, + nativeCancelAllAlarms, + nativePeekAlarmDispositions, nativeScheduleAlarm, + nativeStopAlarmRinging, + subscribeNativeAlarmEvents, } from './native/TimeflowAlarmBridge'; -/** Android TimeflowAlarm 适配器;无法挂上时返回 scheduled: false。 */ +/** Android TimeflowAlarm 适配器;无法挂上时返回 scheduled=false。 */ export class NativeAlarmScheduler implements AlarmSchedulerPort { async schedule(request: AlarmScheduleRequest): Promise { if (!isTimeflowAlarmAvailable()) { @@ -22,29 +29,21 @@ export class NativeAlarmScheduler implements AlarmSchedulerPort { return unscheduled(request.schedule_id); } - try { - const ready = await nativeAreAlarmPermissionsGranted(); - if (!ready) { - return unscheduled(request.schedule_id); - } - - const alarmId = await nativeScheduleAlarm( - triggerAtMillis, - request.title, - request.schedule_id, - ); - if (alarmId == null || alarmId.length === 0) { - return unscheduled(request.schedule_id); - } + const ready = await nativeAreAlarmPermissionsGranted(); + if (!ready) { + return unscheduled(request.schedule_id); + } - return { - alarm_id: alarmId, - schedule_id: request.schedule_id, - scheduled: true, - }; - } catch { + const alarmId = await nativeScheduleAlarm(triggerAtMillis, request.title, request.schedule_id); + if (alarmId == null || alarmId.length === 0) { return unscheduled(request.schedule_id); } + + return { + alarm_id: alarmId, + schedule_id: request.schedule_id, + scheduled: true, + }; } async cancel(alarmId: string | null): Promise<{ cancelled: boolean }> { @@ -58,12 +57,57 @@ export class NativeAlarmScheduler implements AlarmSchedulerPort { async rebuild( requests: readonly AlarmScheduleRequest[], ): Promise { + // 冷启动 registrations 为空时也要清掉 SharedPreferences 里的孤儿闹钟。 + await nativeCancelAllAlarms(); const receipts: AlarmScheduleReceipt[] = []; for (const request of requests) { receipts.push(await this.schedule(request)); } return receipts; } + + async stopRinging(): Promise { + await nativeStopAlarmRinging(); + } + + subscribe(listener: (event: AlarmNativeEvent) => void): () => void { + return subscribeNativeAlarmEvents((payload) => { + listener({ + type: payload.type, + schedule_id: payload.scheduleId, + alarm_id: payload.alarmId, + title: payload.title, + at: new Date(payload.atMillis || Date.now()).toISOString(), + }); + }); + } + + async peekNativeDispositions(): Promise { + const rows = await nativePeekAlarmDispositions(); + return rows + .map((row) => { + const state = + row.state === 'confirmed' + ? 'confirmed' + : row.state === 'pending' + ? 'pending' + : row.state === 'snoozed' + ? 'snoozed' + : null; + if (state == null || !row.scheduleId) return null; + return { + schedule_id: row.scheduleId, + alarm_id: row.alarmId ?? '', + state, + updated_at: new Date(row.updatedAtMillis || Date.now()).toISOString(), + } satisfies AlarmNativeDisposition; + }) + .filter((row): row is AlarmNativeDisposition => row != null); + } + + async ackNativeDispositions(scheduleIds: readonly string[]): Promise { + await nativeAckAlarmDispositions(scheduleIds); + } } function unscheduled(scheduleId: string): AlarmScheduleReceipt { diff --git a/frontend/src/infrastructure/notifications/NativeDeviceCapability.ts b/frontend/src/infrastructure/notifications/NativeDeviceCapability.ts index 4806a195..8f3acef5 100644 --- a/frontend/src/infrastructure/notifications/NativeDeviceCapability.ts +++ b/frontend/src/infrastructure/notifications/NativeDeviceCapability.ts @@ -1,4 +1,4 @@ -import { AppState, Platform, type AppStateStatus } from 'react-native'; +import { AppState, Linking, Platform, type AppStateStatus } from 'react-native'; import type { DeviceCapabilityPort, @@ -12,6 +12,16 @@ import { nativeRequestNotificationPermission, } from './native/TimeflowAlarmBridge'; +type LocationModule = typeof import('expo-location'); + +async function loadExpoLocation(): Promise { + try { + return await import('expo-location'); + } catch { + return null; + } +} + const SETTINGS_KIND: Partial< Record > = { @@ -22,22 +32,42 @@ const SETTINGS_KIND: Partial< notifications: 'app', }; -/** 基于 TimeflowAlarm 原生模块的设备权限适配器。 */ +/** 基于 TimeflowAlarm + expo-location 的设备权限适配器。 */ export class NativeDeviceCapability implements DeviceCapabilityPort { + /** 默认走真实的动态 import;测试注入一个假实现,绕开 expo-location 这个原生模块。 */ + constructor( + private readonly loadLocationModule: () => Promise = loadExpoLocation, + ) {} + async getStatus(): Promise { const platform = toPlatform(); + const location = await this.readLocationPermissions(); + if (!isTimeflowAlarmAvailable()) { - return unsupportedStatus(platform); + return { + platform, + supported: false, + permissions: { + ...emptyPermissions(false), + location_foreground: location.foreground, + location_background: location.background, + }, + background_execution: false, + }; } - let status; - try { - status = await nativeGetAlarmPermissionStatus(); - } catch { - return unsupportedStatus(platform); - } + const status = await nativeGetAlarmPermissionStatus(); if (status == null) { - return unsupportedStatus(platform); + return { + platform, + supported: false, + permissions: { + ...emptyPermissions(false), + location_foreground: location.foreground, + location_background: location.background, + }, + background_execution: false, + }; } return { @@ -49,8 +79,8 @@ export class NativeDeviceCapability implements DeviceCapabilityPort { overlay: status.overlay, full_screen: status.fullScreen, battery_optimization: status.battery, - location_foreground: false, - location_background: false, + location_foreground: location.foreground, + location_background: location.background, }, background_execution: status.battery, }; @@ -58,22 +88,25 @@ export class NativeDeviceCapability implements DeviceCapabilityPort { async requestPermission(permission: DevicePermission): Promise { if (permission === 'notifications') { - try { - return await nativeRequestNotificationPermission(); - } catch { - return false; - } + return nativeRequestNotificationPermission(); + } + if (permission === 'location_foreground' || permission === 'location_background') { + return this.requestLocationPermission(permission); } return this.openSettings(permission); } async openSettings(permission: DevicePermission): Promise { - const kind = SETTINGS_KIND[permission] ?? 'app'; - try { - return await nativeOpenAlarmPermissionSettings(kind); - } catch { - return false; + if (permission === 'location_foreground' || permission === 'location_background') { + try { + await Linking.openSettings(); + return true; + } catch { + return false; + } } + const kind = SETTINGS_KIND[permission] ?? 'app'; + return nativeOpenAlarmPermissionSettings(kind); } onAppActive(listener: () => void): () => void { @@ -82,6 +115,66 @@ export class NativeDeviceCapability implements DeviceCapabilityPort { }); return () => subscription.remove(); } + + private async readLocationPermissions(): Promise<{ foreground: boolean; background: boolean }> { + try { + const Location = await this.loadLocationModule(); + if (Location == null) return { foreground: false, background: false }; + const foreground = await Location.getForegroundPermissionsAsync(); + const background = await Location.getBackgroundPermissionsAsync(); + return { + foreground: foreground.status === Location.PermissionStatus.GRANTED, + background: background.status === Location.PermissionStatus.GRANTED, + }; + } catch { + return { foreground: false, background: false }; + } + } + + private async requestLocationPermission( + permission: 'location_foreground' | 'location_background', + ): Promise { + try { + const Location = await this.loadLocationModule(); + if (Location == null) return false; + if (permission === 'location_foreground') { + const current = await Location.getForegroundPermissionsAsync(); + if (current.status === Location.PermissionStatus.GRANTED) { + return true; + } + // 系统不再弹授权框时不要空等,交给上层 openSettings。 + if (current.canAskAgain === false) { + return false; + } + const result = await Location.requestForegroundPermissionsAsync(); + return result.status === Location.PermissionStatus.GRANTED; + } + + const foreground = await Location.getForegroundPermissionsAsync(); + if (foreground.status !== Location.PermissionStatus.GRANTED) { + if (foreground.canAskAgain === false) { + return false; + } + const requested = await Location.requestForegroundPermissionsAsync(); + if (requested.status !== Location.PermissionStatus.GRANTED) { + return false; + } + } + + const currentBackground = await Location.getBackgroundPermissionsAsync(); + if (currentBackground.status === Location.PermissionStatus.GRANTED) { + return true; + } + if (currentBackground.canAskAgain === false) { + return false; + } + + const background = await Location.requestBackgroundPermissionsAsync(); + return background.status === Location.PermissionStatus.GRANTED; + } catch { + return false; + } + } } function toPlatform(): DeviceCapabilityStatus['platform'] { @@ -91,15 +184,6 @@ function toPlatform(): DeviceCapabilityStatus['platform'] { return 'unknown'; } -function unsupportedStatus(platform: DeviceCapabilityStatus['platform']): DeviceCapabilityStatus { - return { - platform, - supported: false, - permissions: emptyPermissions(false), - background_execution: false, - }; -} - function emptyPermissions(value: boolean): Readonly> { return { notifications: value, diff --git a/frontend/src/infrastructure/notifications/ReactNativeAlertDialog.ts b/frontend/src/infrastructure/notifications/ReactNativeAlertDialog.ts new file mode 100644 index 00000000..ac779b7a --- /dev/null +++ b/frontend/src/infrastructure/notifications/ReactNativeAlertDialog.ts @@ -0,0 +1,25 @@ +import { Alert } from 'react-native'; + +import type { + AlertDialogPort, + AlertDialogRequest, +} from '../../features/reminder/application/interfaces'; + +/** 系统 Alert 对话框适配器;presentation 通过 AlertDialogPort 使用,不直接依赖 RN。 */ +export class ReactNativeAlertDialog implements AlertDialogPort { + async show(request: AlertDialogRequest): Promise { + Alert.alert( + request.title, + request.message, + request.buttons.map((button) => ({ + text: button.text, + style: button.style, + onPress: button.onPress, + })), + { + cancelable: request.cancelable ?? true, + onDismiss: request.onDismiss, + }, + ); + } +} diff --git a/frontend/src/infrastructure/notifications/ReactNativeVibration.ts b/frontend/src/infrastructure/notifications/ReactNativeVibration.ts new file mode 100644 index 00000000..7bec320b --- /dev/null +++ b/frontend/src/infrastructure/notifications/ReactNativeVibration.ts @@ -0,0 +1,38 @@ +import { Platform, Vibration } from 'react-native'; + +import type { VibrationPort } from '../../features/reminder/application/interfaces'; + +/** 震动 / 间隔,确认或 teardown 前循环。 */ +const REPEAT_PATTERN = [0, 700, 350, 700]; + +/** React Native Vibration 适配器:提醒展示期间持续震动。 */ +export class ReactNativeVibration implements VibrationPort { + private iosTimer: ReturnType | null = null; + + async vibrate(): Promise { + this.clearIosTimer(); + Vibration.cancel(); + + if (Platform.OS === 'android') { + // 第二个参数 true = 按 pattern 循环,直到 cancel。 + Vibration.vibrate(REPEAT_PATTERN, true); + return; + } + + Vibration.vibrate(REPEAT_PATTERN); + this.iosTimer = setInterval(() => { + Vibration.vibrate(REPEAT_PATTERN); + }, 2_000); + } + + async stop(): Promise { + this.clearIosTimer(); + Vibration.cancel(); + } + + private clearIosTimer(): void { + if (this.iosTimer == null) return; + clearInterval(this.iosTimer); + this.iosTimer = null; + } +} diff --git a/frontend/src/infrastructure/notifications/index.ts b/frontend/src/infrastructure/notifications/index.ts index 2ad4c8f1..ec8a77d6 100644 --- a/frontend/src/infrastructure/notifications/index.ts +++ b/frontend/src/infrastructure/notifications/index.ts @@ -1,16 +1,22 @@ -export { MockAlarmScheduler } from './MockAlarmScheduler'; export { NativeAlarmScheduler } from './NativeAlarmScheduler'; export { NativeDeviceCapability } from './NativeDeviceCapability'; +export { ReactNativeVibration } from './ReactNativeVibration'; +export { ReactNativeAlertDialog } from './ReactNativeAlertDialog'; +export { ExpoSystemNotification } from './ExpoSystemNotification'; export { MockPopup, MockSystemNotification, MockVibration } from './MockNotificationChannels'; export { MockReminderRecovery } from './MockReminderRecovery'; export { MockReminderDelivery, MOCK_REMINDER_DELIVERY_RECEIPT } from './MockReminderDelivery'; -export { MockDeviceCapability, MOCK_DEVICE_CAPABILITY_STATUS } from './MockDeviceCapability'; export { isTimeflowAlarmAvailable, nativeAreAlarmPermissionsGranted, + nativeAckAlarmDispositions, nativeCancelAlarm, + nativeCancelAllAlarms, nativeGetAlarmPermissionStatus, nativeOpenAlarmPermissionSettings, + nativePeekAlarmDispositions, nativeRequestNotificationPermission, nativeScheduleAlarm, + nativeStopAlarmRinging, + subscribeNativeAlarmEvents, } from './native/TimeflowAlarmBridge'; diff --git a/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts b/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts index ea6a11bc..18625cf2 100644 --- a/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts +++ b/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts @@ -1,4 +1,4 @@ -import { NativeModules, Platform } from 'react-native'; +import { NativeEventEmitter, NativeModules, Platform } from 'react-native'; export type NativeAlarmPermissionStatus = { exactAlarm: boolean; @@ -8,24 +8,49 @@ export type NativeAlarmPermissionStatus = { battery: boolean; }; +export type NativeAlarmEventPayload = { + type: 'fired' | 'dismissed' | 'snoozed'; + scheduleId: string; + alarmId: string; + title: string; + atMillis: number; +}; + +export type NativeAlarmDispositionPayload = { + scheduleId: string; + alarmId: string; + state: string; + updatedAtMillis: number; +}; + type TimeflowAlarmNative = { schedule: ( triggerAtMillis: number, title?: string | null, scheduleId?: string | null, - ) => Promise<{ alarmId: string }>; + ) => Promise<{ alarmId: string; scheduleId?: string }>; cancel: (alarmId: string) => Promise; + cancelAll: () => Promise; + stopRinging: () => Promise; + peekNativeDispositions: () => Promise; + ackNativeDispositions: (scheduleIds: string[]) => Promise; getPermissionStatus: () => Promise; openPermissionSettings: ( kind: 'exactAlarm' | 'overlay' | 'fullScreen' | 'battery' | 'app', ) => Promise; requestNotificationPermission: () => Promise; + addListener?: (eventName: string) => void; + removeListeners?: (count: number) => void; }; -const NativeAlarm = NativeModules.TimeflowAlarm as TimeflowAlarmNative | undefined; +const EVENT_NAME = 'TimeflowAlarmEvent'; + +function getNativeAlarm(): TimeflowAlarmNative | undefined { + return NativeModules.TimeflowAlarm as TimeflowAlarmNative | undefined; +} export function isTimeflowAlarmAvailable(): boolean { - return Platform.OS === 'android' && NativeAlarm != null; + return Platform.OS === 'android' && getNativeAlarm() != null; } export async function nativeScheduleAlarm( @@ -33,46 +58,117 @@ export async function nativeScheduleAlarm( title: string, scheduleId?: string, ): Promise { - if (!isTimeflowAlarmAvailable() || NativeAlarm == null) return null; + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return null; try { - const result = await NativeAlarm.schedule(triggerAtMillis, title, scheduleId ?? ''); - const alarmId = result?.alarmId; - if (alarmId == null || alarmId.length === 0) return null; - return alarmId; + const result = await native.schedule(triggerAtMillis, title, scheduleId ?? ''); + return result.alarmId; } catch { return null; } } export async function nativeCancelAlarm(alarmId: string | null | undefined): Promise { - if (!isTimeflowAlarmAvailable() || NativeAlarm == null || !alarmId) return false; + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null || !alarmId) return false; try { - return Boolean(await NativeAlarm.cancel(alarmId)); + return await native.cancel(alarmId); } catch { return false; } } +export async function nativeCancelAllAlarms(): Promise { + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return; + try { + await native.cancelAll(); + } catch { + // 尽力全部取消,忽略失败。 + } +} + +export async function nativeStopAlarmRinging(): Promise { + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return; + try { + await native.stopRinging(); + } catch { + // 尽力停铃,忽略失败。 + } +} + +export async function nativePeekAlarmDispositions(): Promise { + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return []; + try { + return await native.peekNativeDispositions(); + } catch { + return []; + } +} + +export async function nativeAckAlarmDispositions(scheduleIds: readonly string[]): Promise { + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null || scheduleIds.length === 0) return; + try { + await native.ackNativeDispositions([...scheduleIds]); + } catch { + // 确认失败就不清缓冲区,下次冷启动重新 peek 到、重放同样的幂等状态转换。 + } +} + export async function nativeGetAlarmPermissionStatus(): Promise { - if (!isTimeflowAlarmAvailable() || NativeAlarm == null) return null; - return NativeAlarm.getPermissionStatus(); + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return null; + try { + return await native.getPermissionStatus(); + } catch { + return null; + } } export async function nativeOpenAlarmPermissionSettings( kind: 'exactAlarm' | 'overlay' | 'fullScreen' | 'battery' | 'app', ): Promise { - if (!isTimeflowAlarmAvailable() || NativeAlarm == null) return false; - return NativeAlarm.openPermissionSettings(kind); + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return false; + try { + return await native.openPermissionSettings(kind); + } catch { + return false; + } } export async function nativeRequestNotificationPermission(): Promise { - if (!isTimeflowAlarmAvailable() || NativeAlarm == null) return false; - return NativeAlarm.requestNotificationPermission(); + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return false; + try { + return await native.requestNotificationPermission(); + } catch { + return false; + } } export async function nativeAreAlarmPermissionsGranted(): Promise { const status = await nativeGetAlarmPermissionStatus(); if (status == null) return false; - // 挂闹钟的最低要求:精确闹钟 + 通知;悬浮窗/全屏/电池影响展示,不阻塞调度。 return status.exactAlarm && status.notifications; } + +export function subscribeNativeAlarmEvents( + listener: (event: NativeAlarmEventPayload) => void, +): () => void { + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) { + return () => undefined; + } + const emitter = new NativeEventEmitter(native as never); + const subscription = emitter.addListener(EVENT_NAME, (payload: NativeAlarmEventPayload) => { + if (payload?.type !== 'fired' && payload?.type !== 'dismissed' && payload?.type !== 'snoozed') { + return; + } + listener(payload); + }); + return () => subscription.remove(); +} diff --git a/frontend/tests/unit/infrastructure/notifications/expoSystemNotification.test.ts b/frontend/tests/unit/infrastructure/notifications/expoSystemNotification.test.ts new file mode 100644 index 00000000..26cbf00a --- /dev/null +++ b/frontend/tests/unit/infrastructure/notifications/expoSystemNotification.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; + +import { ExpoSystemNotification } from '../../../../src/infrastructure/notifications/ExpoSystemNotification'; + +/** + * expo-notifications 的动态 import() 在这个 Jest 环境下天然会抛错(没有 + * --experimental-vm-modules),jest.mock('expo-notifications', ...) 拦不住它——见 + * ExpoSystemNotification.ts 构造函数上新加的注入口子。这里直接注入一个假的 + * loadNotificationsModule,绕开真的动态 import,测真正的展示/取消逻辑。 + * + * ExpoSystemNotification.ts 用一个模块级变量 channelReady 缓存"频道已建好"这件事, + * 所以每个用例都要用 jest.resetModules() + require() 拿一份全新的模块实例,不然前一条 + * 用例建好的频道会让后一条用例误判成"已经建过频道,不用再调 setNotificationChannelAsync"。 + * (用 require 而不是 import():这个 Jest 环境本身不支持运行时动态 import()。) + */ +describe('ExpoSystemNotification (fake native notifications module injected)', () => { + const setNotificationChannelAsync = jest.fn<() => Promise>().mockResolvedValue(undefined); + const setNotificationHandler = jest.fn(); + const scheduleNotificationAsync = jest.fn<() => Promise>().mockResolvedValue('id'); + const dismissNotificationAsync = jest.fn<() => Promise>().mockResolvedValue(undefined); + const cancelScheduledNotificationAsync = jest + .fn<() => Promise>() + .mockResolvedValue(undefined); + + function loadNotificationsModule() { + return Promise.resolve({ + AndroidImportance: { DEFAULT: 3 }, + setNotificationChannelAsync, + setNotificationHandler, + scheduleNotificationAsync, + dismissNotificationAsync, + cancelScheduledNotificationAsync, + } as unknown as typeof import('expo-notifications')); + } + + function freshExpoSystemNotification(): ExpoSystemNotification { + let FreshClass: typeof ExpoSystemNotification | undefined; + jest.isolateModules(() => { + /* eslint-disable @typescript-eslint/no-require-imports */ + const moduleExports = + require('../../../../src/infrastructure/notifications/ExpoSystemNotification') as { + ExpoSystemNotification: typeof ExpoSystemNotification; + }; + /* eslint-enable @typescript-eslint/no-require-imports */ + FreshClass = moduleExports.ExpoSystemNotification; + }); + return new FreshClass!(loadNotificationsModule); + } + + beforeEach(() => { + jest.clearAllMocks(); + setNotificationChannelAsync.mockResolvedValue(undefined); + scheduleNotificationAsync.mockResolvedValue('id'); + dismissNotificationAsync.mockResolvedValue(undefined); + cancelScheduledNotificationAsync.mockResolvedValue(undefined); + }); + + it('returns shown: false without touching the module when it fails to load', async () => { + const notification = new ExpoSystemNotification(() => Promise.resolve(null)); + await expect( + notification.show({ notification_id: 'n1', title: '标题', body: '内容' }), + ).resolves.toEqual({ notification_id: 'n1', shown: false }); + expect(setNotificationChannelAsync).not.toHaveBeenCalled(); + }); + + it('creates the Android channel once, schedules the notification, and reports shown: true', async () => { + const notification = freshExpoSystemNotification(); + const receipt = await notification.show({ notification_id: 'n1', title: '标题', body: '内容' }); + + expect(receipt).toEqual({ notification_id: 'n1', shown: true }); + expect(setNotificationChannelAsync).toHaveBeenCalledTimes(1); + expect(setNotificationChannelAsync).toHaveBeenCalledWith( + 'timeflow-reminders', + expect.objectContaining({ name: '日程提醒' }), + ); + expect(scheduleNotificationAsync).toHaveBeenCalledWith({ + identifier: 'n1', + content: { title: '标题', body: '内容', sound: false }, + trigger: null, + }); + }); + + it('installs a foreground handler that shows the banner/list without sound or badge', async () => { + const notification = freshExpoSystemNotification(); + await notification.show({ notification_id: 'n1', title: '标题', body: '内容' }); + + expect(setNotificationHandler).toHaveBeenCalledTimes(1); + const { handleNotification } = setNotificationHandler.mock.calls[0][0] as { + handleNotification: () => Promise>; + }; + await expect(handleNotification()).resolves.toEqual({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: false, + shouldSetBadge: false, + }); + }); + + it('reuses the cached channel setup across repeated calls instead of recreating it', async () => { + const notification = freshExpoSystemNotification(); + await notification.show({ notification_id: 'n1', title: 'a', body: 'a' }); + await notification.show({ notification_id: 'n2', title: 'b', body: 'b' }); + + expect(setNotificationChannelAsync).toHaveBeenCalledTimes(1); + expect(scheduleNotificationAsync).toHaveBeenCalledTimes(2); + }); + + it('clears the cached channel setup after a failure so the next call retries', async () => { + const notification = freshExpoSystemNotification(); + setNotificationChannelAsync.mockRejectedValueOnce(new Error('channel setup failed')); + + await expect( + notification.show({ notification_id: 'n1', title: 'a', body: 'a' }), + ).rejects.toThrow('channel setup failed'); + expect(scheduleNotificationAsync).not.toHaveBeenCalled(); + + await expect( + notification.show({ notification_id: 'n2', title: 'b', body: 'b' }), + ).resolves.toEqual({ notification_id: 'n2', shown: true }); + expect(setNotificationChannelAsync).toHaveBeenCalledTimes(2); + }); + + it('cancel is a no-op when the module fails to load', async () => { + const notification = new ExpoSystemNotification(() => Promise.resolve(null)); + await expect(notification.cancel('n1')).resolves.toBeUndefined(); + expect(dismissNotificationAsync).not.toHaveBeenCalled(); + }); + + it('cancel dismisses and unschedules the notification, swallowing either failure', async () => { + const notification = freshExpoSystemNotification(); + await notification.cancel('n1'); + expect(dismissNotificationAsync).toHaveBeenCalledWith('n1'); + expect(cancelScheduledNotificationAsync).toHaveBeenCalledWith('n1'); + + dismissNotificationAsync.mockRejectedValueOnce(new Error('already dismissed')); + cancelScheduledNotificationAsync.mockRejectedValueOnce(new Error('already cancelled')); + await expect(notification.cancel('n2')).resolves.toBeUndefined(); + }); +}); diff --git a/frontend/tests/unit/infrastructure/notifications/mockDeviceCapability.test.ts b/frontend/tests/unit/infrastructure/notifications/mockDeviceCapability.test.ts deleted file mode 100644 index b0f0b95a..00000000 --- a/frontend/tests/unit/infrastructure/notifications/mockDeviceCapability.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; - -import { MockDeviceCapability } from '../../../../src/infrastructure/notifications/MockDeviceCapability'; - -describe('MockDeviceCapability', () => { - it('reports the fixed mock status', async () => { - const device = new MockDeviceCapability(); - await expect(device.getStatus()).resolves.toEqual({ - platform: 'android', - supported: true, - permissions: { - notifications: true, - exact_alarm: true, - overlay: false, - full_screen: true, - battery_optimization: false, - location_foreground: true, - location_background: false, - }, - background_execution: false, - }); - }); - - it('resolves requestPermission with the fixed value for that permission', async () => { - const device = new MockDeviceCapability(); - await expect(device.requestPermission('overlay')).resolves.toBe(false); - await expect(device.requestPermission('notifications')).resolves.toBe(true); - }); - - it('resolves openSettings as successful', async () => { - const device = new MockDeviceCapability(); - await expect(device.openSettings('overlay')).resolves.toBe(true); - }); - - it('onAppActive is a no-op that returns an unsubscribe function', () => { - const device = new MockDeviceCapability(); - const unsubscribe = device.onAppActive(() => { - throw new Error('should never be called'); - }); - expect(() => unsubscribe()).not.toThrow(); - }); -}); diff --git a/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts b/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts index 5e30302c..fc872352 100644 --- a/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts +++ b/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts @@ -5,28 +5,72 @@ import type { AlarmScheduleRequest } from '../../../../src/features/reminder/app import { NativeAlarmScheduler } from '../../../../src/infrastructure/notifications/NativeAlarmScheduler'; import { isTimeflowAlarmAvailable, + nativeAckAlarmDispositions, nativeAreAlarmPermissionsGranted, nativeCancelAlarm, + nativeCancelAllAlarms, + nativeGetAlarmPermissionStatus, + nativeOpenAlarmPermissionSettings, + nativePeekAlarmDispositions, + nativeRequestNotificationPermission, nativeScheduleAlarm, + nativeStopAlarmRinging, + subscribeNativeAlarmEvents, } from '../../../../src/infrastructure/notifications/native/TimeflowAlarmBridge'; +const mockListeners = new Map void>>(); + jest.mock('react-native', () => { const RN = jest.requireActual('react-native') as typeof import('react-native'); RN.NativeModules.TimeflowAlarm = { schedule: jest.fn(), cancel: jest.fn(), + cancelAll: jest.fn(), + stopRinging: jest.fn(), + peekNativeDispositions: jest.fn(), + ackNativeDispositions: jest.fn(), getPermissionStatus: jest.fn(), openPermissionSettings: jest.fn(), requestNotificationPermission: jest.fn(), + addListener: jest.fn(), + removeListeners: jest.fn(), }; + class FakeNativeEventEmitter { + addListener(eventName: string, listener: (payload: unknown) => void) { + const set = mockListeners.get(eventName) ?? new Set(); + set.add(listener); + mockListeners.set(eventName, set); + return { remove: () => set.delete(listener) }; + } + } + Object.defineProperty(RN, 'NativeEventEmitter', { + value: FakeNativeEventEmitter, + configurable: true, + }); return RN; }); +function emit(eventName: string, payload: unknown): void { + for (const listener of mockListeners.get(eventName) ?? []) { + listener(payload); + } +} + type NativeAlarmMock = { schedule: jest.MockedFunction< - (triggerAtMillis: number, title?: string | null) => Promise<{ alarmId: string }> + ( + triggerAtMillis: number, + title?: string | null, + scheduleId?: string | null, + ) => Promise<{ alarmId: string }> >; cancel: jest.MockedFunction<(alarmId: string) => Promise>; + cancelAll: jest.MockedFunction<() => Promise>; + stopRinging: jest.MockedFunction<() => Promise>; + peekNativeDispositions: jest.MockedFunction< + () => Promise<{ scheduleId: string; alarmId: string; state: string; updatedAtMillis: number }[]> + >; + ackNativeDispositions: jest.MockedFunction<(scheduleIds: string[]) => Promise>; getPermissionStatus: jest.MockedFunction< () => Promise<{ exactAlarm: boolean; @@ -36,13 +80,15 @@ type NativeAlarmMock = { battery: boolean; }> >; + openPermissionSettings: jest.MockedFunction<(kind: string) => Promise>; + requestNotificationPermission: jest.MockedFunction<() => Promise>; }; const NOW = '2026-08-13T08:00:00.000Z'; const FUTURE = '2026-08-13T09:00:00.000Z'; const PAST = '2026-08-13T07:00:00.000Z'; -const native = NativeModules.TimeflowAlarm as NativeAlarmMock; +const native = NativeModules.TimeflowAlarm as unknown as NativeAlarmMock; function request(overrides: Partial = {}): AlarmScheduleRequest { return { @@ -78,12 +124,25 @@ describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { jest.useFakeTimers(); jest.setSystemTime(new Date(NOW)); Platform.OS = 'android'; + mockListeners.clear(); native.schedule.mockReset(); native.cancel.mockReset(); + native.cancelAll.mockReset(); + native.stopRinging.mockReset(); + native.peekNativeDispositions.mockReset(); + native.ackNativeDispositions.mockReset(); native.getPermissionStatus.mockReset(); + native.openPermissionSettings.mockReset(); + native.requestNotificationPermission.mockReset(); grantPermissions(); native.schedule.mockResolvedValue({ alarmId: 'alarm-1' }); native.cancel.mockResolvedValue(true); + native.cancelAll.mockResolvedValue(0); + native.stopRinging.mockResolvedValue(true); + native.peekNativeDispositions.mockResolvedValue([]); + native.ackNativeDispositions.mockResolvedValue(true); + native.openPermissionSettings.mockResolvedValue(true); + native.requestNotificationPermission.mockResolvedValue(true); }); afterEach(() => { @@ -206,6 +265,7 @@ describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { scheduled: false, }); expect(native.schedule).not.toHaveBeenCalled(); + await expect(nativeGetAlarmPermissionStatus()).resolves.toBeNull(); }); it('forwards cancel true and false from the native module', async () => { @@ -224,14 +284,23 @@ describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { expect(native.cancel).not.toHaveBeenCalled(); }); - it('maps a native cancel rejection to cancelled: false', async () => { + it('maps a native cancel rejection to cancelled: false, not a false claim of success', async () => { native.cancel.mockRejectedValue(new Error('cancel failed')); const scheduler = new NativeAlarmScheduler(); await expect(scheduler.cancel('alarm-1')).resolves.toEqual({ cancelled: false }); await expect(nativeCancelAlarm('alarm-1')).resolves.toBe(false); }); - it('rebuilds mixed requests in order', async () => { + it('nativeCancelAlarm is a no-op returning false when the bridge is unavailable or the id is empty', async () => { + Platform.OS = 'ios'; + await expect(nativeCancelAlarm('alarm-1')).resolves.toBe(false); + Platform.OS = 'android'; + await expect(nativeCancelAlarm(null)).resolves.toBe(false); + await expect(nativeCancelAlarm(undefined)).resolves.toBe(false); + expect(native.cancel).not.toHaveBeenCalled(); + }); + + it('rebuilds mixed requests in order and cancels all native alarms first', async () => { native.schedule.mockResolvedValueOnce({ alarmId: 'alarm-ok' }).mockResolvedValueOnce({ alarmId: 'alarm-later', }); @@ -246,6 +315,7 @@ describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { { alarm_id: '', schedule_id: 'expired', scheduled: false }, { alarm_id: 'alarm-later', schedule_id: 'later', scheduled: true }, ]); + expect(native.cancelAll).toHaveBeenCalledTimes(1); expect(native.schedule).toHaveBeenCalledTimes(2); expect(native.schedule).toHaveBeenNthCalledWith(1, Date.parse(FUTURE), '晨会', 'ok'); expect(native.schedule).toHaveBeenNthCalledWith( @@ -255,4 +325,151 @@ describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { 'later', ); }); + + it('nativeCancelAllAlarms swallows a native rejection', async () => { + native.cancelAll.mockRejectedValue(new Error('cancelAll failed')); + await expect(nativeCancelAllAlarms()).resolves.toBeUndefined(); + }); + + it('stopRinging calls the native bridge and swallows rejection', async () => { + const scheduler = new NativeAlarmScheduler(); + await scheduler.stopRinging(); + expect(native.stopRinging).toHaveBeenCalledTimes(1); + + native.stopRinging.mockRejectedValue(new Error('stop failed')); + await expect(nativeStopAlarmRinging()).resolves.toBeUndefined(); + }); + + it('peeks native dispositions and maps known states, dropping unknown ones', async () => { + native.peekNativeDispositions.mockResolvedValue([ + { scheduleId: 'a', alarmId: 'alarm-a', state: 'confirmed', updatedAtMillis: 1000 }, + { scheduleId: 'b', alarmId: 'alarm-b', state: 'pending', updatedAtMillis: 2000 }, + { scheduleId: 'c', alarmId: 'alarm-c', state: 'snoozed', updatedAtMillis: 3000 }, + { scheduleId: 'd', alarmId: 'alarm-d', state: 'unknown-state', updatedAtMillis: 4000 }, + { scheduleId: '', alarmId: 'alarm-e', state: 'confirmed', updatedAtMillis: 5000 }, + ]); + const scheduler = new NativeAlarmScheduler(); + const rows = await scheduler.peekNativeDispositions(); + expect(rows).toEqual([ + { + schedule_id: 'a', + alarm_id: 'alarm-a', + state: 'confirmed', + updated_at: new Date(1000).toISOString(), + }, + { + schedule_id: 'b', + alarm_id: 'alarm-b', + state: 'pending', + updated_at: new Date(2000).toISOString(), + }, + { + schedule_id: 'c', + alarm_id: 'alarm-c', + state: 'snoozed', + updated_at: new Date(3000).toISOString(), + }, + ]); + }); + + it('peekNativeDispositions returns an empty list when the bridge is unavailable or rejects', async () => { + Platform.OS = 'ios'; + await expect(nativePeekAlarmDispositions()).resolves.toEqual([]); + Platform.OS = 'android'; + native.peekNativeDispositions.mockRejectedValue(new Error('peek failed')); + await expect(nativePeekAlarmDispositions()).resolves.toEqual([]); + }); + + it('acknowledges native dispositions by schedule id', async () => { + const scheduler = new NativeAlarmScheduler(); + await scheduler.ackNativeDispositions(['a', 'b']); + expect(native.ackNativeDispositions).toHaveBeenCalledWith(['a', 'b']); + }); + + it('nativeAckAlarmDispositions is a no-op with an empty list and swallows rejection', async () => { + await nativeAckAlarmDispositions([]); + expect(native.ackNativeDispositions).not.toHaveBeenCalled(); + + native.ackNativeDispositions.mockRejectedValue(new Error('ack failed')); + await expect(nativeAckAlarmDispositions(['a'])).resolves.toBeUndefined(); + }); + + it('subscribes to native fired/dismissed/snoozed events and ignores unknown types', () => { + const received: unknown[] = []; + const unsubscribe = subscribeNativeAlarmEvents((event) => received.push(event)); + + emit('TimeflowAlarmEvent', { + type: 'fired', + scheduleId: 'a', + alarmId: 'alarm-a', + title: '晨会', + atMillis: 1000, + }); + emit('TimeflowAlarmEvent', { type: 'unknown', scheduleId: 'b' }); + + expect(received).toEqual([ + { + type: 'fired', + scheduleId: 'a', + alarmId: 'alarm-a', + title: '晨会', + atMillis: 1000, + }, + ]); + + unsubscribe(); + }); + + it('NativeAlarmScheduler.subscribe maps native event payloads to the port shape', () => { + const scheduler = new NativeAlarmScheduler(); + const received: unknown[] = []; + const unsubscribe = scheduler.subscribe((event) => received.push(event)); + + emit('TimeflowAlarmEvent', { + type: 'dismissed', + scheduleId: 'a', + alarmId: 'alarm-a', + title: '晨会', + atMillis: 1000, + }); + + expect(received).toEqual([ + { + type: 'dismissed', + schedule_id: 'a', + alarm_id: 'alarm-a', + title: '晨会', + at: new Date(1000).toISOString(), + }, + ]); + + unsubscribe(); + }); + + it('subscribe falls back to a no-op unsubscribe when the bridge is unavailable', () => { + Platform.OS = 'ios'; + const unsubscribe = subscribeNativeAlarmEvents(() => undefined); + expect(() => unsubscribe()).not.toThrow(); + }); + + it('requests permission settings and notification permission through the bridge', async () => { + await expect(nativeOpenAlarmPermissionSettings('exactAlarm')).resolves.toBe(true); + expect(native.openPermissionSettings).toHaveBeenCalledWith('exactAlarm'); + + await expect(nativeRequestNotificationPermission()).resolves.toBe(true); + expect(native.requestNotificationPermission).toHaveBeenCalledTimes(1); + }); + + it('permission-settings and notification-permission calls fail closed off Android', async () => { + Platform.OS = 'ios'; + await expect(nativeOpenAlarmPermissionSettings('app')).resolves.toBe(false); + await expect(nativeRequestNotificationPermission()).resolves.toBe(false); + }); + + it('permission-settings and notification-permission calls fail closed on rejection', async () => { + native.openPermissionSettings.mockRejectedValue(new Error('settings failed')); + native.requestNotificationPermission.mockRejectedValue(new Error('permission failed')); + await expect(nativeOpenAlarmPermissionSettings('app')).resolves.toBe(false); + await expect(nativeRequestNotificationPermission()).resolves.toBe(false); + }); }); diff --git a/frontend/tests/unit/infrastructure/notifications/nativeDeviceCapability.test.ts b/frontend/tests/unit/infrastructure/notifications/nativeDeviceCapability.test.ts index e14c96ea..db1eee26 100644 --- a/frontend/tests/unit/infrastructure/notifications/nativeDeviceCapability.test.ts +++ b/frontend/tests/unit/infrastructure/notifications/nativeDeviceCapability.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; -import { AppState, Platform } from 'react-native'; +import { AppState, Linking, Platform } from 'react-native'; import { NativeDeviceCapability } from '../../../../src/infrastructure/notifications/NativeDeviceCapability'; import { @@ -27,6 +27,38 @@ const requestNotifications = nativeRequestNotificationPermission as jest.MockedF typeof nativeRequestNotificationPermission >; +type PermissionResponse = { status: string; canAskAgain: boolean }; + +const getForeground = jest.fn<() => Promise>(); +const requestForeground = jest.fn<() => Promise>(); +const getBackground = jest.fn<() => Promise>(); +const requestBackground = jest.fn<() => Promise>(); + +function fakeLocationModule() { + return { + getForegroundPermissionsAsync: getForeground, + requestForegroundPermissionsAsync: requestForeground, + getBackgroundPermissionsAsync: getBackground, + requestBackgroundPermissionsAsync: requestBackground, + PermissionStatus: { GRANTED: 'granted', DENIED: 'denied', UNDETERMINED: 'undetermined' }, + } as unknown as typeof import('expo-location'); +} + +function newDevice( + loadLocationModule: () => Promise = () => + Promise.resolve(fakeLocationModule()), +) { + return new NativeDeviceCapability(loadLocationModule); +} + +function granted(): PermissionResponse { + return { status: 'granted', canAskAgain: true }; +} + +function denied(canAskAgain = true): PermissionResponse { + return { status: 'denied', canAskAgain }; +} + describe('NativeDeviceCapability', () => { beforeEach(() => { Platform.OS = 'android'; @@ -34,11 +66,15 @@ describe('NativeDeviceCapability', () => { getStatus.mockReset(); openSettings.mockReset().mockResolvedValue(true); requestNotifications.mockReset().mockResolvedValue(true); + getForeground.mockReset().mockResolvedValue(denied()); + requestForeground.mockReset().mockResolvedValue(denied()); + getBackground.mockReset().mockResolvedValue(denied()); + requestBackground.mockReset().mockResolvedValue(denied()); }); it('reports unsupported when the native module is unavailable', async () => { available.mockReturnValue(false); - const device = new NativeDeviceCapability(); + const device = newDevice(); await expect(device.getStatus()).resolves.toMatchObject({ platform: 'android', supported: false, @@ -58,32 +94,13 @@ describe('NativeDeviceCapability', () => { it('reports unsupported when permission status is missing', async () => { getStatus.mockResolvedValue(null); - const device = new NativeDeviceCapability(); + const device = newDevice(); await expect(device.getStatus()).resolves.toMatchObject({ supported: false, background_execution: false, }); }); - it('reports unsupported when the native status read rejects', async () => { - getStatus.mockRejectedValue(new Error('bridge unavailable')); - const device = new NativeDeviceCapability(); - await expect(device.getStatus()).resolves.toMatchObject({ - platform: 'android', - supported: false, - background_execution: false, - permissions: { - notifications: false, - exact_alarm: false, - overlay: false, - full_screen: false, - battery_optimization: false, - location_foreground: false, - location_background: false, - }, - }); - }); - it('maps native permission flags onto the device port', async () => { getStatus.mockResolvedValue({ exactAlarm: true, @@ -92,7 +109,7 @@ describe('NativeDeviceCapability', () => { notifications: true, battery: true, }); - const device = new NativeDeviceCapability(); + const device = newDevice(); await expect(device.getStatus()).resolves.toEqual({ platform: 'android', supported: true, @@ -109,42 +126,200 @@ describe('NativeDeviceCapability', () => { }); }); - it('requests notification permission through the native prompt', async () => { - const device = new NativeDeviceCapability(); - await expect(device.requestPermission('notifications')).resolves.toBe(true); - expect(requestNotifications).toHaveBeenCalledTimes(1); - expect(openSettings).not.toHaveBeenCalled(); + it.each<[typeof Platform.OS, string]>([ + ['ios', 'ios'], + ['web', 'web'], + ['windows', 'unknown'], + ])('maps Platform.OS %s to platform %s', async (os, platform) => { + Platform.OS = os; + available.mockReturnValue(false); + const device = newDevice(); + await expect(device.getStatus()).resolves.toMatchObject({ platform }); }); - it('returns false when the native notification permission request rejects', async () => { - requestNotifications.mockRejectedValue(new Error('prompt failed')); - const device = new NativeDeviceCapability(); - await expect(device.requestPermission('notifications')).resolves.toBe(false); + it('reports location foreground/background permissions read from expo-location', async () => { + getForeground.mockResolvedValue(granted()); + getBackground.mockResolvedValue(granted()); + getStatus.mockResolvedValue({ + exactAlarm: true, + overlay: true, + fullScreen: true, + notifications: true, + battery: true, + }); + const device = newDevice(); + await expect(device.getStatus()).resolves.toMatchObject({ + permissions: { + location_foreground: true, + location_background: true, + }, + }); }); - it('returns false when opening settings rejects', async () => { - openSettings.mockRejectedValue(new Error('intent failed')); - const device = new NativeDeviceCapability(); - await expect(device.openSettings('overlay')).resolves.toBe(false); - await expect(device.requestPermission('exact_alarm')).resolves.toBe(false); + it('reports location permissions as false when the expo-location read throws', async () => { + getForeground.mockRejectedValue(new Error('location module unavailable')); + getStatus.mockResolvedValue({ + exactAlarm: true, + overlay: true, + fullScreen: true, + notifications: true, + battery: true, + }); + const device = newDevice(); + await expect(device.getStatus()).resolves.toMatchObject({ + permissions: { + location_foreground: false, + location_background: false, + }, + }); }); - it('opens the matching settings page for non-notification permissions', async () => { - const device = new NativeDeviceCapability(); + it('reports location permissions as false when the location module fails to load', async () => { + const device = newDevice(() => Promise.resolve(null)); + getStatus.mockResolvedValue({ + exactAlarm: true, + overlay: true, + fullScreen: true, + notifications: true, + battery: true, + }); + await expect(device.getStatus()).resolves.toMatchObject({ + permissions: { + location_foreground: false, + location_background: false, + }, + }); + expect(getForeground).not.toHaveBeenCalled(); + }); + + it('requestPermission for location fails when the location module fails to load', async () => { + const device = newDevice(() => Promise.resolve(null)); + await expect(device.requestPermission('location_foreground')).resolves.toBe(false); + expect(getForeground).not.toHaveBeenCalled(); + }); + + it('requests notification permission through the native prompt', async () => { + const device = newDevice(); + await expect(device.requestPermission('notifications')).resolves.toBe(true); + expect(requestNotifications).toHaveBeenCalledTimes(1); + expect(openSettings).not.toHaveBeenCalled(); + }); + + it('opens the matching settings page for non-notification, non-location permissions', async () => { + const device = newDevice(); await expect(device.requestPermission('exact_alarm')).resolves.toBe(true); await expect(device.openSettings('overlay')).resolves.toBe(true); await expect(device.openSettings('full_screen')).resolves.toBe(true); await expect(device.openSettings('battery_optimization')).resolves.toBe(true); - await expect(device.openSettings('location_foreground')).resolves.toBe(true); expect(openSettings.mock.calls.map((call) => call[0])).toEqual([ 'exactAlarm', 'overlay', 'fullScreen', 'battery', - 'app', ]); }); + describe('location permission requests', () => { + it('requestPermission(location_foreground) grants immediately when already granted', async () => { + getForeground.mockResolvedValue(granted()); + const device = newDevice(); + await expect(device.requestPermission('location_foreground')).resolves.toBe(true); + expect(requestForeground).not.toHaveBeenCalled(); + }); + + it('requestPermission(location_foreground) prompts when not yet granted and can still ask', async () => { + getForeground.mockResolvedValue(denied(true)); + requestForeground.mockResolvedValue(granted()); + const device = newDevice(); + await expect(device.requestPermission('location_foreground')).resolves.toBe(true); + expect(requestForeground).toHaveBeenCalledTimes(1); + }); + + it('requestPermission(location_foreground) fails closed without prompting when the system will not ask again', async () => { + getForeground.mockResolvedValue(denied(false)); + const device = newDevice(); + await expect(device.requestPermission('location_foreground')).resolves.toBe(false); + expect(requestForeground).not.toHaveBeenCalled(); + }); + + it('requestPermission(location_background) escalates through foreground then background', async () => { + getForeground.mockResolvedValue(granted()); + getBackground.mockResolvedValue(denied(true)); + requestBackground.mockResolvedValue(granted()); + const device = newDevice(); + await expect(device.requestPermission('location_background')).resolves.toBe(true); + expect(requestForeground).not.toHaveBeenCalled(); + expect(requestBackground).toHaveBeenCalledTimes(1); + }); + + it('requestPermission(location_background) requests foreground first when missing', async () => { + getForeground.mockResolvedValue(denied(true)); + requestForeground.mockResolvedValue(granted()); + getBackground.mockResolvedValue(granted()); + const device = newDevice(); + await expect(device.requestPermission('location_background')).resolves.toBe(true); + expect(requestForeground).toHaveBeenCalledTimes(1); + }); + + it('requestPermission(location_background) fails when foreground cannot be granted', async () => { + getForeground.mockResolvedValue(denied(false)); + const device = newDevice(); + await expect(device.requestPermission('location_background')).resolves.toBe(false); + expect(requestBackground).not.toHaveBeenCalled(); + }); + + it('requestPermission(location_background) fails when the foreground prompt is declined', async () => { + getForeground.mockResolvedValue(denied(true)); + requestForeground.mockResolvedValue(denied()); + const device = newDevice(); + await expect(device.requestPermission('location_background')).resolves.toBe(false); + expect(requestBackground).not.toHaveBeenCalled(); + }); + + it('requestPermission(location_background) already-granted background short-circuits', async () => { + getForeground.mockResolvedValue(granted()); + getBackground.mockResolvedValue(granted()); + const device = newDevice(); + await expect(device.requestPermission('location_background')).resolves.toBe(true); + expect(requestBackground).not.toHaveBeenCalled(); + }); + + it('requestPermission(location_background) fails closed when background cannot ask again', async () => { + getForeground.mockResolvedValue(granted()); + getBackground.mockResolvedValue(denied(false)); + const device = newDevice(); + await expect(device.requestPermission('location_background')).resolves.toBe(false); + expect(requestBackground).not.toHaveBeenCalled(); + }); + + it('requestPermission for location fails closed when the expo-location module throws', async () => { + getForeground.mockRejectedValue(new Error('module unavailable')); + const device = newDevice(); + await expect(device.requestPermission('location_foreground')).resolves.toBe(false); + }); + }); + + describe('openSettings for location permissions', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('opens the OS app settings page via Linking for foreground/background location', async () => { + const openLinkingSettings = jest.spyOn(Linking, 'openSettings').mockResolvedValue(); + const device = newDevice(); + await expect(device.openSettings('location_foreground')).resolves.toBe(true); + await expect(device.openSettings('location_background')).resolves.toBe(true); + expect(openLinkingSettings).toHaveBeenCalledTimes(2); + expect(openSettings).not.toHaveBeenCalled(); + }); + + it('returns false when Linking.openSettings rejects', async () => { + jest.spyOn(Linking, 'openSettings').mockRejectedValue(new Error('cannot open settings')); + const device = newDevice(); + await expect(device.openSettings('location_foreground')).resolves.toBe(false); + }); + }); + describe('onAppActive', () => { afterEach(() => { jest.restoreAllMocks(); @@ -156,7 +331,7 @@ describe('NativeDeviceCapability', () => { .spyOn(AppState, 'addEventListener') .mockReturnValue({ remove } as unknown as ReturnType); - const device = new NativeDeviceCapability(); + const device = newDevice(); const listener = jest.fn(); device.onAppActive(listener); @@ -176,7 +351,7 @@ describe('NativeDeviceCapability', () => { .spyOn(AppState, 'addEventListener') .mockReturnValue({ remove } as unknown as ReturnType); - const device = new NativeDeviceCapability(); + const device = newDevice(); const unsubscribe = device.onAppActive(jest.fn()); expect(remove).not.toHaveBeenCalled(); diff --git a/frontend/tests/unit/infrastructure/notifications/reactNativeAlertDialog.test.ts b/frontend/tests/unit/infrastructure/notifications/reactNativeAlertDialog.test.ts new file mode 100644 index 00000000..1874d722 --- /dev/null +++ b/frontend/tests/unit/infrastructure/notifications/reactNativeAlertDialog.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { Alert } from 'react-native'; + +import { ReactNativeAlertDialog } from '../../../../src/infrastructure/notifications/ReactNativeAlertDialog'; + +describe('ReactNativeAlertDialog', () => { + it('forwards title, message, buttons, and options to Alert.alert', async () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => undefined); + const onPress = jest.fn(); + const onDismiss = jest.fn(); + + const dialog = new ReactNativeAlertDialog(); + await dialog.show({ + title: '标题', + message: '内容', + buttons: [{ text: '确定', style: 'default', onPress }], + cancelable: false, + onDismiss, + }); + + expect(alertSpy).toHaveBeenCalledWith( + '标题', + '内容', + [{ text: '确定', style: 'default', onPress }], + { cancelable: false, onDismiss }, + ); + + alertSpy.mockRestore(); + }); + + it('defaults cancelable to true when not specified', async () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => undefined); + const dialog = new ReactNativeAlertDialog(); + await dialog.show({ title: 't', message: 'm', buttons: [] }); + + expect(alertSpy).toHaveBeenCalledWith('t', 'm', [], { + cancelable: true, + onDismiss: undefined, + }); + + alertSpy.mockRestore(); + }); +}); diff --git a/frontend/tests/unit/infrastructure/notifications/reactNativeVibration.test.ts b/frontend/tests/unit/infrastructure/notifications/reactNativeVibration.test.ts new file mode 100644 index 00000000..d73edce9 --- /dev/null +++ b/frontend/tests/unit/infrastructure/notifications/reactNativeVibration.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { Platform, Vibration } from 'react-native'; + +import { ReactNativeVibration } from '../../../../src/infrastructure/notifications/ReactNativeVibration'; + +const REPEAT_PATTERN = [0, 700, 350, 700]; + +describe('ReactNativeVibration', () => { + let vibrateSpy: jest.SpiedFunction; + let cancelSpy: jest.SpiedFunction; + + beforeEach(() => { + jest.useFakeTimers(); + vibrateSpy = jest.spyOn(Vibration, 'vibrate').mockImplementation(() => undefined); + cancelSpy = jest.spyOn(Vibration, 'cancel').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + Platform.OS = 'android'; + }); + + it('vibrates in a looping pattern on Android without an interval timer', async () => { + Platform.OS = 'android'; + const vibration = new ReactNativeVibration(); + await vibration.vibrate(); + + expect(cancelSpy).toHaveBeenCalledTimes(1); + expect(vibrateSpy).toHaveBeenCalledTimes(1); + expect(vibrateSpy).toHaveBeenCalledWith(REPEAT_PATTERN, true); + + vibrateSpy.mockClear(); + jest.advanceTimersByTime(10_000); + expect(vibrateSpy).not.toHaveBeenCalled(); + }); + + it('vibrates once and re-triggers on a 2s interval on iOS', async () => { + Platform.OS = 'ios'; + const vibration = new ReactNativeVibration(); + await vibration.vibrate(); + + expect(vibrateSpy).toHaveBeenCalledTimes(1); + expect(vibrateSpy).toHaveBeenCalledWith(REPEAT_PATTERN); + + jest.advanceTimersByTime(2_000); + expect(vibrateSpy).toHaveBeenCalledTimes(2); + jest.advanceTimersByTime(2_000); + expect(vibrateSpy).toHaveBeenCalledTimes(3); + }); + + it('cancels the previous iOS interval timer when vibrate is called again', async () => { + Platform.OS = 'ios'; + const vibration = new ReactNativeVibration(); + await vibration.vibrate(); + vibrateSpy.mockClear(); + await vibration.vibrate(); + + jest.advanceTimersByTime(2_000); + // 只应该有新那个定时器的一次触发,不是新旧两个定时器叠加成两次。 + expect(vibrateSpy).toHaveBeenCalledTimes(2); + }); + + it('stop cancels vibration and clears the iOS interval timer', async () => { + Platform.OS = 'ios'; + const vibration = new ReactNativeVibration(); + await vibration.vibrate(); + vibrateSpy.mockClear(); + cancelSpy.mockClear(); + + await vibration.stop(); + expect(cancelSpy).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(10_000); + expect(vibrateSpy).not.toHaveBeenCalled(); + }); + + it('stop is safe to call without a prior vibrate (no timer to clear)', async () => { + const vibration = new ReactNativeVibration(); + cancelSpy.mockClear(); + await expect(vibration.stop()).resolves.toBeUndefined(); + expect(cancelSpy).toHaveBeenCalledTimes(1); + }); +});