diff --git a/backend/src/timeflow/intelligence/realtime/agent.py b/backend/src/timeflow/intelligence/realtime/agent.py index 5f9cdcaf..12887f19 100644 --- a/backend/src/timeflow/intelligence/realtime/agent.py +++ b/backend/src/timeflow/intelligence/realtime/agent.py @@ -327,6 +327,9 @@ def __init__( self._question_id_factory = question_id_factory # None between replies; assigned on first use so each reply gets a fresh id. self._audio_id: str | None = None + # Survives the reset below so a late barge-in can still name the audio the + # phone is playing -- the model finishes generating well before playback ends. + self._last_audio_id: str | None = None self._reply_id: str | None = None self._spoken = "" self._purpose = REPLY_PURPOSE @@ -373,6 +376,7 @@ async def audio(self, data: bytes) -> None: """Queue one chunk, starting the delivery on the first one.""" if self._speaking is None: self._audio_id = self._audio_id_factory() + self._last_audio_id = self._audio_id self._speaking = asyncio.create_task(self._speak()) await self._audio.put(data) @@ -477,10 +481,12 @@ async def _finish_reply(self, *, canceled: bool) -> None: # own before the barge-in arrived -- but the model generates audio faster # than it plays back, so the phone can still be sounding it out. The session # only calls interrupted() this late when its own playable-until estimate - # says that's still plausible, so the client is told to stop regardless of - # what this turn still has queued locally. audio_id is empty because there - # is no reply left here to name; the client's handler does not read it. - await self._result_sink.deliver_canceled(AudioCanceled(audio_id=""), self._stream) + # says that's still plausible. Name the original audio so the client can + # tell this stale cancellation apart from a newer reply already starting. + if self._last_audio_id is not None: + await self._result_sink.deliver_canceled( + AudioCanceled(audio_id=self._last_audio_id), self._stream + ) self._spoken = "" self._reply_id = None self._audio_id = None diff --git a/backend/tests/intelligence/realtime/test_realtime_agent.py b/backend/tests/intelligence/realtime/test_realtime_agent.py index 14043b58..ae5b07ac 100644 --- a/backend/tests/intelligence/realtime/test_realtime_agent.py +++ b/backend/tests/intelligence/realtime/test_realtime_agent.py @@ -495,8 +495,8 @@ def test_a_barge_in_after_the_reply_already_finished_sending_still_tells_the_cli back, so a reply can finish sending -- turn_completed() already settled it -- while the phone is still sounding it out. The session only calls interrupted() this late when its own playable-until estimate says that is still plausible, so the client - must still be told to stop even though this turn's own bookkeeping has nothing left - to name; there is no reply id left here to attach to it, hence the empty audio_id. + must still be told to stop even though this turn's current bookkeeping has already + been reset. The original audio id must be preserved so a newer reply is not stopped. """ async def scenario() -> None: @@ -515,7 +515,29 @@ async def scenario() -> None: ) (canceled,) = [payload for kind, payload in sink.calls if kind == "canceled"] - assert canceled.audio_id == "" + (audio_reply,) = [payload for kind, payload in sink.calls if kind == "audio_start"] + assert canceled.audio_id == audio_reply.audio_id + + asyncio.run(scenario()) + + +def test_a_barge_in_before_any_reply_started_sends_no_cancellation() -> None: + """interrupted() with nothing ever spoken has no audio id to preserve or send. + + _last_audio_id is only set once audio() queues a first chunk; a barge-in that + lands before the model has said anything (or produced any audio) leaves it None, + and there is nothing playing on the phone for the client to stop. + """ + + async def scenario() -> None: + sink = RecordingSink() + session = ScriptedSession([("interrupted", ())]) + + await RealtimeAgent(ScriptedFactory(session), sink).handle_audio( + _open_mic(), _Stream(voice_mode="continuous") + ) + + assert [kind for kind, _ in sink.calls if kind == "canceled"] == [] asyncio.run(scenario()) diff --git a/frontend/src/app/AppProviders.tsx b/frontend/src/app/AppProviders.tsx index 9fb78e24..62ee61fc 100644 --- a/frontend/src/app/AppProviders.tsx +++ b/frontend/src/app/AppProviders.tsx @@ -1,7 +1,8 @@ -import { type PropsWithChildren, useEffect } from 'react'; +import { type PropsWithChildren, useCallback, useEffect } from 'react'; import type { AuthController, AuthInvalidationCoordinator } from '../features/auth/application'; import { AuthProvider, useAuth } from '../features/auth/presentation/AuthProvider'; +import { useReminderPermissionsOnLaunch } from '../features/reminder'; import { AppServicesProvider } from './composition/AppServicesProvider'; import type { AppServices } from './composition/createAppServices'; @@ -19,26 +20,37 @@ export function AppProviders({ return ( - + {children} ); } -function AuthenticatedRuntime({ runtime }: { readonly runtime: AppServices['runtime'] }) { +function AuthenticatedRuntime({ services }: { readonly services: AppServices }) { const { viewState } = useAuth(); + const isAuthenticated = viewState.status === 'authenticated'; + + const onPermissionsUpdated = useCallback(() => { + void services.reminder.rebuild(); + }, [services]); + + useReminderPermissionsOnLaunch( + isAuthenticated ? services.reminderPorts.device : null, + isAuthenticated ? services.alertDialog : null, + onPermissionsUpdated, + ); useEffect(() => { - if (viewState.status !== 'authenticated') { + if (!isAuthenticated) { return; } - void runtime.start(); + void services.runtime.start(); return () => { - void runtime.stop(); + void services.runtime.stop(); }; - }, [runtime, viewState.status]); + }, [isAuthenticated, services]); return null; } diff --git a/frontend/src/app/composition/.gitkeep b/frontend/src/app/composition/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/app/composition/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/app/composition/createAppServices.ts b/frontend/src/app/composition/createAppServices.ts index 4ea1065a..41757fe1 100644 --- a/frontend/src/app/composition/createAppServices.ts +++ b/frontend/src/app/composition/createAppServices.ts @@ -1,30 +1,38 @@ import { AppRuntime } from '../orchestration/AppRuntime'; import { createAuthRuntime, type AuthRuntime, type CreateAuthRuntimeOptions } from '../authRuntime'; import type { + AlertDialogPort, ReminderApplicationDependencies, ReminderApplicationPort, } from '../../features/reminder/application/interfaces'; import { LocalReminderApplication } from '../../features/reminder/application'; import { + LocalReminderDelivery, LocalReminderDispositionSync, + LocalReminderRecovery, + NoopPopup, SqliteLocalScheduleReader, SqliteReminderStateStore, } from '../../features/reminder/data/local'; +import { AlertReminderPresenter } from '../../features/reminder/presentation'; import { ExpoAudioPlayback } from '../../infrastructure/audio'; import { ExpoLocationMonitor } from '../../infrastructure/location'; import { - MockPopup, - MockReminderRecovery, - MockReminderDelivery, - MockSystemNotification, - MockVibration, + ExpoSystemNotification, NativeAlarmScheduler, NativeDeviceCapability, + ReactNativeAlertDialog, + ReactNativeVibration, } from '../../infrastructure/notifications'; import { IntervalTimeListener } from '../../infrastructure/time'; -import { MockReminderPresenter } from '../../features/reminder/presentation'; import { ScheduleViewStore } from '../../features/schedule/presentation'; +export interface CreateAppServicesOptions { + readonly auth?: CreateAuthRuntimeOptions; + readonly schedules?: SqliteLocalScheduleReader; + readonly overrides?: Partial; +} + export type AppServices = { auth: AuthRuntime; protectedClient: AuthRuntime['protectedClient']; @@ -35,33 +43,43 @@ export type AppServices = { scheduleView: ScheduleViewStore; schedules: SqliteLocalScheduleReader; webSocketClient: AuthRuntime['webSocketClient']; + alertDialog: AlertDialogPort; }; -export interface CreateAppServicesOptions { - readonly auth?: CreateAuthRuntimeOptions; -} - /** 应用唯一组合根:认证传输、功能服务、生命周期和账号内存清理在此接线。 */ export function createAppServices(options: CreateAppServicesOptions = {}): AppServices { const auth = createAuthRuntime(options.auth); - const schedules = new SqliteLocalScheduleReader(); + const alertDialog = new ReactNativeAlertDialog(); + const schedules = options.schedules ?? new SqliteLocalScheduleReader(); const reminderState = new SqliteReminderStateStore(); + const presenter = + (options.overrides?.presenter as AlertReminderPresenter | undefined) ?? + new AlertReminderPresenter(alertDialog); + + const { + schedules: _ignoredSchedules, + presenter: _ignoredPresenter, + ...restOverrides + } = options.overrides ?? {}; + const reminderPorts: ReminderApplicationDependencies = { - schedules, time: new IntervalTimeListener(), location: new ExpoLocationMonitor(), alarms: new NativeAlarmScheduler(), - delivery: new MockReminderDelivery(), + delivery: new LocalReminderDelivery(), audio: new ExpoAudioPlayback(), device: new NativeDeviceCapability(), - presenter: new MockReminderPresenter(), - systemNotification: new MockSystemNotification(), - popup: new MockPopup(), - vibration: new MockVibration(), - recovery: new MockReminderRecovery(), + systemNotification: new ExpoSystemNotification(), + popup: new NoopPopup(), + vibration: new ReactNativeVibration(), + recovery: new LocalReminderRecovery(), state: reminderState, dispositionSync: new LocalReminderDispositionSync(), + ...restOverrides, + schedules, + presenter, }; + const reminder = new LocalReminderApplication(reminderPorts); const scheduleView = new ScheduleViewStore(); const runtime = new AppRuntime([ @@ -84,5 +102,6 @@ export function createAppServices(options: CreateAppServicesOptions = {}): AppSe scheduleView, schedules, webSocketClient: auth.webSocketClient, + alertDialog, }; } diff --git a/frontend/src/app/orchestration/.gitkeep b/frontend/src/app/orchestration/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/app/orchestration/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts index 09ea00fb..6ac0097c 100644 --- a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts +++ b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts @@ -57,6 +57,8 @@ export class AssistantContinuousConversationService implements AssistantApplicat private streamId: string | null = null; /** 非 null 表示当前正处于 voice.tts.start 和 voice.tts.end/canceled 之间。 */ private currentAudioId: string | null = null; + /** 最近被打断的音频 id;服务端会在 canceled 后补发同 id 的 tts.end。 */ + private canceledAudioId: string | null = null; private streamStartedWaiter: ((conversationId: string) => void) | null = null; /** 跟 streamStartedWaiter 配对;传输层报错/连接掉线时用它让等待方结束,不然会永远卡住。 */ private streamStartRejecter: ((error: Error) => void) | null = null; @@ -89,6 +91,8 @@ export class AssistantContinuousConversationService implements AssistantApplicat * startStream() 内部有一个没人等的 await(配置原生播放器),如果紧跟着的 * 第一块音频不排在它后面,可能在原生侧还没配置完时就到达。 */ private playbackChain: Promise = Promise.resolve(); + /** 取消时递增,让已经排队但尚未执行的旧流操作失效。 */ + private playbackGeneration = 0; /** Category events can arrive before the command result creates their local row. */ private readonly pendingCategoryUpdates = new Map(); private disposed = false; @@ -253,7 +257,7 @@ export class AssistantContinuousConversationService implements AssistantApplicat this.replyText = null; this.currentAudioId = null; this.notifyListeners(); - await this.deps.playback.stop().catch(() => {}); + await this.stopPlaybackImmediately(); } /** 用户点圆圈暂停/恢复。暂停期间空闲计时器照常跑——忘记恢复也会兜底挂断。 */ @@ -397,6 +401,8 @@ export class AssistantContinuousConversationService implements AssistantApplicat this.notifyListeners(); return; case 'voice.tts.start': + this.playbackGeneration += 1; + this.canceledAudioId = null; this.currentAudioId = message.audio_id; this.setState({ conversationId: message.conversation_id, phase: 'speaking' }); this.chainPlayback(() => @@ -407,7 +413,16 @@ export class AssistantContinuousConversationService implements AssistantApplicat ); return; case 'voice.tts.end': + // canceled 后服务端仍会补发同 id 的 tts.end;它不能收尾新流,也不能把 + // interrupted 状态提前改回 listening。 + if ( + (this.canceledAudioId !== null && message.audio_id === this.canceledAudioId) || + (this.currentAudioId !== null && message.audio_id !== this.currentAudioId) + ) { + return; + } this.currentAudioId = null; + this.canceledAudioId = null; this.chainPlayback(() => this.deps.playback.endStream()); this.setState({ conversationId: message.conversation_id, phase: 'listening' }); // 播报完成后给一个全新的窗口,对应"播报完成后进入短暂等待"——不用单独 @@ -415,10 +430,17 @@ export class AssistantContinuousConversationService implements AssistantApplicat this.armIdleTimer(); return; case 'voice.tts.canceled': - // 用户开口打断了正在播的回复:立刻丢掉播放端缓冲里还没放出来的音频, - // 而不是等 voice.tts.end(后面仍会补发,但语义已经不是"正常说完")。 + // 用户开口打断了正在播的回复:stop 必须绕过 playbackChain 立即执行,否则 + // 已排队的 PCM 会先继续喂给原生播放器;旧队列随后由代次检查丢弃。 + if ( + this.currentAudioId !== null && + (message.audio_id === '' || message.audio_id !== this.currentAudioId) + ) { + return; + } + this.canceledAudioId = message.audio_id || this.currentAudioId; this.currentAudioId = null; - this.chainPlayback(() => this.deps.playback.stop()); + void this.stopPlaybackImmediately(); this.setState({ conversationId: message.conversation_id, phase: 'interrupted' }); return; case 'voice.session.end': @@ -501,9 +523,25 @@ export class AssistantContinuousConversationService implements AssistantApplicat } /** 把一次对原生播放模块的调用接到 playbackChain 末尾,保证上一次真正执行完 - * (不管成功与否)才轮到这一次。 */ + * (不管成功与否)才轮到这一次;取消后旧代次的操作会被跳过。 */ private chainPlayback(run: () => Promise): void { - this.playbackChain = this.playbackChain.then(run).catch(() => {}); + const generation = this.playbackGeneration; + this.playbackChain = this.playbackChain + .then(async () => { + if (generation !== this.playbackGeneration) { + return; + } + await run(); + }) + .catch(() => {}); + } + + /** 立即清空原生播放器,并把后续新操作排在 stop 完成之后。 */ + private async stopPlaybackImmediately(): Promise { + this.playbackGeneration += 1; + const stop = this.deps.playback.stop().catch(() => {}); + this.playbackChain = stop; + await stop; } private handleClose(event: { code: number; reason: string }): void { diff --git a/frontend/src/features/assistant/application/AssistantConversationService.ts b/frontend/src/features/assistant/application/AssistantConversationService.ts index 3f27ac60..16d05bbd 100644 --- a/frontend/src/features/assistant/application/AssistantConversationService.ts +++ b/frontend/src/features/assistant/application/AssistantConversationService.ts @@ -342,6 +342,9 @@ export class AssistantConversationService implements AssistantApplicationPort { } private handleClose(event: { code: number; reason: string }): void { + // 必须真的执行:切到连续对话时共享 WS 会因 voiceMode 不同断开重连,只置空的话 + // 旧服务仍订阅着新连接的 TTS/PCM,同一句话会被两个服务重复送进播放器。 + this.unsubscribeConnection?.(); this.connection = null; this.unsubscribeConnection = null; const message = event.reason || `连接已断开(${event.code})`; diff --git a/frontend/src/features/reminder/application/interfaces/.gitkeep b/frontend/src/features/reminder/application/interfaces/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/features/reminder/application/interfaces/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/features/reminder/data/local/.gitkeep b/frontend/src/features/reminder/data/local/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/features/reminder/data/local/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/features/reminder/domain/.gitkeep b/frontend/src/features/reminder/domain/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/features/reminder/domain/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/features/reminder/index.ts b/frontend/src/features/reminder/index.ts index 698a7b5c..69a84f55 100644 --- a/frontend/src/features/reminder/index.ts +++ b/frontend/src/features/reminder/index.ts @@ -18,9 +18,27 @@ export type { ReminderTrigger, ReminderTriggerReason, ReminderType, + GeofenceTransition, + GeofenceWatchMode, + StrengthDeliveryPlan, +} from './domain'; +export { + DEFAULT_SNOOZE_MINUTES, + distanceMeters, + evaluateGeofence, + isSnoozeActive, + isSnoozeExpired, + isTimeWindowReached, + resolveEffectiveTriggerAt, + resolveGeofenceCenter, + resolveSnoozeUntil, + resolveStrengthDeliveryPlan, + resolveTimeTriggerAt, + resolveWatchMode, } from './domain'; -export { DEFAULT_SNOOZE_MINUTES } from './domain'; export type { + AlarmNativeDisposition, + AlarmNativeEvent, AlarmScheduleReceipt, AlarmScheduleRequest, AlarmSchedulerPort, @@ -34,9 +52,13 @@ export type { LocalTimeTick, LocationMonitorEvent, LocationMonitorPort, + LocationRebuildTarget, LocationWatchHandle, LocationWatchMode, LocationWatchRequest, + AlertDialogButton, + AlertDialogPort, + AlertDialogRequest, PopupPort, PopupReceipt, PopupRequest, @@ -72,4 +94,5 @@ export { SqliteLocalScheduleReader, SqliteReminderStateStore, } from './data/local'; -export { MockReminderPresenter, useReminderPermissionsOnLaunch } from './presentation'; +export { AlertReminderPresenter, useReminderPermissionsOnLaunch } from './presentation'; +export type { ReminderActionHandler, ReminderViewModel } from './presentation'; diff --git a/frontend/src/features/reminder/presentation/.gitkeep b/frontend/src/features/reminder/presentation/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/features/reminder/presentation/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/features/reminder/presentation/AlertReminderPresenter.ts b/frontend/src/features/reminder/presentation/AlertReminderPresenter.ts new file mode 100644 index 00000000..0403bcae --- /dev/null +++ b/frontend/src/features/reminder/presentation/AlertReminderPresenter.ts @@ -0,0 +1,79 @@ +import type { + ReminderPresentationAction, + ReminderPresentationReceipt, + ReminderPresenterPort, + AlertDialogPort, +} from '../application/interfaces'; +import type { ReminderDeliveryRequest } from '../domain'; + +const MESSAGE_BY_REASON: Record = { + arrive_location: '您已进入目标地点附近,请及时处理。', + return_to_recorded_location: '您已回到记录地点附近,请及时处理。', + at_time: '已到提醒时间,请及时处理。', + before_start: '日程即将开始,请及时处理。', + snooze_expired: '延后提醒时间已到,请及时处理。', +}; + +/** 提醒展示编排;平台 Alert 由注入的 AlertDialogPort 完成。 */ +export class AlertReminderPresenter implements ReminderPresenterPort { + private readonly actionListeners = new Set< + (event: { schedule_id: string; action: ReminderPresentationAction }) => void + >(); + private visibleScheduleId: string | null = null; + private readonly suppressed = new Set(); + + constructor(private readonly dialog: AlertDialogPort) {} + + async show(request: ReminderDeliveryRequest): Promise { + this.suppressed.delete(request.schedule_id); + this.visibleScheduleId = request.schedule_id; + /* istanbul ignore next -- MESSAGE_BY_REASON has an entry for every current + * ReminderTriggerReason; this only guards a future reason added to the union + * without a matching message, which can't happen with today's types. */ + const message = MESSAGE_BY_REASON[request.trigger.reason] ?? '日程提醒已触发,请及时处理。'; + + await this.dialog.show({ + title: request.title || '日程提醒', + message, + buttons: [ + { + text: '延后', + style: 'cancel', + onPress: () => this.emit(request.schedule_id, 'snooze'), + }, + { + text: '确认', + onPress: () => this.emit(request.schedule_id, 'confirm'), + }, + ], + }); + + return { + presentation_id: `alert-${request.schedule_id}`, + visible: true, + }; + } + + async hide(scheduleId: string): Promise { + this.suppressed.add(scheduleId); + if (this.visibleScheduleId === scheduleId) { + this.visibleScheduleId = null; + } + } + + onAction( + listener: (event: { schedule_id: string; action: ReminderPresentationAction }) => void, + ): () => void { + this.actionListeners.add(listener); + return () => { + this.actionListeners.delete(listener); + }; + } + + private emit(scheduleId: string, action: ReminderPresentationAction): void { + if (this.suppressed.has(scheduleId)) return; + for (const listener of this.actionListeners) { + listener({ schedule_id: scheduleId, action }); + } + } +} diff --git a/frontend/src/features/reminder/presentation/MockReminderPresenter.ts b/frontend/src/features/reminder/presentation/MockReminderPresenter.ts deleted file mode 100644 index 34bc0fc5..00000000 --- a/frontend/src/features/reminder/presentation/MockReminderPresenter.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { - ReminderPresenterPort, - ReminderPresentationAction, - ReminderPresentationReceipt, -} from '../application/interfaces'; -import type { ReminderDeliveryRequest } from '../domain'; - -/** 应用级弹窗/悬浮层完成前使用的固定展示器。 */ -export class MockReminderPresenter implements ReminderPresenterPort { - async show(_request: ReminderDeliveryRequest): Promise { - return { - presentation_id: 'mock-presentation-001', - visible: true, - }; - } - - async hide(_scheduleId: string): Promise { - return Promise.resolve(); - } - - onAction( - _listener: (event: { schedule_id: string; action: ReminderPresentationAction }) => void, - ): () => void { - return () => undefined; - } -} diff --git a/frontend/src/features/reminder/presentation/index.ts b/frontend/src/features/reminder/presentation/index.ts index 90ea79ea..bd6e4fdc 100644 --- a/frontend/src/features/reminder/presentation/index.ts +++ b/frontend/src/features/reminder/presentation/index.ts @@ -1,3 +1,3 @@ -export { MockReminderPresenter } from './MockReminderPresenter'; +export { AlertReminderPresenter } from './AlertReminderPresenter'; export { useReminderPermissionsOnLaunch } from './useReminderPermissionsOnLaunch'; export type { ReminderActionHandler, ReminderViewModel } from './ReminderViewModel'; diff --git a/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts b/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts index a62a036d..f4954a31 100644 --- a/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts +++ b/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts @@ -1,9 +1,12 @@ import { useEffect, useRef } from 'react'; -import { Alert, AppState, type AppStateStatus, Platform } from 'react-native'; -import type { DeviceCapabilityPort, DevicePermission } from '../application/interfaces'; +import type { + AlertDialogPort, + DeviceCapabilityPort, + DevicePermission, +} from '../application/interfaces'; -const PERMISSION_ORDER: DevicePermission[] = [ +const ANDROID_ALARM_ORDER: DevicePermission[] = [ 'notifications', 'exact_alarm', 'overlay', @@ -11,6 +14,8 @@ const PERMISSION_ORDER: DevicePermission[] = [ 'battery_optimization', ]; +const LOCATION_ORDER: DevicePermission[] = ['location_foreground', 'location_background']; + const PERMISSION_PROMPTS: Partial> = { notifications: { title: '需要通知权限', @@ -32,31 +37,49 @@ const PERMISSION_PROMPTS: Partial = new Set(['notifications']); + +/** 精确闹钟没有系统授权框,只能跳设置页;先弹说明框会多一步点击,直接跳过去。 */ +const DIRECT_SETTINGS: ReadonlySet = new Set(['exact_alarm']); + +const LOCATION_PERMISSIONS: ReadonlySet = new Set([ + 'location_foreground', + 'location_background', +]); + /** - * 启动时逐项申请提醒相关权限;通过 DeviceCapabilityPort 访问平台, - * 不在 UI 层直接依赖 NativeModules。 + * 启动时逐项申请提醒相关权限;通过 DeviceCapabilityPort / AlertDialogPort 访问平台, + * 不在 UI 层直接依赖 react-native。 + * + * - Android(alarm 能力可用):闹钟相关权限 + 定位权限 + * - 其它平台 / alarm 不可用:仍申请定位权限,保证地点提醒链路可授权 + * + * @param onPermissionsUpdated 某项权限刚授权成功时回调(用于重建围栏/闹钟)。 */ -export function useReminderPermissionsOnLaunch(device: DeviceCapabilityPort | null): void { +export function useReminderPermissionsOnLaunch( + device: DeviceCapabilityPort | null, + dialog: AlertDialogPort | null, + onPermissionsUpdated?: () => void, +): void { const busyRef = useRef(false); const awaitingReturnRef = useRef(false); const skippedRef = useRef(new Set()); useEffect(() => { - if (Platform.OS !== 'android' || device == null) return; + if (device == null || dialog == null) return; let cancelled = false; - const timeouts = new Set>(); - - const schedule = (fn: () => void, ms: number) => { - const id = setTimeout(() => { - timeouts.delete(id); - if (cancelled) return; - fn(); - }, ms); - timeouts.add(id); - }; const runPrompt = () => { if (cancelled) return; @@ -66,90 +89,139 @@ export function useReminderPermissionsOnLaunch(device: DeviceCapabilityPort | nu }; const promptNext = async () => { - if (cancelled || busyRef.current) return; + if (busyRef.current || cancelled || awaitingReturnRef.current) return; busyRef.current = true; + let queueNext = false; try { const status = await device.getStatus(); - if (cancelled) return; - if (!status.supported) return; + if (status.platform === 'web' || status.platform === 'unknown') return; - const missing = PERMISSION_ORDER.find((permission) => { - if (skippedRef.current.has(permission)) return false; - return !status.permissions[permission]; - }); + const missing = nextMissingPermission(status); if (missing == null) return; const prompt = PERMISSION_PROMPTS[missing]; + /* istanbul ignore next -- PERMISSION_PROMPTS has an entry for every current + * DevicePermission; this only guards a future permission added to the union + * without a matching prompt, which can't happen with today's types. */ if (prompt == null) { skippedRef.current.add(missing); + queueNext = true; return; } - if (missing === 'notifications') { - let granted = false; - try { - granted = await device.requestPermission('notifications'); - } catch { - granted = false; + // 通知:直接系统授权框。 + if (DIRECT_REQUEST.has(missing)) { + const granted = await device.requestPermission(missing); + if (!granted) { + skippedRef.current.add(missing); + } else { + onPermissionsUpdated?.(); } - if (cancelled) return; - if (!granted) skippedRef.current.add('notifications'); - busyRef.current = false; - schedule(runPrompt, 350); + queueNext = true; return; } - const shouldAuthorize = await confirmAsync(prompt.title, prompt.message); - if (cancelled) return; + // 精确闹钟没有系统授权框,只能跳设置页,先弹说明框只会多一次点击,直接跳过去。 + if (DIRECT_SETTINGS.has(missing)) { + awaitingReturnRef.current = true; + const opened = await device.openSettings(missing); + if (!opened) { + awaitingReturnRef.current = false; + skippedRef.current.add(missing); + queueNext = true; + } + return; + } + + const shouldAuthorize = await confirmAsync(dialog, prompt.title, prompt.message); if (!shouldAuthorize) { skippedRef.current.add(missing); - } else { - let opened = false; - try { - opened = await device.openSettings(missing); - } catch { - opened = false; + queueNext = true; + return; + } + + // 定位:先等应用内说明框关掉,再申请系统权限;失败则跳转设置,避免“点了没反应还连弹”。 + if (LOCATION_PERMISSIONS.has(missing)) { + await delay(350); + const granted = await device.requestPermission(missing); + if (granted) { + onPermissionsUpdated?.(); + queueNext = true; + return; } - if (cancelled) return; - if (opened) { - awaitingReturnRef.current = true; - } else { + awaitingReturnRef.current = true; + const opened = await device.openSettings(missing); + if (!opened) { + awaitingReturnRef.current = false; skippedRef.current.add(missing); + queueNext = true; } + return; + } + + // 精确闹钟 / 悬浮窗等:跳转系统设置,回来后再继续。 + awaitingReturnRef.current = true; + const opened = await device.openSettings(missing); + if (!opened) { + awaitingReturnRef.current = false; + skippedRef.current.add(missing); + queueNext = true; } } finally { busyRef.current = false; + // 必须放在 finally 里:try 里几乎每条分支(直接请求、跳设置、弹框拒绝、 + // 定位授权/回退)都在分支末尾 return,放在 try/finally 外面的话,只有 + // 悬浮窗/全屏通知/电池优化这种"用户点了确认"的兜底分支能落到这行—— + // 其余分支设了 queueNext 也没人来消费,链就断在第一个权限上。 + if (queueNext && !awaitingReturnRef.current) { + setTimeout(runPrompt, 250); + } } + }; - if (!cancelled && !awaitingReturnRef.current) { - schedule(runPrompt, 200); + const nextMissingPermission = ( + status: Awaited>, + ): DevicePermission | null => { + if (status.platform === 'android' && status.supported) { + const alarmMissing = ANDROID_ALARM_ORDER.find((permission) => { + if (skippedRef.current.has(permission)) return false; + return !status.permissions[permission]; + }); + if (alarmMissing != null) return alarmMissing; } + + return ( + LOCATION_ORDER.find((permission) => { + if (skippedRef.current.has(permission)) return false; + return !status.permissions[permission]; + }) ?? null + ); }; - const onAppStateChange = (state: AppStateStatus) => { - if (cancelled) return; - if (state !== 'active') return; + const unsubscribe = device.onAppActive(() => { if (!awaitingReturnRef.current) return; awaitingReturnRef.current = false; - schedule(runPrompt, 300); - }; + onPermissionsUpdated?.(); + setTimeout(runPrompt, 300); + }); - schedule(runPrompt, 600); - const subscription = AppState.addEventListener('change', onAppStateChange); + const timer = setTimeout(runPrompt, 600); return () => { cancelled = true; + clearTimeout(timer); + unsubscribe(); + // device 变 null(登出)时这个 effect 会重跑清理:不重置的话,刚好卡在 + // "等用户从设置页返回"这一步登出的账号,会让下次登录后的新一轮 promptNext() + // 一直读到陈旧的 true 直接返回——不会再有新的 onAppActive 事件来清掉它 + // (应用早就是 active 的),所有权限提示永久失效,直到进程重启。 awaitingReturnRef.current = false; - for (const id of timeouts) { - clearTimeout(id); - } - timeouts.clear(); - subscription.remove(); + skippedRef.current = new Set(); }; - }, [device]); + }, [device, dialog, onPermissionsUpdated]); } -function confirmAsync(title: string, message: string): Promise { +function confirmAsync(dialog: AlertDialogPort, title: string, message: string): Promise { return new Promise((resolve) => { let settled = false; const finish = (value: boolean) => { @@ -157,14 +229,19 @@ function confirmAsync(title: string, message: string): Promise { settled = true; resolve(value); }; - Alert.alert( + void dialog.show({ title, message, - [ + cancelable: true, + onDismiss: () => finish(false), + buttons: [ { text: '暂不', style: 'cancel', onPress: () => finish(false) }, { text: '去授权', onPress: () => finish(true) }, ], - { cancelable: true, onDismiss: () => finish(false) }, - ); + }); }); } + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/frontend/src/infrastructure/audio/.gitkeep b/frontend/src/infrastructure/audio/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/infrastructure/audio/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/infrastructure/location/.gitkeep b/frontend/src/infrastructure/location/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/infrastructure/location/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/infrastructure/notifications/.gitkeep b/frontend/src/infrastructure/notifications/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/infrastructure/notifications/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/shared/time/.gitkeep b/frontend/src/shared/time/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/shared/time/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/types/.gitkeep b/frontend/src/types/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/types/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/tests/unit/app/AppProviders.test.tsx b/frontend/tests/unit/app/AppProviders.test.tsx new file mode 100644 index 00000000..ae9b7710 --- /dev/null +++ b/frontend/tests/unit/app/AppProviders.test.tsx @@ -0,0 +1,55 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { render } from '@testing-library/react-native'; +import type { PropsWithChildren } from 'react'; + +import type { AppServices } from '../../../src/app/composition/createAppServices'; + +jest.mock('../../../src/features/auth/presentation/AuthProvider', () => ({ + AuthProvider: ({ children }: PropsWithChildren) => children, + useAuth: () => ({ + viewState: { status: 'authenticated', accountId: 'acc-1', username: 'user-1' }, + }), +})); + +jest.mock('../../../src/features/reminder', () => ({ + useReminderPermissionsOnLaunch: ( + _device: unknown, + _dialog: unknown, + onPermissionsUpdated?: () => void, + ) => { + // 直接同步调用,模拟"这一次渲染就报告了权限更新",覆盖 AppProviders 把 + // 回调接到 reminder.rebuild() 上的那一行。 + onPermissionsUpdated?.(); + }, +})); + +// AppProviders imports AuthProvider/useReminderPermissionsOnLaunch; jest.mock hoists +// above imports, so this import must come after the mocks are declared. +// eslint-disable-next-line import/first +import { AppProviders } from '../../../src/app/AppProviders'; + +function createServices(): AppServices { + return { + reminder: { rebuild: jest.fn() }, + reminderPorts: { device: {} }, + alertDialog: {}, + runtime: { start: jest.fn(), stop: jest.fn() }, + protectedClient: {}, + scheduleView: {}, + webSocketClient: {}, + } as unknown as AppServices; +} + +describe('AppProviders', () => { + it('rebuilds the reminder engine once permissions are updated', () => { + const services = createServices(); + + render( + + {null} + , + ); + + expect(services.reminder.rebuild).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts b/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts index b36f55af..e9913d6b 100644 --- a/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts +++ b/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts @@ -698,6 +698,171 @@ describe('AssistantContinuousConversationService', () => { disposeService(service); }); + it('stops immediately and drops queued chunks when TTS is canceled', async () => { + const fake = createFakeConnection(); + const deps = createDeps({ connection: fake.connection }); + const service = createService(deps); + const calls: string[] = []; + let resolveFirst: () => void = () => {}; + (deps.playback.pushChunk as jest.Mock) + .mockImplementationOnce( + () => + new Promise((resolve) => { + calls.push('push-1'); + resolveFirst = resolve; + }), + ) + .mockImplementationOnce(async () => { + calls.push('push-2'); + }); + (deps.playback.stop as jest.Mock).mockImplementation(async () => { + calls.push('stop'); + }); + + await startListening(fake, service); + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + payload: { + format: 'pcm_s16le', + purpose: 'reply', + sample_rate_hz: 24000, + speech_text: '', + }, + type: 'voice.tts.start', + } as AssistantServerMessage); + await flushAsync(); + fake.emitAudioFrame(new ArrayBuffer(4)); + fake.emitAudioFrame(new ArrayBuffer(4)); + await flushAsync(); + + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + type: 'voice.tts.canceled', + } as AssistantServerMessage); + expect(calls).toEqual(['push-1', 'stop']); + + resolveFirst(); + await flushAsync(); + + // push-2 已经排在链上,但代次已经变了,必须被丢掉而不是补喂给播放器。 + expect(calls).toEqual(['push-1', 'stop']); + expect(service.getState()).toEqual({ conversationId: 'conv_001', phase: 'interrupted' }); + disposeService(service); + }); + + it('ignores the canceled stream end that arrives after interruption', async () => { + const fake = createFakeConnection(); + const deps = createDeps({ connection: fake.connection }); + const service = createService(deps); + + await startListening(fake, service); + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + payload: { + format: 'pcm_s16le', + purpose: 'reply', + sample_rate_hz: 24000, + speech_text: '', + }, + type: 'voice.tts.start', + } as AssistantServerMessage); + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + type: 'voice.tts.canceled', + } as AssistantServerMessage); + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + type: 'voice.tts.end', + } as AssistantServerMessage); + await flushAsync(); + + expect(deps.playback.endStream).not.toHaveBeenCalled(); + expect(service.getState()).toEqual({ conversationId: 'conv_001', phase: 'interrupted' }); + disposeService(service); + }); + + it('does not stop a newer TTS when a late cancellation belongs to the old audio', async () => { + const fake = createFakeConnection(); + const deps = createDeps({ connection: fake.connection }); + const service = createService(deps); + + await startListening(fake, service); + const startMessage = (audioId: string): AssistantServerMessage => + ({ + audio_id: audioId, + conversation_id: 'conv_001', + payload: { + format: 'pcm_s16le', + purpose: 'reply', + sample_rate_hz: 24000, + speech_text: '', + }, + type: 'voice.tts.start', + }) as AssistantServerMessage; + + fake.emitMessage(startMessage('audio_001')); + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + type: 'voice.tts.end', + } as AssistantServerMessage); + fake.emitMessage(startMessage('audio_002')); + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + type: 'voice.tts.canceled', + } as AssistantServerMessage); + // 兼容尚未升级的后端:旧实现把这种晚到的取消发成空 id,不能把新流停掉。 + fake.emitMessage({ + audio_id: '', + conversation_id: 'conv_001', + type: 'voice.tts.canceled', + } as AssistantServerMessage); + await flushAsync(); + + expect(deps.playback.stop).not.toHaveBeenCalled(); + expect(service.getState()).toEqual({ conversationId: 'conv_001', phase: 'speaking' }); + disposeService(service); + }); + + it('dismissReply() clears the reply bubble and stops playback immediately', async () => { + const fake = createFakeConnection(); + const deps = createDeps({ connection: fake.connection }); + const service = createService(deps); + + await startListening(fake, service); + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + payload: { + format: 'pcm_s16le', + purpose: 'reply', + sample_rate_hz: 24000, + speech_text: '回复内容', + }, + type: 'voice.tts.start', + } as AssistantServerMessage); + await flushAsync(); + fake.emitMessage({ + conversation_id: 'conv_001', + payload: { done: true, reply_id: 'reply_1', speech_text: '回复内容' }, + type: 'voice.dialogue.reply', + } as AssistantServerMessage); + await flushAsync(); + expect(service.getReplyText()).toBe('回复内容'); + + await service.dismissReply(); + + expect(service.getReplyText()).toBeNull(); + expect(deps.playback.stop).toHaveBeenCalledTimes(1); + disposeService(service); + }); + it('handleClose() unsubscribes from the shared connection before nulling it, even when a real disconnect races endTurn()', async () => { const fake = createFakeConnection(); const deps = createDeps({ connection: fake.connection }); diff --git a/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts b/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts index 0e043763..3dc8c28a 100644 --- a/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts +++ b/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts @@ -68,6 +68,9 @@ function createFakeConnection() { return { closeCalls, connection, + emitAudioFrame: (chunk: ArrayBuffer) => { + for (const handler of audioHandlers) handler(chunk); + }, emitClose: (event: { code: number; reason: string }) => { for (const handler of closeHandlers) handler(event); }, @@ -172,6 +175,34 @@ describe('AssistantConversationService', () => { expect(service.getState()).toEqual({ message: '连接已断开(1006)', phase: 'error' }); }); + it('unsubscribes the old push-to-talk listeners after a mode-switch disconnect', async () => { + const fake = createFakeConnection(); + const deps = createDeps({ connection: fake.connection }); + const service = new AssistantConversationService({ accountId: 'acc_001' }, deps); + + await completeStreamStart(fake, service.startTurn()); + // AuthenticatedWebSocketClient 切到 continuous 时会关掉当前这条 push_to_talk 连接。 + fake.emitClose({ code: 1000, reason: '' }); + expect(fake.unsubscribeCalls).toEqual({ audio: 1, close: 1, message: 1 }); + + // 新连接上的 TTS:旧服务不能再收到,否则会和连续对话服务重叠播放。 + fake.emitMessage({ + audio_id: 'audio_002', + conversation_id: 'conv_002', + payload: { + format: 'pcm_s16le', + purpose: 'reply', + sample_rate_hz: 24000, + speech_text: '', + }, + type: 'voice.tts.start', + } as AssistantServerMessage); + fake.emitAudioFrame(new ArrayBuffer(4)); + + expect(deps.playback.startStream).not.toHaveBeenCalled(); + expect(deps.playback.pushChunk).not.toHaveBeenCalled(); + }); + it('endTurn does not hang waiting on a startTurn that never got voice.stream.started', async () => { const fake = createFakeConnection(); const deps = createDeps({ connection: fake.connection }); diff --git a/frontend/tests/unit/features/reminder/presentation/AlertReminderPresenter.test.ts b/frontend/tests/unit/features/reminder/presentation/AlertReminderPresenter.test.ts new file mode 100644 index 00000000..1f3193ba --- /dev/null +++ b/frontend/tests/unit/features/reminder/presentation/AlertReminderPresenter.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, jest } from '@jest/globals'; + +import type { + AlertDialogPort, + AlertDialogRequest, +} from '../../../../../src/features/reminder/application/interfaces'; +import { AlertReminderPresenter } from '../../../../../src/features/reminder/presentation/AlertReminderPresenter'; +import type { ReminderDeliveryRequest } from '../../../../../src/features/reminder/domain'; + +function createDialog(): { dialog: AlertDialogPort; requests: AlertDialogRequest[] } { + const requests: AlertDialogRequest[] = []; + return { + dialog: { + show: jest.fn(async (request: AlertDialogRequest) => { + requests.push(request); + }), + }, + requests, + }; +} + +function deliveryRequest( + overrides: Partial = {}, +): ReminderDeliveryRequest { + return { + reminder_id: 'reminder-1', + schedule_id: 'schedule-1', + title: '喝水', + strength: 'medium', + trigger: { + reminder_id: 'reminder-1', + schedule_id: 'schedule-1', + reason: 'at_time', + triggered_at: '2026-08-20T08:00:00Z', + }, + ...overrides, + }; +} + +describe('AlertReminderPresenter', () => { + it('shows the dialog with the reason-specific message and returns a visible receipt', async () => { + const { dialog, requests } = createDialog(); + const presenter = new AlertReminderPresenter(dialog); + + const receipt = await presenter.show(deliveryRequest()); + + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + title: '喝水', + message: '已到提醒时间,请及时处理。', + }); + expect(receipt).toEqual({ presentation_id: 'alert-schedule-1', visible: true }); + }); + + it('falls back to a generic title when the schedule has none', async () => { + const { dialog, requests } = createDialog(); + const presenter = new AlertReminderPresenter(dialog); + + await presenter.show(deliveryRequest({ title: '' })); + + expect(requests[0]).toMatchObject({ title: '日程提醒' }); + }); + + it('uses the reason-specific message for every trigger reason', async () => { + const { dialog, requests } = createDialog(); + const presenter = new AlertReminderPresenter(dialog); + const cases: readonly [ReminderDeliveryRequest['trigger']['reason'], string][] = [ + ['arrive_location', '您已进入目标地点附近,请及时处理。'], + ['return_to_recorded_location', '您已回到记录地点附近,请及时处理。'], + ['at_time', '已到提醒时间,请及时处理。'], + ['before_start', '日程即将开始,请及时处理。'], + ['snooze_expired', '延后提醒时间已到,请及时处理。'], + ]; + + for (const [reason] of cases) { + await presenter.show(deliveryRequest({ trigger: { ...deliveryRequest().trigger, reason } })); + } + + expect(requests.map((request) => request.message)).toEqual(cases.map(([, message]) => message)); + }); + + it('notifies listeners with confirm when the confirm button is pressed', async () => { + const { dialog, requests } = createDialog(); + const presenter = new AlertReminderPresenter(dialog); + const listener = jest.fn(); + presenter.onAction(listener); + + await presenter.show(deliveryRequest()); + requests[0].buttons.find((button) => button.text === '确认')?.onPress?.(); + + expect(listener).toHaveBeenCalledWith({ schedule_id: 'schedule-1', action: 'confirm' }); + }); + + it('notifies listeners with snooze when the snooze button is pressed', async () => { + const { dialog, requests } = createDialog(); + const presenter = new AlertReminderPresenter(dialog); + const listener = jest.fn(); + presenter.onAction(listener); + + await presenter.show(deliveryRequest()); + requests[0].buttons.find((button) => button.text === '延后')?.onPress?.(); + + expect(listener).toHaveBeenCalledWith({ schedule_id: 'schedule-1', action: 'snooze' }); + }); + + it('stops notifying a listener once it has unsubscribed', async () => { + const { dialog, requests } = createDialog(); + const presenter = new AlertReminderPresenter(dialog); + const listener = jest.fn(); + const unsubscribe = presenter.onAction(listener); + unsubscribe(); + + await presenter.show(deliveryRequest()); + requests[0].buttons.find((button) => button.text === '确认')?.onPress?.(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('suppresses a pending action after hide() is called for that schedule', async () => { + const { dialog, requests } = createDialog(); + const presenter = new AlertReminderPresenter(dialog); + const listener = jest.fn(); + presenter.onAction(listener); + + await presenter.show(deliveryRequest()); + await presenter.hide('schedule-1'); + requests[0].buttons.find((button) => button.text === '确认')?.onPress?.(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('clears suppression for a schedule the next time it is shown', async () => { + const { dialog, requests } = createDialog(); + const presenter = new AlertReminderPresenter(dialog); + const listener = jest.fn(); + presenter.onAction(listener); + + await presenter.show(deliveryRequest()); + await presenter.hide('schedule-1'); + await presenter.show(deliveryRequest()); + requests[requests.length - 1].buttons.find((button) => button.text === '确认')?.onPress?.(); + + expect(listener).toHaveBeenCalledWith({ schedule_id: 'schedule-1', action: 'confirm' }); + }); + + it('hiding a schedule that is not currently visible is a no-op on the visible tracking', async () => { + const { dialog } = createDialog(); + const presenter = new AlertReminderPresenter(dialog); + + await expect(presenter.hide('schedule-not-shown')).resolves.toBeUndefined(); + }); +}); diff --git a/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts b/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts index a7291319..bf917cfd 100644 --- a/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts +++ b/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts @@ -1,19 +1,15 @@ import { act, renderHook } from '@testing-library/react-native'; import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; -import { Alert, AppState, Platform, type AlertButton } from 'react-native'; import type { + AlertDialogPort, + AlertDialogRequest, DeviceCapabilityPort, DeviceCapabilityStatus, DevicePermission, } from '../../../../src/features/reminder/application/interfaces'; import { useReminderPermissionsOnLaunch } from '../../../../src/features/reminder/presentation/useReminderPermissionsOnLaunch'; -const ALERT_OPTIONS = expect.objectContaining({ - cancelable: true, - onDismiss: expect.any(Function), -}); - function deniedPermissions(): Record { return { notifications: false, @@ -41,15 +37,14 @@ function grantedPermissions(): Record { type FakeDevice = DeviceCapabilityPort & { status: DeviceCapabilityStatus }; function createDevice( - status: Partial & Pick = { - platform: 'android', - }, + status: Partial & Pick, ): FakeDevice { + const listeners: (() => void)[] = []; const device: FakeDevice = { status: { platform: status.platform, supported: status.supported ?? status.platform === 'android', - permissions: { ...deniedPermissions(), ...status.permissions }, + permissions: { ...grantedPermissions(), ...status.permissions }, background_execution: status.background_execution ?? true, }, getStatus: jest.fn(async () => device.status), @@ -61,14 +56,26 @@ function createDevice( return true; }), openSettings: jest.fn(async () => true), - onAppActive: jest.fn(() => () => {}), + onAppActive: jest.fn((listener: () => void) => { + listeners.push(listener); + return jest.fn(); + }), + }; + (device as unknown as { fireAppActive: () => void }).fireAppActive = () => { + listeners.forEach((listener) => listener()); }; return device; } -let alertButtons: readonly AlertButton[] = []; -let dismissAlertCallback: (() => void) | undefined; -const appStateListeners: ((state: string) => void)[] = []; +let dialogRequest: AlertDialogRequest | undefined; + +function createDialog(): AlertDialogPort { + return { + show: jest.fn(async (request: AlertDialogRequest) => { + dialogRequest = request; + }), + }; +} async function flush(ms = 0): Promise { await act(async () => { @@ -83,15 +90,7 @@ async function flush(ms = 0): Promise { async function press(label: string): Promise { await act(async () => { - alertButtons.find((button) => button.text === label)?.onPress?.(); - await Promise.resolve(); - await Promise.resolve(); - }); -} - -async function dismissAlert(): Promise { - await act(async () => { - dismissAlertCallback?.(); + dialogRequest?.buttons.find((button) => button.text === label)?.onPress?.(); await Promise.resolve(); await Promise.resolve(); }); @@ -99,213 +98,293 @@ async function dismissAlert(): Promise { describe('useReminderPermissionsOnLaunch', () => { beforeEach(() => { - alertButtons = []; - appStateListeners.length = 0; - Platform.OS = 'android'; - dismissAlertCallback = undefined; - jest.spyOn(Alert, 'alert').mockImplementation((_title, _message, buttons, options) => { - alertButtons = buttons ?? []; - dismissAlertCallback = options?.onDismiss; - }); - jest.spyOn(AppState, 'addEventListener').mockImplementation((_event, listener) => { - appStateListeners.push(listener as (state: string) => void); - return { remove: jest.fn() } as ReturnType; - }); + dialogRequest = undefined; }); afterEach(() => { jest.useRealTimers(); - jest.restoreAllMocks(); }); - it('does nothing off Android or without a device', async () => { + it('does nothing without a device or a dialog', async () => { jest.useFakeTimers(); - const androidDevice = createDevice(); - Platform.OS = 'ios'; - renderHook(() => useReminderPermissionsOnLaunch(androidDevice)); - Platform.OS = 'android'; - renderHook(() => useReminderPermissionsOnLaunch(null)); + const device = createDevice({ + platform: 'android', + supported: true, + permissions: deniedPermissions(), + }); + const dialog = createDialog(); + renderHook(() => useReminderPermissionsOnLaunch(null, dialog)); + renderHook(() => useReminderPermissionsOnLaunch(device, null)); await flush(1_000); - expect(androidDevice.getStatus).not.toHaveBeenCalled(); + expect(device.getStatus).not.toHaveBeenCalled(); + expect(dialog.show).not.toHaveBeenCalled(); }); - it('requests notifications directly without an explanation alert', async () => { + it('requests notifications directly without a dialog, then reports the update', async () => { jest.useFakeTimers(); - const device = createDevice({ platform: 'android', supported: true }); - renderHook(() => useReminderPermissionsOnLaunch(device)); + const device = createDevice({ + platform: 'android', + supported: true, + permissions: deniedPermissions(), + }); + const dialog = createDialog(); + const onPermissionsUpdated = jest.fn(); + renderHook(() => useReminderPermissionsOnLaunch(device, dialog, onPermissionsUpdated)); await flush(600); - expect(Alert.alert).not.toHaveBeenCalled(); + expect(dialog.show).not.toHaveBeenCalled(); expect(device.requestPermission).toHaveBeenCalledWith('notifications'); + expect(onPermissionsUpdated).toHaveBeenCalledTimes(1); }); - it('asks before opening settings for exact alarm, then resumes on app active', async () => { + it('jumps straight to settings for exact alarm, then resumes once the app is active again', async () => { jest.useFakeTimers(); const device = createDevice({ platform: 'android', supported: true, permissions: { ...deniedPermissions(), notifications: true }, }); - renderHook(() => useReminderPermissionsOnLaunch(device)); + const dialog = createDialog(); + const onPermissionsUpdated = jest.fn(); + renderHook(() => useReminderPermissionsOnLaunch(device, dialog, onPermissionsUpdated)); await flush(600); - expect(Alert.alert).toHaveBeenCalledWith( - '需要精确闹钟权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, - ); - await press('去授权'); + expect(dialog.show).not.toHaveBeenCalled(); expect(device.openSettings).toHaveBeenCalledWith('exact_alarm'); - expect(device.requestPermission).not.toHaveBeenCalled(); + expect(device.requestPermission).not.toHaveBeenCalledWith('exact_alarm'); device.status = { ...device.status, permissions: { ...device.status.permissions, exact_alarm: true }, }; await act(async () => { - appStateListeners.forEach((listener) => listener('active')); + (device as unknown as { fireAppActive: () => void }).fireAppActive(); await Promise.resolve(); }); await flush(300); - expect(Alert.alert).toHaveBeenCalledWith( - '需要悬浮窗权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, + expect(onPermissionsUpdated).toHaveBeenCalledTimes(1); + expect(dialog.show).toHaveBeenCalledWith(expect.objectContaining({ title: '需要悬浮窗权限' })); + }); + + it('resumes prompting after a logout that happens while waiting to return from settings', async () => { + jest.useFakeTimers(); + const firstDevice = createDevice({ + platform: 'android', + supported: true, + permissions: { ...deniedPermissions(), notifications: true }, + }); + const dialog = createDialog(); + const { rerender } = renderHook( + ({ device }: { device: DeviceCapabilityPort | null }) => + useReminderPermissionsOnLaunch(device, dialog), + { initialProps: { device: firstDevice as DeviceCapabilityPort | null } }, ); + await flush(600); + expect(firstDevice.openSettings).toHaveBeenCalledWith('exact_alarm'); + + // 用户登出,还没从设置页返回——effect 清理时必须把 awaitingReturnRef 也重置掉, + // 不然下一个账号登录后永远读到陈旧的 true,所有权限提示直接失效。 + rerender({ device: null }); + + const secondDevice = createDevice({ + platform: 'android', + supported: true, + permissions: { ...deniedPermissions(), notifications: true }, + }); + rerender({ device: secondDevice as DeviceCapabilityPort | null }); + await flush(600); + + expect(secondDevice.openSettings).toHaveBeenCalledWith('exact_alarm'); }); - it('skips a permission when the user declines the explanation alert', async () => { + it('shows a dialog before requesting location permission, and falls back to settings when denied', async () => { jest.useFakeTimers(); const device = createDevice({ platform: 'android', supported: true, - permissions: { ...deniedPermissions(), notifications: true }, + permissions: { ...deniedPermissions(), location_foreground: false }, + background_execution: true, + }); + device.status.permissions = { + ...grantedPermissions(), + location_foreground: false, + }; + device.requestPermission = jest.fn(async () => false); + const dialog = createDialog(); + const onPermissionsUpdated = jest.fn(); + renderHook(() => useReminderPermissionsOnLaunch(device, dialog, onPermissionsUpdated)); + await flush(600); + expect(dialog.show).toHaveBeenCalledWith(expect.objectContaining({ title: '需要定位权限' })); + + await press('去授权'); + await flush(350); + expect(device.requestPermission).toHaveBeenCalledWith('location_foreground'); + expect(device.openSettings).toHaveBeenCalledWith('location_foreground'); + expect(onPermissionsUpdated).not.toHaveBeenCalled(); + }); + + it('skips a permission and moves on when the user declines the dialog', async () => { + jest.useFakeTimers(); + const device = createDevice({ + platform: 'android', + supported: true, + permissions: { ...grantedPermissions(), overlay: false, full_screen: false }, }); - renderHook(() => useReminderPermissionsOnLaunch(device)); + const dialog = createDialog(); + renderHook(() => useReminderPermissionsOnLaunch(device, dialog)); await flush(600); + expect(dialog.show).toHaveBeenCalledWith(expect.objectContaining({ title: '需要悬浮窗权限' })); + await press('暂不'); await flush(250); - expect(device.openSettings).not.toHaveBeenCalled(); - expect(Alert.alert).toHaveBeenCalledWith( - '需要悬浮窗权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, + expect(device.openSettings).not.toHaveBeenCalledWith('overlay'); + expect(dialog.show).toHaveBeenCalledWith( + expect.objectContaining({ title: '需要全屏通知权限' }), ); }); - it('skips and continues when the explanation alert is dismissed', async () => { + it('treats a dismissed dialog the same as declining', async () => { jest.useFakeTimers(); const device = createDevice({ platform: 'android', supported: true, - permissions: { ...deniedPermissions(), notifications: true }, + permissions: { ...grantedPermissions(), overlay: false, full_screen: false }, }); - renderHook(() => useReminderPermissionsOnLaunch(device)); + const dialog = createDialog(); + renderHook(() => useReminderPermissionsOnLaunch(device, dialog)); await flush(600); - await dismissAlert(); + expect(dialog.show).toHaveBeenCalledWith(expect.objectContaining({ title: '需要悬浮窗权限' })); + + await act(async () => { + dialogRequest?.onDismiss?.(); + await Promise.resolve(); + }); await flush(250); - expect(device.openSettings).not.toHaveBeenCalled(); - expect(Alert.alert).toHaveBeenCalledWith( - '需要悬浮窗权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, + expect(device.openSettings).not.toHaveBeenCalledWith('overlay'); + expect(dialog.show).toHaveBeenCalledWith( + expect.objectContaining({ title: '需要全屏通知权限' }), ); }); - it('skips notifications and continues when requestPermission rejects', async () => { + it('recovers cleanly when a status check rejects', async () => { jest.useFakeTimers(); - const device = createDevice({ platform: 'android', supported: true }); - device.requestPermission = jest.fn(async () => { - throw new Error('native prompt failed'); + const device = createDevice({ + platform: 'android', + supported: true, + permissions: deniedPermissions(), + }); + device.getStatus = jest.fn(async () => { + throw new Error('boom'); }); - renderHook(() => useReminderPermissionsOnLaunch(device)); + const dialog = createDialog(); + renderHook(() => useReminderPermissionsOnLaunch(device, dialog)); await flush(600); - expect(device.requestPermission).toHaveBeenCalledWith('notifications'); - await flush(350); - expect(Alert.alert).toHaveBeenCalledWith( - '需要精确闹钟权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, - ); + + expect(dialog.show).not.toHaveBeenCalled(); }); - it('skips and continues when openSettings returns false', async () => { + it('skips notifications when the request is denied, then moves on to the next permission', async () => { jest.useFakeTimers(); const device = createDevice({ platform: 'android', supported: true, - permissions: { ...deniedPermissions(), notifications: true }, + permissions: { ...grantedPermissions(), notifications: false, exact_alarm: false }, }); - device.openSettings = jest.fn(async () => false); - renderHook(() => useReminderPermissionsOnLaunch(device)); + device.requestPermission = jest.fn(async () => false); + const dialog = createDialog(); + const onPermissionsUpdated = jest.fn(); + renderHook(() => useReminderPermissionsOnLaunch(device, dialog, onPermissionsUpdated)); await flush(600); - await press('去授权'); + expect(device.requestPermission).toHaveBeenCalledWith('notifications'); + expect(onPermissionsUpdated).not.toHaveBeenCalled(); + + await flush(250); expect(device.openSettings).toHaveBeenCalledWith('exact_alarm'); - await flush(200); - expect(Alert.alert).toHaveBeenCalledWith( - '需要悬浮窗权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, - ); }); - it('skips and continues when openSettings throws', async () => { + it('skips exact alarm and moves on when opening settings fails', async () => { jest.useFakeTimers(); const device = createDevice({ platform: 'android', supported: true, - permissions: { ...deniedPermissions(), notifications: true }, + permissions: { ...grantedPermissions(), exact_alarm: false, overlay: false }, }); - device.openSettings = jest.fn(async () => { - throw new Error('settings unavailable'); - }); - renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - await press('去授权'); - await flush(200); - expect(Alert.alert).toHaveBeenCalledWith( - '需要悬浮窗权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, + device.openSettings = jest.fn( + async (permission: DevicePermission) => permission !== 'exact_alarm', ); + const dialog = createDialog(); + renderHook(() => useReminderPermissionsOnLaunch(device, dialog)); + await flush(600); + expect(device.openSettings).toHaveBeenCalledWith('exact_alarm'); + + await flush(250); + expect(dialog.show).toHaveBeenCalledWith(expect.objectContaining({ title: '需要悬浮窗权限' })); }); - it('does not run delayed prompts after unmount', async () => { + it('reports the update once the user grants location permission directly', async () => { jest.useFakeTimers(); - const device = createDevice(); - const { unmount } = renderHook(() => useReminderPermissionsOnLaunch(device)); - unmount(); - await flush(1_000); - expect(device.getStatus).not.toHaveBeenCalled(); + const device = createDevice({ + platform: 'android', + supported: true, + permissions: { ...grantedPermissions(), location_foreground: false }, + }); + const dialog = createDialog(); + const onPermissionsUpdated = jest.fn(); + renderHook(() => useReminderPermissionsOnLaunch(device, dialog, onPermissionsUpdated)); + await flush(600); + expect(dialog.show).toHaveBeenCalledWith(expect.objectContaining({ title: '需要定位权限' })); + + await press('去授权'); + await flush(350); + expect(device.requestPermission).toHaveBeenCalledWith('location_foreground'); + expect(device.openSettings).not.toHaveBeenCalledWith('location_foreground'); + expect(onPermissionsUpdated).toHaveBeenCalledTimes(1); }); - it('cancels pending follow-up prompts on unmount', async () => { + it('skips a location permission and moves to the next one when settings also fails', async () => { jest.useFakeTimers(); - const device = createDevice(); - const { unmount } = renderHook(() => useReminderPermissionsOnLaunch(device)); + const device = createDevice({ + platform: 'android', + supported: true, + permissions: { + ...grantedPermissions(), + location_foreground: false, + location_background: false, + }, + }); + device.requestPermission = jest.fn(async () => false); + device.openSettings = jest.fn(async () => false); + const dialog = createDialog(); + renderHook(() => useReminderPermissionsOnLaunch(device, dialog)); await flush(600); - expect(device.getStatus).toHaveBeenCalledTimes(1); - unmount(); - await flush(1_000); - expect(device.getStatus).toHaveBeenCalledTimes(1); - expect(Alert.alert).not.toHaveBeenCalled(); + expect(dialog.show).toHaveBeenCalledWith(expect.objectContaining({ title: '需要定位权限' })); + + await press('去授权'); + await flush(350); + expect(device.openSettings).toHaveBeenCalledWith('location_foreground'); + + await flush(250); + expect(dialog.show).toHaveBeenCalledWith( + expect.objectContaining({ title: '需要后台定位权限' }), + ); }); - it('does not prompt when every permission is already granted', async () => { + it('skips overlay and moves on when opening settings fails after the dialog is confirmed', async () => { jest.useFakeTimers(); const device = createDevice({ platform: 'android', supported: true, - permissions: grantedPermissions(), + permissions: { ...grantedPermissions(), overlay: false, full_screen: false }, }); - renderHook(() => useReminderPermissionsOnLaunch(device)); + device.openSettings = jest.fn(async (permission: DevicePermission) => permission !== 'overlay'); + const dialog = createDialog(); + renderHook(() => useReminderPermissionsOnLaunch(device, dialog)); await flush(600); - expect(device.requestPermission).not.toHaveBeenCalled(); - expect(Alert.alert).not.toHaveBeenCalled(); + expect(dialog.show).toHaveBeenCalledWith(expect.objectContaining({ title: '需要悬浮窗权限' })); + + await press('去授权'); + await flush(250); + expect(device.openSettings).toHaveBeenCalledWith('overlay'); + expect(dialog.show).toHaveBeenCalledWith( + expect.objectContaining({ title: '需要全屏通知权限' }), + ); }); });