diff --git a/frontend/package.json b/frontend/package.json index 4acdde90..28f5e633 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,14 @@ "npm": ">=10.8.2 <11" }, "packageManager": "npm@10.8.2", + "expo": { + "autolinking": { + "buildFromSource": [ + "expo-task-manager", + "unimodules-app-loader" + ] + } + }, "dependencies": { "@expo/metro-runtime": "~57.0.8", "@irvingouj/expo-audio-stream": "3.1.0", diff --git a/frontend/patches/expo-task-manager+57.0.9.patch b/frontend/patches/expo-task-manager+57.0.9.patch new file mode 100644 index 00000000..4dff7e98 --- /dev/null +++ b/frontend/patches/expo-task-manager+57.0.9.patch @@ -0,0 +1,93 @@ +diff --git a/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskManagerInternalModule.java b/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskManagerInternalModule.java +index 1157a8d..f209272 100644 +--- a/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskManagerInternalModule.java ++++ b/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskManagerInternalModule.java +@@ -30,6 +30,7 @@ public class TaskManagerInternalModule implements InternalModule, TaskManagerInt + + public TaskManagerInternalModule(Context context) { + mContextRef = new WeakReference<>(context); ++ Log.i("TimeflowDiag", "TaskManagerInternalModule created id=" + System.identityHashCode(this)); + } + + //region InternalModule +@@ -84,8 +85,11 @@ public class TaskManagerInternalModule implements InternalModule, TaskManagerInt + if (mEventsQueue != null) { + // `startObserving` on TaskManagerModule wasn't called yet - add event body to the queue. + mEventsQueue.add(body); ++ Log.i("TimeflowDiag", "executeTaskWithBody QUEUED id=" + System.identityHashCode(this) ++ + " queueSize=" + mEventsQueue.size()); + } else { + // Manager is already being observed by JS app, so we can execute the event immediately. ++ Log.i("TimeflowDiag", "executeTaskWithBody EMIT id=" + System.identityHashCode(this)); + emitEvent(body); + } + } +@@ -101,6 +105,9 @@ public class TaskManagerInternalModule implements InternalModule, TaskManagerInt + @Override + public synchronized void flushQueuedEvents() { + // Execute any events that came before this call. ++ Log.i("TimeflowDiag", "flushQueuedEvents called id=" + System.identityHashCode(this) ++ + " queueWasNull=" + (mEventsQueue == null) ++ + " queueSize=" + (mEventsQueue == null ? -1 : mEventsQueue.size())); + if (mEventsQueue != null) { + for (Bundle body : mEventsQueue) { + emitEvent(body); +@@ -188,8 +195,10 @@ public class TaskManagerInternalModule implements InternalModule, TaskManagerInt + + private void emitEvent(Bundle body) { + if (mEmitEventWrapper != null) { ++ Log.i("TimeflowDiag", "emitEvent -> mEmitEventWrapper.emit() id=" + System.identityHashCode(this)); + mEmitEventWrapper.emit(TaskManagerInterface.EVENT_NAME, body); + } else { ++ Log.e("TimeflowDiag", "emitEvent SKIPPED: EmitEventWrapper is null, id=" + System.identityHashCode(this)); + Log.e("ExpoTaskManager", "EmitEventWrapper is not set. Failed to emit the TaskManager Event."); + } + } +diff --git a/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskManagerModule.kt b/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskManagerModule.kt +index fc42f02..58b6d06 100644 +--- a/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskManagerModule.kt ++++ b/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskManagerModule.kt +@@ -81,14 +81,20 @@ class TaskManagerModule : Module() { + } + + OnStartObserving { ++ Log.i("TimeflowDiag", "OnStartObserving fired, internal id=" + System.identityHashCode(taskManagerInternal)) + val handler = Handler(Looper.getMainLooper()) + handler.postDelayed( + { ++ Log.i("TimeflowDiag", "OnStartObserving delayed flush running, internal id=" + System.identityHashCode(taskManagerInternal)) + taskManagerInternal?.flushQueuedEvents() + }, + 1000 + ) + } ++ ++ OnStopObserving { ++ Log.i("TimeflowDiag", "OnStopObserving fired, internal id=" + System.identityHashCode(taskManagerInternal)) ++ } + } + + private val appScopeKey: String +diff --git a/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskService.java b/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskService.java +index de829ca..8416444 100644 +--- a/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskService.java ++++ b/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskService.java +@@ -242,6 +242,7 @@ public class TaskService implements SingletonModule, TaskServiceInterface { + // It may be called with null when the host activity is destroyed. + if (taskManager == null) { + sTaskManagers.remove(appScopeKey); ++ sHeadlessTaskManagers.remove(appScopeKey); + return; + } + +@@ -611,9 +612,7 @@ public class TaskService implements SingletonModule, TaskServiceInterface { + private void invalidateAppRecord(String appScopeKey) { + HeadlessAppLoader appLoader = getAppLoader(); + if (appLoader != null) { +- if (getAppLoader().invalidateApp(appScopeKey)) { +- sHeadlessTaskManagers.remove(appScopeKey); +- } ++ appLoader.invalidateApp(appScopeKey); + } + } + diff --git a/frontend/src/features/reminder/application/ReminderGuardCoordinator.ts b/frontend/src/features/reminder/application/ReminderGuardCoordinator.ts index 721bec45..5eab7607 100644 --- a/frontend/src/features/reminder/application/ReminderGuardCoordinator.ts +++ b/frontend/src/features/reminder/application/ReminderGuardCoordinator.ts @@ -17,6 +17,22 @@ import { /** 真机上 hasStarted/stopLocationUpdates 偶发不返回;登出不能卡在这里。 */ const LOCATION_STOP_TIMEOUT_MS = 2_000; +/** + * 注册声称在跑、却这么久一次心跳都收不到,就当它已经不投递了。取最疏轮询间隔 + * (5min)的两倍:宁可发现得晚,也不能误判去拆一个还在正常投递的注册。 + */ +const REGISTRATION_STALE_AFTER_MS = 600_000; + +/** + * 独立于心跳事件的兜底重试节奏。isRegistrationStale() 要判的恰恰是"心跳已经 + * 不再来了"这件事——如果只在 handleSample()/日程变化触发的 reconcile 里查, + * 那么注册一旦在后台悄悄失活(收到过一次心跳、之后再没有),就再也没有任何 + * 代码会主动重新检查,会一直卡到用户手动改日程或重启 App。这里独立定时唤醒 + * 一次 reconcile,跟心跳来不来无关。取跟 REGISTRATION_STALE_AFTER_MS 同一个 + * 最疏轮询间隔,10 分钟的陈旧窗口内能查两次,发现得不算晚。 + */ +const WATCHDOG_INTERVAL_MS = 300_000; + /** * 原生注册其实有三种状态,而 hasStartedLocationUpdatesAsync() 只能回答把后两种 * 合并之后的那个布尔值("注册着吗"): @@ -54,7 +70,16 @@ export class ReminderGuardCoordinator { private running = false; private currentIntervalMs: number | null = null; private lastSample: GeoPoint | null = null; + /** + * 本进程自己成功建起过这个注册没有。注册记录是持久化的,进程被杀之后它依旧 + * 完好,光看注册状态分不出"我建的"和"上个进程留下的"。 + */ + private ownsRegistration = false; + /** 上一次收到位置心跳的时刻,用来发现会话中途悄悄断掉的投递。 */ + private lastProgressAt: number | null = null; private reconcileChain: Promise = Promise.resolve(); + /** 独立于心跳事件的兜底定时器,见 WATCHDOG_INTERVAL_MS 的说明。 */ + private watchdogTimer: ReturnType | null = null; constructor(private readonly dependencies: ReminderGuardDependencies) {} @@ -68,20 +93,36 @@ export class ReminderGuardCoordinator { this.unsubscribeSchedules = this.dependencies.schedules.subscribe(() => { void this.reconcile(); }); + this.watchdogTimer = setInterval(() => { + void this.reconcile(); + }, WATCHDOG_INTERVAL_MS); + // Node 测试环境下的定时器带 unref(),不调用它 Jest 进程退不出去;React + // Native 运行时的 setInterval 返回值没有这个方法,特性检测一下就是安全的 + // 空操作——两边都不影响真正的定时逻辑,只影响"这个定时器算不算 keep-alive + // 句柄"这一件事。 + const maybeUnref = this.watchdogTimer as unknown as { unref?: () => void }; + maybeUnref.unref?.(); await this.reconcile(); } async stop(): Promise { this.started = false; this.generation += 1; + this.ownsRegistration = false; + this.lastProgressAt = null; this.unsubscribeGuardTask?.(); this.unsubscribeGuardTask = null; this.unsubscribeSchedules?.(); this.unsubscribeSchedules = null; + if (this.watchdogTimer != null) { + clearInterval(this.watchdogTimer); + this.watchdogTimer = null; + } await this.stopLocationUpdates(); } private async handleSample(sample: GuardTaskSample): Promise { + this.lastProgressAt = Date.now(); this.lastSample = { latitude: sample.latitude, longitude: sample.longitude }; await this.dependencies.handleLocation(sample); await this.reconcile(); @@ -110,6 +151,9 @@ export class ReminderGuardCoordinator { schedule.status === 'active' && schedule.runtime.reminder_disposition_state !== 'confirmed', ); + // 临时诊断:区分"reconcile 压根没被调到"和"调到了但在这里就早退"。 + console.warn(`[guard] reconcile active=${active.length}`); + if (active.length === 0) { if (!this.isCurrentGeneration(generation)) return; await this.stopLocationUpdates(); @@ -143,19 +187,29 @@ export class ReminderGuardCoordinator { // 这个协调器不知情,本地标志会跟真实状态脱节;用本地标志判断的话,日程 // 清空又新增时会被误判成"已经在跑",永远不会真正重新启动。 // - // 但"注册着"这一个布尔值还不够:注册项带没带 foregroundService 决定了常驻 - // 前台服务在不在,而两者在 hasStartedLocationUpdatesAsync() 眼里完全一样。 - // 只看它的话,一次没带 foregroundService 的重注册就会让协调器永远早退—— - // 而且那份降级注册会被 expo-task-manager 持久化,force-stop 和冷启动都清不掉。 + // 但注册状态本身不能当"还在投递"的证据,两个方向都会骗人: + // - options 带着 foregroundService,进程却已经被杀过一次。注册记录是持久化 + // 的,force-stop 杀不掉,冷启动读到它就一路早退,而真正的投递早断了—— + // 这正是"重启救不回来、只有重装能救"那个卡死状态。 + // - options 没带 foregroundService,服务其实活得好好的。后台唤醒时 + // refreshGuardRegistration 只能不带这个字段重注册(带上会被原生拒), + // 于是每次切后台都会把 options 打成这样,回前台再去"修"一个健康的服务。 + // 所以真正的判据是"最近还收不收得到心跳"(isRegistrationStale),注册状态 + // 只用来回答"重建之前要不要先注销一次"。 const state = await this.resolveRegistrationState(); + const stale = this.isRegistrationStale(state); + // 临时诊断:这几个值就是下面全部分流的依据,卡住时只看这一行就够。 + console.warn( + `[guard] state=${state} foregrounded=${isAppForegrounded()} stale=${stale} wantInterval=${intervalMs}`, + ); if (!this.isCurrentGeneration(generation)) return; - if (state === 'foreground') { - this.running = true; - return; - } if (state === 'unknown') return; - if (state === 'degraded') { + if (state !== 'absent') { + if (!stale) { + this.running = true; + return; + } // 后台补不回来:带 foregroundService 的注册在后台会被原生直接拒掉,这时候 // 硬 stop 只会把仅剩的定位任务也弄没,比维持现状更糟。等回到前台的那次 // reconcile 再修(位置心跳每 15s~5min 就会触发一次 reconcile)。 @@ -171,7 +225,25 @@ export class ReminderGuardCoordinator { try { await Location.stopLocationUpdatesAsync(GUARD_TASK_NAME); } catch (error) { - console.warn('[guard] failed to clear the degraded registration', error); + console.warn('[guard] failed to clear the stale registration', error); + return; + } + // stopLocationUpdatesAsync() 只解绑位置更新,TaskManager 里那条任务注册还留着 + // ——跟 ExpoLocationMonitor 清理老围栏时遇到的是同一件事。真机实测:进程被杀 + // 之后光 stop 再 start,注册看着建上了(hasStarted=true、options 带着 + // foregroundService、间隔也对),却再也不投递一次样本,只有卸载重装才能恢复。 + // 这里补一次真正的注销,把持久化记录也抹掉,等价于重装那一下。注销失败就 + // 中止本次重建、保留现状——不能继续往下 startLocationUpdatesAsync(): + // 持久化记录没删掉,走的还是那条"已存在就 setOptions"的原生分支,等于 + // 重新绑上同一条失活的记录,registerTasks 又会把它当健康注册提前返回, + // 恢复彻底失败且无声无息。留在原状态,等下一次 reconcile(心跳或 + // watchdog 定时器)重试。 + try { + if (await TaskManager.isTaskRegisteredAsync(GUARD_TASK_NAME)) { + await TaskManager.unregisterTaskAsync(GUARD_TASK_NAME); + } + } catch (error) { + console.warn('[guard] failed to unregister the stale task', error); return; } if (!this.isCurrentGeneration(generation)) return; @@ -218,13 +290,35 @@ export class ReminderGuardCoordinator { return; } if (!this.isCurrentGeneration(generation)) return; + // 临时诊断:这行打出来才代表注册真的建上了;之后多久没有 dispatching sample + // 就能直接跟 interval 对照,区分"间隔太疏"和"根本不投递"。 + console.warn(`[guard] registered interval=${intervalMs}`); this.running = true; this.currentIntervalMs = intervalMs; + this.ownsRegistration = true; + // 自己刚建起来的注册,在第一次心跳到来之前也算"确认过还活着",否则紧接着 + // 的那次 reconcile(比如同时又新增了一条日程)会当它是陈旧的再拆一遍。 + this.lastProgressAt = Date.now(); } catch (error) { console.warn('[guard] startLocationUpdatesAsync failed', error); } } + /** + * 注册声称在跑,但它真的还在投递吗?两个判据缺一不可: + * + * 1. 本进程自己建过它没有。继承来的注册在冷启动瞬间还会投出一两次心跳(上一份 + * 注册的余波,真机上量到过两条紧挨着的样本),之后就彻底停摆——所以不能拿 + * "刚收到心跳"当它还活着的证据,只要不是自己建的就一律重建。 + * 2. 自己建的那份,最近还在不在投递。这条管的是会话中途悄悄断掉的情况。 + */ + private isRegistrationStale(state: GuardRegistrationState): boolean { + if (state === 'absent' || state === 'unknown') return false; + if (!this.ownsRegistration) return true; + if (this.lastProgressAt == null) return true; + return Date.now() - this.lastProgressAt > REGISTRATION_STALE_AFTER_MS; + } + /** * 判定原生注册处于 GuardRegistrationState 的哪一种。关键在第二步:光问 * hasStartedLocationUpdatesAsync() 只知道"注册着",得再把注册项自己的 options diff --git a/frontend/src/features/reminder/domain/geofence.ts b/frontend/src/features/reminder/domain/geofence.ts index 654a0867..083c42da 100644 --- a/frontend/src/features/reminder/domain/geofence.ts +++ b/frontend/src/features/reminder/domain/geofence.ts @@ -68,7 +68,7 @@ function toRadians(degrees: number): number { /** local_schedules 表没有单独的半径列,地点提醒目前全局统一用这个值——改这里 * 就是改全部地点提醒的实际触发半径,跟下面轮询密度用的门槛是同一个数字。 */ -export const DEFAULT_GEOFENCE_RADIUS_METERS = 400; +export const DEFAULT_GEOFENCE_RADIUS_METERS = 200; /** 离围栏边界(不是中心点)≤ 此距离时,按最密的轮询间隔查。门槛直接等于围栏 * 半径本身:沿着半径这段路程加密轮询,正好在真正跨过边界前进入最密档。之前 diff --git a/frontend/src/infrastructure/location/reminderGuardTask.ts b/frontend/src/infrastructure/location/reminderGuardTask.ts index eecba6a2..51822463 100644 --- a/frontend/src/infrastructure/location/reminderGuardTask.ts +++ b/frontend/src/infrastructure/location/reminderGuardTask.ts @@ -717,6 +717,10 @@ async function refreshGuardRegistration( distanceInterval: 0, ...(foregroundService == null ? {} : { foregroundService }), }); + // 临时诊断:任务自己的重注册才是最终生效的那次,间隔以这行为准。 + console.warn( + `[guard] refreshed interval=${intervalMs} withService=${foregroundService != null}`, + ); } catch (error) { console.warn('[guard] refresh startLocationUpdatesAsync failed', error); } diff --git a/frontend/tests/integration/sqliteLocalScheduleReader.test.ts b/frontend/tests/integration/sqliteLocalScheduleReader.test.ts index d6276fbd..c03b523f 100644 --- a/frontend/tests/integration/sqliteLocalScheduleReader.test.ts +++ b/frontend/tests/integration/sqliteLocalScheduleReader.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite import { ScheduleLocalRepository, type CloudScheduleRow } from '../../src/features/schedule/data'; import { SqliteLocalScheduleReader } from '../../src/features/reminder/data/local/SqliteLocalScheduleReader'; +import { DEFAULT_GEOFENCE_RADIUS_METERS } from '../../src/features/reminder/domain/geofence'; import { migrateScheduleDatabase } from '../../src/infrastructure/database/migrations'; import { SqlJsExpoDatabase } from '../helpers/sqliteTestDatabase'; @@ -70,7 +71,7 @@ describe('SqliteLocalScheduleReader', () => { id: 'schedule-a', account_id: 'account-a', title: 'Original title', - geofence_radius_meters: 400, + geofence_radius_meters: DEFAULT_GEOFENCE_RADIUS_METERS, reminder: { reminder_type: 'before_start', reminder_offset_minutes: 15, diff --git a/frontend/tests/unit/features/reminder/application/ReminderGuardCoordinator.test.ts b/frontend/tests/unit/features/reminder/application/ReminderGuardCoordinator.test.ts index 44ee2359..e797faf7 100644 --- a/frontend/tests/unit/features/reminder/application/ReminderGuardCoordinator.test.ts +++ b/frontend/tests/unit/features/reminder/application/ReminderGuardCoordinator.test.ts @@ -26,6 +26,8 @@ jest.mock('expo-task-manager', () => ({ isTaskDefined: jest.fn(() => true), defineTask: jest.fn(), getRegisteredTasksAsync: jest.fn(), + isTaskRegisteredAsync: jest.fn(), + unregisterTaskAsync: jest.fn(), })); jest.mock('../../../../../src/infrastructure/location/reminderGuardTask', () => { @@ -60,6 +62,12 @@ const subscribeTaskEvents = subscribeGuardTaskEvents as jest.MockedFunction< const getRegisteredTasks = TaskManager.getRegisteredTasksAsync as jest.MockedFunction< typeof TaskManager.getRegisteredTasksAsync >; +const isTaskRegistered = TaskManager.isTaskRegisteredAsync as jest.MockedFunction< + typeof TaskManager.isTaskRegisteredAsync +>; +const unregisterTask = TaskManager.unregisterTaskAsync as jest.MockedFunction< + typeof TaskManager.unregisterTaskAsync +>; const foregrounded = isAppForegrounded as jest.MockedFunction; /** @@ -176,6 +184,8 @@ describe('ReminderGuardCoordinator', () => { getForeground.mockResolvedValue(granted()); startUpdates.mockResolvedValue(undefined); stopUpdates.mockResolvedValue(undefined); + isTaskRegistered.mockResolvedValue(true); + unregisterTask.mockResolvedValue(undefined); // 默认"还没启动"——ensureLocationUpdates 现在也会查这个真实状态来判断 // 要不要重新调 startLocationUpdatesAsync,跟 stopLocationUpdates 共用同一个 // mock;哪个测试要验证"已经在跑"分支(比如停止逻辑),在 start() 成功之后 @@ -407,7 +417,64 @@ describe('ReminderGuardCoordinator', () => { expect(options?.timeInterval).toBe(300_000); }); - it('does not re-register when the running registration still carries the foreground service', async () => { + it('rebuilds a registration inherited from a previous process even when it carries the foreground service', async () => { + // force-stop 杀不掉 expo-task-manager 持久化的注册记录:冷启动读到的 + // 'foreground' 可能是上一个进程留下的,注册还在、投递已经随进程一起死了。 + // 改之前协调器信了它直接早退,守护就此永久停摆,只有重装能恢复。 + hasStarted.mockResolvedValue(true); + getRegisteredTasks.mockResolvedValue([registeredTask(true)]); + const reader = createReader([timeSchedule()]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + + await coordinator.start(); + + expect(stopUpdates).toHaveBeenCalledWith(GUARD_TASK_NAME); + expect(startUpdates).toHaveBeenCalledTimes(1); + }); + + it('still rebuilds an inherited registration when its dying heartbeats land first', async () => { + // 真机实测:force-stop 之后重开,继承来的注册会先投出一两次心跳(上一份注册 + // 的余波,两条样本紧挨着 13ms),之后彻底停摆。只按"最近有没有心跳"判断的话 + // 会被这两下骗过去,当它还活着而不去重建,守护就此又卡死。 + let sampleListener: + | ((sample: { + latitude: number; + longitude: number; + accuracy_meters: number; + observed_at: string; + }) => void) + | undefined; + subscribeTaskEvents.mockImplementation((listener) => { + sampleListener = listener as typeof sampleListener; + return () => {}; + }); + hasStarted.mockResolvedValue(true); + getRegisteredTasks.mockResolvedValue([registeredTask(true)]); + const reader = createReader([timeSchedule()]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + + await sampleListener?.({ + latitude: 31.2304, + longitude: 121.4737, + accuracy_meters: 10, + observed_at: '2026-08-18T10:00:00.000Z', + }); + await coordinator.start(); + + expect(stopUpdates).toHaveBeenCalledWith(GUARD_TASK_NAME); + expect(startUpdates).toHaveBeenCalledTimes(1); + }); + + it('unregisters the task itself before rebuilding, not just the location updates', async () => { + // 真机实测:进程被杀之后光 stopLocationUpdatesAsync 再 start,注册看着建上了 + // (hasStarted=true、options 带着 foregroundService、间隔也对)却一次样本都不 + // 投递,只有卸载重装能恢复——因为 TaskManager 里那条持久化的任务注册没被清掉。 hasStarted.mockResolvedValue(true); getRegisteredTasks.mockResolvedValue([registeredTask(true)]); const reader = createReader([timeSchedule()]); @@ -418,8 +485,117 @@ describe('ReminderGuardCoordinator', () => { await coordinator.start(); + expect(unregisterTask).toHaveBeenCalledWith(GUARD_TASK_NAME); + expect(startUpdates).toHaveBeenCalledTimes(1); + }); + + it('aborts the rebuild when unregistering the stale task fails, instead of re-registering anyway', async () => { + // 持久化记录没被真正删掉的话,重注册走的还是原生"已存在就 setOptions"那条 + // 分支——等于又绑上了同一条失活的记录,下次 reconcile 读到的 state 还是 + // 'foreground',会被当成健康注册直接早退,恢复彻底失败且没有任何痕迹。 + // 所以注销失败必须整段放弃,把重试留给下一次 reconcile,而不是硬着头皮 + // 继续 startLocationUpdatesAsync()。 + hasStarted.mockResolvedValue(true); + getRegisteredTasks.mockResolvedValue([registeredTask(true)]); + unregisterTask.mockRejectedValue(new Error('boom')); + const reader = createReader([timeSchedule()]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + + await coordinator.start(); + expect(startUpdates).not.toHaveBeenCalled(); + }); + + it('reconciles on an independent watchdog timer even when no sample or schedule change arrives', async () => { + // 心跳静默失活时,三个触发源(start() 只跑一次、日程订阅要等用户凑巧去改、 + // handleSample() 恰恰是已经停了的那个)全都指望不上。watchdog 定时器必须 + // 独立于这三者,自己把 reconcile 叫起来,才能发现"注册看着健在、其实早就 + // 不投递了"这种状态。 + hasStarted.mockResolvedValue(true); + getRegisteredTasks.mockResolvedValue([registeredTask(true)]); + const reader = createReader([timeSchedule()]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + + jest.useFakeTimers(); + try { + await coordinator.start(); + getRegisteredTasks.mockClear(); + + jest.advanceTimersByTime(300_000); + await flushMicrotasks(); + + expect(getRegisteredTasks).toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + + it('stops the watchdog timer on stop() so it does not keep reconciling after teardown', async () => { + hasStarted.mockResolvedValue(true); + getRegisteredTasks.mockResolvedValue([registeredTask(true)]); + const reader = createReader([timeSchedule()]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + + jest.useFakeTimers(); + try { + await coordinator.start(); + await coordinator.stop(); + getRegisteredTasks.mockClear(); + + jest.advanceTimersByTime(600_000); + await flushMicrotasks(); + + expect(getRegisteredTasks).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + + it('leaves the registration alone once a heartbeat has confirmed it is delivering', async () => { + // 心跳还在流就说明注册真的在投递,这时候 options 里带不带 foregroundService + // 都不该去动它——后台唤醒重注册必然丢掉那个字段,据此"修复"就是对着一个 + // 健康的服务做一次多余的 stop+start。 + let sampleListener: + | ((sample: { + latitude: number; + longitude: number; + accuracy_meters: number; + observed_at: string; + }) => void) + | undefined; + subscribeTaskEvents.mockImplementation((listener) => { + sampleListener = listener as typeof sampleListener; + return () => {}; + }); + hasStarted.mockResolvedValue(true); + getRegisteredTasks.mockResolvedValue([registeredTask(false)]); + const reader = createReader([timeSchedule()]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + await coordinator.start(); + stopUpdates.mockClear(); + startUpdates.mockClear(); + + await sampleListener?.({ + latitude: 31.2304, + longitude: 121.4737, + accuracy_meters: 10, + observed_at: '2026-08-18T10:00:00.000Z', + }); + expect(stopUpdates).not.toHaveBeenCalled(); + expect(startUpdates).not.toHaveBeenCalled(); }); it('repairs a registration that lost its foreground service', async () => {