From 13898b934f3a80904230ba244276c6d95b3409a1 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Mon, 17 Aug 2026 10:03:23 +0800 Subject: [PATCH 1/7] feat(reminder): add SQLite-backed reminder data layer Part of #263. SqliteLocalScheduleReader / SqliteReminderStateStore read and persist against the real local database (ScheduleLocalRepository) instead of in-memory fixtures; geofence_radius_meters is hardcoded to 200m for now (known simplification, see Issue #263 Out of Scope). InMemoryLocalScheduleReader is kept as a non-persisted alternative implementation of the same port. LocalScheduleWriter's post-write hook refreshes the new reader after a voice-driven schedule mutation lands. Only depends on application interfaces already on main and the existing ScheduleLocalRepository -- independent of the audio/location/ notifications adapter PRs in this stack. Removes MockLocalScheduleReader, MockReminderApplication, MockReminderDispositionSync, MockReminderStateStore, mockReminderSchedules. --- .../data/local/InMemoryLocalScheduleReader.ts | 61 +++++++++++++++++++ .../src/features/reminder/data/local/index.ts | 1 + 2 files changed, 62 insertions(+) create mode 100644 frontend/src/features/reminder/data/local/InMemoryLocalScheduleReader.ts diff --git a/frontend/src/features/reminder/data/local/InMemoryLocalScheduleReader.ts b/frontend/src/features/reminder/data/local/InMemoryLocalScheduleReader.ts new file mode 100644 index 00000000..fd175135 --- /dev/null +++ b/frontend/src/features/reminder/data/local/InMemoryLocalScheduleReader.ts @@ -0,0 +1,61 @@ +import type { LocalScheduleReader } from '../../application/interfaces'; +import type { LocalReminderSchedule } from '../../domain'; + +/** + * 进程内日程投影,供调用方写入后再由 LocalReminderApplication rebuild/start 接管。 + * 正式本地 DB 接入前作为默认 LocalScheduleReader。 + */ +export class InMemoryLocalScheduleReader implements LocalScheduleReader { + private readonly byId = new Map(); + private readonly listeners = new Set<(schedules: readonly LocalReminderSchedule[]) => void>(); + + list(): readonly LocalReminderSchedule[] { + return [...this.byId.values()]; + } + + async listReminderSchedules(): Promise { + return this.list(); + } + + async getReminderSchedule(scheduleId: string): Promise { + return this.byId.get(scheduleId) ?? null; + } + + subscribe(listener: (schedules: readonly LocalReminderSchedule[]) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + replaceAll(schedules: readonly LocalReminderSchedule[]): void { + this.byId.clear(); + for (const schedule of schedules) { + this.byId.set(schedule.id, schedule); + } + this.notify(); + } + + upsert(schedule: LocalReminderSchedule): void { + this.byId.set(schedule.id, schedule); + this.notify(); + } + + remove(scheduleId: string): void { + if (!this.byId.delete(scheduleId)) return; + this.notify(); + } + + clear(): void { + if (this.byId.size === 0) return; + this.byId.clear(); + this.notify(); + } + + private notify(): void { + const snapshot = this.list(); + for (const listener of this.listeners) { + listener(snapshot); + } + } +} diff --git a/frontend/src/features/reminder/data/local/index.ts b/frontend/src/features/reminder/data/local/index.ts index 9396c1f0..1fea083c 100644 --- a/frontend/src/features/reminder/data/local/index.ts +++ b/frontend/src/features/reminder/data/local/index.ts @@ -1,4 +1,5 @@ export { MemoryReminderStateStore } from './MemoryReminderStateStore'; +export { InMemoryLocalScheduleReader } from './InMemoryLocalScheduleReader'; export { SqliteLocalScheduleReader } from './SqliteLocalScheduleReader'; export { SqliteReminderStateStore } from './SqliteReminderStateStore'; export { From 648851e02c7da0fcba5026cbba9f4bb33a62c0e7 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Mon, 17 Aug 2026 08:10:48 +0800 Subject: [PATCH 2/7] feat(reminder): wire real engine into the app, drop remaining mocks Swaps the composition root over to the real implementations added in the previous three commits: LocalReminderApplication replaces MockReminderApplication, SqliteLocalScheduleReader/SqliteReminderStateStore replace their Mock counterparts, and every device port (audio/notification/vibration/alarm/location) now points at its real adapter. ExpoLocationMonitor (system geofencing) is used for location monitoring; NativeLocationMonitor (Baidu SDK) stays in the repo but unwired -- see the comment in createAppServices.ts for how to switch. AppProviders/AppRoot gain the reminder permission-request flow (useReminderPermissionsOnLaunch, now driven by an injected AlertDialogPort instead of calling Alert.alert directly, with a settings-page fallback for denied background-location permission) and rebuild() the engine once permissions change. Removes MockReminderPresenter, the last remaining Mock* adapter. --- frontend/src/app/AppProviders.tsx | 26 ++- .../src/app/composition/createAppServices.ts | 55 +++-- frontend/src/features/reminder/index.ts | 28 ++- .../presentation/AlertReminderPresenter.ts | 76 +++++++ .../presentation/MockReminderPresenter.ts | 26 --- .../features/reminder/presentation/index.ts | 2 +- .../useReminderPermissionsOnLaunch.ts | 199 ++++++++++++------ 7 files changed, 291 insertions(+), 121 deletions(-) create mode 100644 frontend/src/features/reminder/presentation/AlertReminderPresenter.ts delete mode 100644 frontend/src/features/reminder/presentation/MockReminderPresenter.ts 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/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/features/reminder/index.ts b/frontend/src/features/reminder/index.ts index 698a7b5c..d6235aa6 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, @@ -64,6 +86,7 @@ export type { } from './application'; export { LocalReminderApplication } from './application'; export { + InMemoryLocalScheduleReader, LocalReminderDelivery, LocalReminderDispositionSync, LocalReminderRecovery, @@ -72,4 +95,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/AlertReminderPresenter.ts b/frontend/src/features/reminder/presentation/AlertReminderPresenter.ts new file mode 100644 index 00000000..a35306e9 --- /dev/null +++ b/frontend/src/features/reminder/presentation/AlertReminderPresenter.ts @@ -0,0 +1,76 @@ +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; + 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..ca37781d 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,127 @@ 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]; 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?.(); + } + queueNext = true; + 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; } - if (cancelled) return; - if (!granted) skippedRef.current.add('notifications'); - busyRef.current = false; - schedule(runPrompt, 350); return; } - const shouldAuthorize = await confirmAsync(prompt.title, prompt.message); - if (cancelled) 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; } - if (!cancelled && !awaitingReturnRef.current) { - schedule(runPrompt, 200); + if (queueNext && !awaitingReturnRef.current) { + setTimeout(runPrompt, 250); } }; - const onAppStateChange = (state: AppStateStatus) => { - if (cancelled) return; - if (state !== 'active') return; + 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 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; - awaitingReturnRef.current = false; - for (const id of timeouts) { - clearTimeout(id); - } - timeouts.clear(); - subscription.remove(); + clearTimeout(timer); + unsubscribe(); }; - }, [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 +217,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)); +} From 4776bbf6daa164b827887ed317f5cb64d1522565 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Mon, 17 Aug 2026 08:16:56 +0800 Subject: [PATCH 3/7] fix(reminder): permission flow stalled after the first prompt Every branch in promptNext() except the overlay/full_screen/battery confirm-and-continue path returns from inside the try block, so the setTimeout(runPrompt, 250) that was meant to advance to the next missing permission -- placed after the try/finally -- was dead code for those branches. In practice: grant notifications, and exact_alarm (the next permission in line) would just never get prompted; same for any declined dialog, or a granted/denied location permission. Moved the continuation check into the finally block so it always runs regardless of which branch returned. Also replaces the old mock-based useReminderPermissionsOnLaunch test (deleted upstream when this hook's signature changed to take an injected AlertDialogPort + onPermissionsUpdated callback, with no replacement written) and drops the now-redundant .gitkeep placeholders left over from directories that have had real files in them since earlier commits in this stack. --- frontend/src/app/composition/.gitkeep | 1 - frontend/src/app/orchestration/.gitkeep | 1 - .../reminder/application/interfaces/.gitkeep | 1 - .../src/features/reminder/data/local/.gitkeep | 1 - .../src/features/reminder/domain/.gitkeep | 1 - .../features/reminder/presentation/.gitkeep | 1 - .../useReminderPermissionsOnLaunch.ts | 11 +- frontend/src/infrastructure/audio/.gitkeep | 1 - frontend/src/infrastructure/location/.gitkeep | 1 - .../src/infrastructure/notifications/.gitkeep | 1 - frontend/src/shared/time/.gitkeep | 1 - frontend/src/types/.gitkeep | 1 - .../useReminderPermissionsOnLaunch.test.ts | 259 ++++++------------ 13 files changed, 87 insertions(+), 194 deletions(-) delete mode 100644 frontend/src/app/composition/.gitkeep delete mode 100644 frontend/src/app/orchestration/.gitkeep delete mode 100644 frontend/src/features/reminder/application/interfaces/.gitkeep delete mode 100644 frontend/src/features/reminder/data/local/.gitkeep delete mode 100644 frontend/src/features/reminder/domain/.gitkeep delete mode 100644 frontend/src/features/reminder/presentation/.gitkeep delete mode 100644 frontend/src/infrastructure/audio/.gitkeep delete mode 100644 frontend/src/infrastructure/location/.gitkeep delete mode 100644 frontend/src/infrastructure/notifications/.gitkeep delete mode 100644 frontend/src/shared/time/.gitkeep delete mode 100644 frontend/src/types/.gitkeep 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/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/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/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/useReminderPermissionsOnLaunch.ts b/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts index ca37781d..2e197862 100644 --- a/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts +++ b/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts @@ -167,10 +167,13 @@ export function useReminderPermissionsOnLaunch( } } finally { busyRef.current = false; - } - - if (queueNext && !awaitingReturnRef.current) { - setTimeout(runPrompt, 250); + // 必须放在 finally 里:try 里几乎每条分支(直接请求、跳设置、弹框拒绝、 + // 定位授权/回退)都在分支末尾 return,放在 try/finally 外面的话,只有 + // 悬浮窗/全屏通知/电池优化这种"用户点了确认"的兜底分支能落到这行—— + // 其余分支设了 queueNext 也没人来消费,链就断在第一个权限上。 + if (queueNext && !awaitingReturnRef.current) { + setTimeout(runPrompt, 250); + } } }; 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/features/reminder/useReminderPermissionsOnLaunch.test.ts b/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts index a7291319..f16bffc1 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,115 @@ 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('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, }); - renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - await press('暂不'); - await flush(250); - expect(device.openSettings).not.toHaveBeenCalled(); - expect(Alert.alert).toHaveBeenCalledWith( - '需要悬浮窗权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, - ); - }); - - it('skips and continues when the explanation alert is dismissed', async () => { - jest.useFakeTimers(); - const device = createDevice({ - platform: 'android', - supported: true, - permissions: { ...deniedPermissions(), notifications: true }, - }); - renderHook(() => useReminderPermissionsOnLaunch(device)); + 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); - await dismissAlert(); - await flush(250); - expect(device.openSettings).not.toHaveBeenCalled(); - expect(Alert.alert).toHaveBeenCalledWith( - '需要悬浮窗权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, - ); - }); + expect(dialog.show).toHaveBeenCalledWith(expect.objectContaining({ title: '需要定位权限' })); - it('skips notifications and continues when requestPermission rejects', async () => { - jest.useFakeTimers(); - const device = createDevice({ platform: 'android', supported: true }); - device.requestPermission = jest.fn(async () => { - throw new Error('native prompt failed'); - }); - renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - expect(device.requestPermission).toHaveBeenCalledWith('notifications'); + await press('去授权'); await flush(350); - expect(Alert.alert).toHaveBeenCalledWith( - '需要精确闹钟权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, - ); + expect(device.requestPermission).toHaveBeenCalledWith('location_foreground'); + expect(device.openSettings).toHaveBeenCalledWith('location_foreground'); + expect(onPermissionsUpdated).not.toHaveBeenCalled(); }); - it('skips and continues when openSettings returns false', async () => { + it('skips a permission and moves on when the user declines the dialog', async () => { jest.useFakeTimers(); const device = createDevice({ platform: 'android', supported: true, - permissions: { ...deniedPermissions(), notifications: true }, + permissions: { ...grantedPermissions(), overlay: false, full_screen: false }, }); - device.openSettings = jest.fn(async () => false); - renderHook(() => useReminderPermissionsOnLaunch(device)); + const dialog = createDialog(); + renderHook(() => useReminderPermissionsOnLaunch(device, dialog)); await flush(600); - await press('去授权'); - expect(device.openSettings).toHaveBeenCalledWith('exact_alarm'); - await flush(200); - expect(Alert.alert).toHaveBeenCalledWith( - '需要悬浮窗权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, - ); - }); + expect(dialog.show).toHaveBeenCalledWith(expect.objectContaining({ title: '需要悬浮窗权限' })); - it('skips and continues when openSettings throws', async () => { - jest.useFakeTimers(); - const device = createDevice({ - platform: 'android', - supported: true, - permissions: { ...deniedPermissions(), notifications: true }, - }); - 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, + await press('暂不'); + await flush(250); + expect(device.openSettings).not.toHaveBeenCalledWith('overlay'); + expect(dialog.show).toHaveBeenCalledWith( + expect.objectContaining({ title: '需要全屏通知权限' }), ); }); - - it('does not run delayed prompts after unmount', async () => { - jest.useFakeTimers(); - const device = createDevice(); - const { unmount } = renderHook(() => useReminderPermissionsOnLaunch(device)); - unmount(); - await flush(1_000); - expect(device.getStatus).not.toHaveBeenCalled(); - }); - - it('cancels pending follow-up prompts on unmount', async () => { - jest.useFakeTimers(); - const device = createDevice(); - const { unmount } = renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - expect(device.getStatus).toHaveBeenCalledTimes(1); - unmount(); - await flush(1_000); - expect(device.getStatus).toHaveBeenCalledTimes(1); - expect(Alert.alert).not.toHaveBeenCalled(); - }); - - it('does not prompt when every permission is already granted', async () => { - jest.useFakeTimers(); - const device = createDevice({ - platform: 'android', - supported: true, - permissions: grantedPermissions(), - }); - renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - expect(device.requestPermission).not.toHaveBeenCalled(); - expect(Alert.alert).not.toHaveBeenCalled(); - }); }); From e82f1b80656c91767680a8d0cc2f07bad6510b03 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Thu, 20 Aug 2026 16:27:27 +0800 Subject: [PATCH 4/7] fix(voice): remove stale push-to-talk listeners AssistantConversationService.handleClose() nulled unsubscribeConnection without calling it. Switching from push-to-talk to continuous mode makes the shared AuthenticatedWebSocketClient drop and reopen the connection (the two modes negotiate different voiceMode), so the old service stayed subscribed to the new connection's TTS/PCM and pushed the same reply into the player alongside the continuous service -- audible as one sentence played twice, overlapping. dispose() already unsubscribed correctly; only the close path was missing it. Carried over from #245, which this stacked series replaces -- the fix is not reminder-scoped so none of #264-#270 picked it up. --- .../AssistantConversationService.ts | 3 ++ .../AssistantConversationService.test.ts | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+) 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/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 }); From 646e202e30d05a6dd7a238df09b88b99acebd3f2 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Thu, 20 Aug 2026 16:27:52 +0800 Subject: [PATCH 5/7] fix(voice): preserve new TTS after interruption A barge-in that lands after the model already finished delivering a reply cancelled whatever was playing *next*, not the reply the phone was still sounding out. Two halves: Backend: _Turn reset _audio_id before that late interrupted() ran, so it sent AudioCanceled(audio_id=""). Added _last_audio_id, which survives the reset, and send that instead -- the cancellation now names the audio it actually refers to. Frontend: voice.tts.canceled routed stop() through playbackChain, so every PCM chunk already queued was still fed to the native player before the stop landed. It now bypasses the chain via stopPlaybackImmediately(), and chainPlayback() tags each queued operation with a playbackGeneration that stop bumps, so the stale queue is dropped rather than replayed. tts.end and tts.canceled are both matched against currentAudioId/canceledAudioId, so the server's follow-up tts.end for a cancelled reply no longer ends a newer stream or flips interrupted back to listening. The empty-audio_id case is still handled on the client so a not-yet-updated backend cannot stop a newer reply. #297 removed only the user-facing interrupt button; backend barge-in still drives voice.tts.canceled, so this path is live. Carried over from #245, which this stacked series replaces -- not reminder-scoped, so none of #264-#270 picked it up. --- .../timeflow/intelligence/realtime/agent.py | 14 +- .../realtime/test_realtime_agent.py | 7 +- .../AssistantContinuousConversationService.ts | 50 ++++++- ...stantContinuousConversationService.test.ts | 132 ++++++++++++++++++ 4 files changed, 190 insertions(+), 13 deletions(-) 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..70586811 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,8 @@ 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()) 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/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts b/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts index b36f55af..d3eb0860 100644 --- a/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts +++ b/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts @@ -698,6 +698,138 @@ 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('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 }); From 85a8f0523c9de4ae27963d21613c641ee5d4e2e3 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Thu, 20 Aug 2026 16:43:49 +0800 Subject: [PATCH 6/7] fix(reminder): reset permission-prompt state when the effect unmounts Code review (PR #271, fennoai): the cleanup only cleared the timer and unsubscribed onAppActive, not awaitingReturnRef/skippedRef. If a user opened settings for exact_alarm (or a denied location permission) and logged out before returning, AppProviders reruns this effect with device=null, but the stale awaitingReturnRef stayed true. On the next login the new effect's promptNext() reads that same ref (it's a component-level useRef, not reset by the effect re-running) and returns immediately every time, and since the app is already active there's no new onAppActive event left to clear it -- every permission prompt stays disabled until the process restarts. Reset both refs in the cleanup so a fresh login starts a clean prompt round. --- .../useReminderPermissionsOnLaunch.ts | 6 ++++ .../useReminderPermissionsOnLaunch.test.ts | 31 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts b/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts index 2e197862..7d9f18c1 100644 --- a/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts +++ b/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts @@ -208,6 +208,12 @@ export function useReminderPermissionsOnLaunch( cancelled = true; clearTimeout(timer); unsubscribe(); + // device 变 null(登出)时这个 effect 会重跑清理:不重置的话,刚好卡在 + // "等用户从设置页返回"这一步登出的账号,会让下次登录后的新一轮 promptNext() + // 一直读到陈旧的 true 直接返回——不会再有新的 onAppActive 事件来清掉它 + // (应用早就是 active 的),所有权限提示永久失效,直到进程重启。 + awaitingReturnRef.current = false; + skippedRef.current = new Set(); }; }, [device, dialog, onPermissionsUpdated]); } diff --git a/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts b/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts index f16bffc1..89769e59 100644 --- a/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts +++ b/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts @@ -164,6 +164,37 @@ describe('useReminderPermissionsOnLaunch', () => { 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('shows a dialog before requesting location permission, and falls back to settings when denied', async () => { jest.useFakeTimers(); const device = createDevice({ From b822903c37ee17b874adab6bdfdeb9789d43b821 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Thu, 20 Aug 2026 17:10:02 +0800 Subject: [PATCH 7/7] test(reminder,voice): close Codecov patch-coverage gaps Codecov flagged 60.79% patch coverage across six files from this branch's recent commits. Closed each: - InMemoryLocalScheduleReader.ts: 0% because nothing in the app or tests actually uses it -- only re-exported from two barrels, never imported or instantiated anywhere in feature/reminder-wiring's own history. Deleted the file and its two re-exports instead of testing dead code. - AlertReminderPresenter.ts: new AlertReminderPresenter.test.ts covers every reason-specific message, the title fallback, confirm/snooze dispatch, unsubscribe, and hide()'s suppression window. The `?? '...'` message fallback is unreachable (MESSAGE_BY_REASON already covers every ReminderTriggerReason), so it's istanbul-ignored with a stated reason instead of faked with an invalid reason value. - useReminderPermissionsOnLaunch.ts: added 7 tests for branches the existing suite didn't reach -- denied notifications, failed openSettings on both the direct-settings and location paths, granted location, the bottom settings-redirect branch, a rejected getStatus(), and a dismissed (vs declined) dialog. Its own similarly-unreachable `prompt == null` branch (all 7 DevicePermission values already have a prompt) got the same istanbul-ignore treatment. - AppProviders.tsx: new AppProviders.test.tsx isolates the onPermissionsUpdated -> reminder.rebuild() wiring with a mocked useReminderPermissionsOnLaunch, instead of relying on AppRoot.test.tsx's much heavier integration setup for one line. - AssistantContinuousConversationService.ts: dismissReply() had no coverage at all before this branch touched one line of it (routing through stopPlaybackImmediately()); added a test that drives a reply through voice.tts.start/voice.dialogue.reply and asserts dismissReply() clears it and stops playback. - backend agent.py: the interrupted()-with-nothing-ever-spoken branch (_last_audio_id is None) wasn't exercised; added test_a_barge_in_before_any_reply_started_sends_no_cancellation. Verified: frontend tsc/eslint/prettier clean, Jest 520/520, Vitest 87/87; backend ruff/mypy clean, pytest 97.53% coverage. --- .../realtime/test_realtime_agent.py | 21 +++ .../data/local/InMemoryLocalScheduleReader.ts | 61 ------- .../src/features/reminder/data/local/index.ts | 1 - frontend/src/features/reminder/index.ts | 1 - .../presentation/AlertReminderPresenter.ts | 3 + .../useReminderPermissionsOnLaunch.ts | 3 + frontend/tests/unit/app/AppProviders.test.tsx | 55 +++++++ ...stantContinuousConversationService.test.ts | 33 ++++ .../AlertReminderPresenter.test.ts | 152 ++++++++++++++++++ .../useReminderPermissionsOnLaunch.test.ts | 147 +++++++++++++++++ 10 files changed, 414 insertions(+), 63 deletions(-) delete mode 100644 frontend/src/features/reminder/data/local/InMemoryLocalScheduleReader.ts create mode 100644 frontend/tests/unit/app/AppProviders.test.tsx create mode 100644 frontend/tests/unit/features/reminder/presentation/AlertReminderPresenter.test.ts diff --git a/backend/tests/intelligence/realtime/test_realtime_agent.py b/backend/tests/intelligence/realtime/test_realtime_agent.py index 70586811..ae5b07ac 100644 --- a/backend/tests/intelligence/realtime/test_realtime_agent.py +++ b/backend/tests/intelligence/realtime/test_realtime_agent.py @@ -521,6 +521,27 @@ async def scenario() -> None: 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()) + + def test_a_continuous_pump_that_fails_does_not_wait_on_a_microphone_nobody_reads() -> None: """A vendor failure ends the turn even though the client's microphone stays open. diff --git a/frontend/src/features/reminder/data/local/InMemoryLocalScheduleReader.ts b/frontend/src/features/reminder/data/local/InMemoryLocalScheduleReader.ts deleted file mode 100644 index fd175135..00000000 --- a/frontend/src/features/reminder/data/local/InMemoryLocalScheduleReader.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { LocalScheduleReader } from '../../application/interfaces'; -import type { LocalReminderSchedule } from '../../domain'; - -/** - * 进程内日程投影,供调用方写入后再由 LocalReminderApplication rebuild/start 接管。 - * 正式本地 DB 接入前作为默认 LocalScheduleReader。 - */ -export class InMemoryLocalScheduleReader implements LocalScheduleReader { - private readonly byId = new Map(); - private readonly listeners = new Set<(schedules: readonly LocalReminderSchedule[]) => void>(); - - list(): readonly LocalReminderSchedule[] { - return [...this.byId.values()]; - } - - async listReminderSchedules(): Promise { - return this.list(); - } - - async getReminderSchedule(scheduleId: string): Promise { - return this.byId.get(scheduleId) ?? null; - } - - subscribe(listener: (schedules: readonly LocalReminderSchedule[]) => void): () => void { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - } - - replaceAll(schedules: readonly LocalReminderSchedule[]): void { - this.byId.clear(); - for (const schedule of schedules) { - this.byId.set(schedule.id, schedule); - } - this.notify(); - } - - upsert(schedule: LocalReminderSchedule): void { - this.byId.set(schedule.id, schedule); - this.notify(); - } - - remove(scheduleId: string): void { - if (!this.byId.delete(scheduleId)) return; - this.notify(); - } - - clear(): void { - if (this.byId.size === 0) return; - this.byId.clear(); - this.notify(); - } - - private notify(): void { - const snapshot = this.list(); - for (const listener of this.listeners) { - listener(snapshot); - } - } -} diff --git a/frontend/src/features/reminder/data/local/index.ts b/frontend/src/features/reminder/data/local/index.ts index 1fea083c..9396c1f0 100644 --- a/frontend/src/features/reminder/data/local/index.ts +++ b/frontend/src/features/reminder/data/local/index.ts @@ -1,5 +1,4 @@ export { MemoryReminderStateStore } from './MemoryReminderStateStore'; -export { InMemoryLocalScheduleReader } from './InMemoryLocalScheduleReader'; export { SqliteLocalScheduleReader } from './SqliteLocalScheduleReader'; export { SqliteReminderStateStore } from './SqliteReminderStateStore'; export { diff --git a/frontend/src/features/reminder/index.ts b/frontend/src/features/reminder/index.ts index d6235aa6..69a84f55 100644 --- a/frontend/src/features/reminder/index.ts +++ b/frontend/src/features/reminder/index.ts @@ -86,7 +86,6 @@ export type { } from './application'; export { LocalReminderApplication } from './application'; export { - InMemoryLocalScheduleReader, LocalReminderDelivery, LocalReminderDispositionSync, LocalReminderRecovery, diff --git a/frontend/src/features/reminder/presentation/AlertReminderPresenter.ts b/frontend/src/features/reminder/presentation/AlertReminderPresenter.ts index a35306e9..0403bcae 100644 --- a/frontend/src/features/reminder/presentation/AlertReminderPresenter.ts +++ b/frontend/src/features/reminder/presentation/AlertReminderPresenter.ts @@ -27,6 +27,9 @@ export class AlertReminderPresenter implements ReminderPresenterPort { 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({ diff --git a/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts b/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts index 7d9f18c1..f4954a31 100644 --- a/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts +++ b/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts @@ -101,6 +101,9 @@ export function useReminderPermissionsOnLaunch( 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; 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 d3eb0860..e9913d6b 100644 --- a/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts +++ b/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts @@ -830,6 +830,39 @@ describe('AssistantContinuousConversationService', () => { 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/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 89769e59..bf917cfd 100644 --- a/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts +++ b/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts @@ -240,4 +240,151 @@ describe('useReminderPermissionsOnLaunch', () => { expect.objectContaining({ title: '需要全屏通知权限' }), ); }); + + it('treats a dismissed dialog the same as declining', async () => { + jest.useFakeTimers(); + const device = createDevice({ + platform: 'android', + supported: true, + permissions: { ...grantedPermissions(), overlay: false, full_screen: false }, + }); + const dialog = createDialog(); + renderHook(() => useReminderPermissionsOnLaunch(device, dialog)); + await flush(600); + expect(dialog.show).toHaveBeenCalledWith(expect.objectContaining({ title: '需要悬浮窗权限' })); + + await act(async () => { + dialogRequest?.onDismiss?.(); + await Promise.resolve(); + }); + await flush(250); + expect(device.openSettings).not.toHaveBeenCalledWith('overlay'); + expect(dialog.show).toHaveBeenCalledWith( + expect.objectContaining({ title: '需要全屏通知权限' }), + ); + }); + + it('recovers cleanly when a status check rejects', async () => { + jest.useFakeTimers(); + const device = createDevice({ + platform: 'android', + supported: true, + permissions: deniedPermissions(), + }); + device.getStatus = jest.fn(async () => { + throw new Error('boom'); + }); + const dialog = createDialog(); + renderHook(() => useReminderPermissionsOnLaunch(device, dialog)); + await flush(600); + + expect(dialog.show).not.toHaveBeenCalled(); + }); + + 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: { ...grantedPermissions(), notifications: false, exact_alarm: false }, + }); + device.requestPermission = jest.fn(async () => false); + const dialog = createDialog(); + const onPermissionsUpdated = jest.fn(); + renderHook(() => useReminderPermissionsOnLaunch(device, dialog, onPermissionsUpdated)); + await flush(600); + expect(device.requestPermission).toHaveBeenCalledWith('notifications'); + expect(onPermissionsUpdated).not.toHaveBeenCalled(); + + await flush(250); + expect(device.openSettings).toHaveBeenCalledWith('exact_alarm'); + }); + + it('skips exact alarm and moves on when opening settings fails', async () => { + jest.useFakeTimers(); + const device = createDevice({ + platform: 'android', + supported: true, + permissions: { ...grantedPermissions(), exact_alarm: false, overlay: false }, + }); + 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('reports the update once the user grants location permission directly', async () => { + jest.useFakeTimers(); + 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('skips a location permission and moves to the next one when settings also fails', async () => { + jest.useFakeTimers(); + 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(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('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(), overlay: false, full_screen: false }, + }); + device.openSettings = jest.fn(async (permission: DevicePermission) => permission !== 'overlay'); + 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).toHaveBeenCalledWith('overlay'); + expect(dialog.show).toHaveBeenCalledWith( + expect.objectContaining({ title: '需要全屏通知权限' }), + ); + }); });