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: '需要全屏通知权限' }),
+ );
});
});