From 8d7ec75188116dd02d89633959d1755ea1163bfc Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Mon, 17 Aug 2026 10:01:49 +0800 Subject: [PATCH 1/6] feat(reminder): add system geofencing location adapter Part of #263. ExpoLocationMonitor implements LocationMonitorPort via expo-location's OS-level geofencing (Android GeofencingClient / iOS CLCircularRegion) -- system manages battery/background reliability instead of app-side continuous polling. geofenceTask.ts is the TaskManager headless task definition; frontend/index.ts imports it at module scope so headless launches (app process killed, geofence crossed) still pick it up. ExpoLocationProvider/LocationProvider gain the location-sample reuse used by the voice assistant's location_search tool. Only depends on application interfaces already on main -- independent of the notifications/audio/data-layer PRs in this stack. Removes MockLocationMonitor. Does not include NativeLocationMonitor / BaiduLocationBridge (Baidu SDK continuous polling) -- dropped entirely per #263's decision to use system geofencing instead. --- frontend/index.ts | 3 + .../src/app/composition/createAppServices.ts | 4 +- .../location/ExpoLocationMonitor.ts | 253 +++++++++++++++++ .../location/ExpoLocationProvider.ts | 69 ++++- .../location/LocationProvider.ts | 4 +- .../location/MockLocationMonitor.ts | 54 ---- .../infrastructure/location/geofenceTask.ts | 258 ++++++++++++++++++ frontend/src/infrastructure/location/index.ts | 2 +- .../location/mockLocationMonitor.test.ts | 64 ----- 9 files changed, 578 insertions(+), 133 deletions(-) create mode 100644 frontend/src/infrastructure/location/ExpoLocationMonitor.ts delete mode 100644 frontend/src/infrastructure/location/MockLocationMonitor.ts create mode 100644 frontend/src/infrastructure/location/geofenceTask.ts delete mode 100644 frontend/tests/unit/infrastructure/location/mockLocationMonitor.test.ts diff --git a/frontend/index.ts b/frontend/index.ts index 5600fa08..d3f0c0f2 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -1,3 +1,6 @@ +// 必须在根组件注册前加载,确保 TaskManager.defineTask 进入顶层作用域。 +import './src/infrastructure/location/geofenceTask'; + import { registerRootComponent } from 'expo'; import App from './App'; diff --git a/frontend/src/app/composition/createAppServices.ts b/frontend/src/app/composition/createAppServices.ts index 8ad840d6..4140dee7 100644 --- a/frontend/src/app/composition/createAppServices.ts +++ b/frontend/src/app/composition/createAppServices.ts @@ -11,7 +11,7 @@ import { MockReminderStateStore, } from '../../features/reminder/data/local'; import { ExpoAudioPlayback } from '../../infrastructure/audio'; -import { MockLocationMonitor } from '../../infrastructure/location'; +import { ExpoLocationMonitor } from '../../infrastructure/location'; import { MockPopup, MockReminderRecovery, @@ -45,7 +45,7 @@ export function createAppServices(options: CreateAppServicesOptions = {}): AppSe const reminderPorts: ReminderApplicationDependencies = { schedules: new MockLocalScheduleReader(), time: new IntervalTimeListener(), - location: new MockLocationMonitor(), + location: new ExpoLocationMonitor(), alarms: new NativeAlarmScheduler(), delivery: new MockReminderDelivery(), audio: new ExpoAudioPlayback(), diff --git a/frontend/src/infrastructure/location/ExpoLocationMonitor.ts b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts new file mode 100644 index 00000000..12a2081f --- /dev/null +++ b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts @@ -0,0 +1,253 @@ +import * as Location from 'expo-location'; +import { AppState, type AppStateStatus, type NativeEventSubscription } from 'react-native'; + +import type { + LocationMonitorEvent, + LocationMonitorPort, + LocationRebuildTarget, + LocationWatchHandle, + LocationWatchRequest, +} from '../../features/reminder/application/interfaces'; +import type { LocationSample } from '../../features/reminder/domain'; + +import { + GEOFENCE_TASK_NAME, + drainPendingGeofenceEvents, + subscribeGeofenceTaskEvents, + type GeofenceTaskPayload, +} from './geofenceTask'; +import type { LocationProvider } from './LocationProvider'; + +type ActiveWatch = { + listener_id: string; + request: LocationWatchRequest; + listener: (event: LocationMonitorEvent) => void; +}; + +/** ≈1.1km,足够超出任何合理的围栏半径,用来给 exit 事件合成一个"明显在圈外"的采样点。 */ +const OUTSIDE_OFFSET_DEGREES = 0.01; + +/** + * 基于 expo-location 系统原生地理围栏(Android GeofencingClient / iOS + * CLCircularRegion)的适配器:不依赖百度账号/Key,围栏进出判断交给系统,比 + * NativeLocationMonitor 那套连续定位轮询 + 应用侧 Haversine 更省电、后台也更可靠。 + * + * 系统只会在真正穿越边界时回调 enter/exit,不会在注册那一刻告诉你当前在圈内还是圈 + * 外,所以 watch() 里仍然主动取一次当前定位、强制送一条初始采样,让 + * LocalReminderApplication 的 armed 状态机能定出正确的起始值;之后的进出全靠 + * geofenceTask 的系统回调,不再轮询。 + * + * LocalReminderApplication.applyLocationSample() 不看这里传的 phase,只用 sample + * 坐标自己重新跑一遍 Haversine 判断,所以 enter/exit 时合成的坐标(区域中心 / + * 中心外一段距离)足够,不需要为了拿"真实"坐标再多发一次定位请求。 + */ +export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvider { + private readonly watches = new Map(); + private readonly scheduleToListener = new Map(); + private lastSample: LocationSample | null = null; + private syncChain: Promise = Promise.resolve(); + private unsubscribeTask: (() => void) | null; + private readonly appStateSub: NativeEventSubscription; + + constructor() { + this.unsubscribeTask = subscribeGeofenceTaskEvents(this.handleTaskEvent); + this.appStateSub = AppState.addEventListener('change', this.handleAppState); + } + + async watch( + request: LocationWatchRequest, + listener: (event: LocationMonitorEvent) => void, + ): Promise { + const existingId = this.scheduleToListener.get(request.schedule_id); + if (existingId != null) { + await this.removeWatch(existingId, false); + } + + const listener_id = `location-${request.schedule_id}`; + this.watches.set(listener_id, { listener_id, request: { ...request }, listener }); + this.scheduleToListener.set(request.schedule_id, listener_id); + await this.enqueueSync(); + + const sample = await this.getCurrentSample(); + if (sample != null) { + listener({ schedule_id: request.schedule_id, sample, phase: 'inside' }); + } + await this.replayPendingEvents(); + + return { listener_id, schedule_id: request.schedule_id }; + } + + async unwatch(listenerId: string): Promise { + await this.removeWatch(listenerId, true); + } + + async rebuild( + targets: readonly LocationRebuildTarget[], + listener: (event: LocationMonitorEvent) => void, + ): Promise { + for (const listenerId of [...this.watches.keys()]) { + await this.removeWatch(listenerId, false); + } + + // 直接灌 Map,不逐个调用 watch():watch() 自己的 enqueueSync()+getCurrentSample() + // 是为单条增量注册设计的,N 个 target 各跑一次会把 startGeofencingAsync 的区域 + // 列表越注册越长(O(N²) 总区域数)、定位请求也打 N 次;这里全部灌完只同步一次、 + // 取一次定位,再扇给每个刚注册的 watch。 + const handles: LocationWatchHandle[] = []; + for (const target of targets) { + const listener_id = `location-${target.schedule_id}`; + this.watches.set(listener_id, { + listener_id, + request: { + schedule_id: target.schedule_id, + center: target.center, + radius_meters: target.radius_meters, + mode: target.mode, + background: target.background, + }, + listener, + }); + this.scheduleToListener.set(target.schedule_id, listener_id); + handles.push({ listener_id, schedule_id: target.schedule_id }); + } + await this.enqueueSync(); + + const sample = await this.getCurrentSample(); + if (sample != null) { + for (const handle of handles) { + listener({ schedule_id: handle.schedule_id, sample, phase: 'inside' }); + } + } + + // App 进程被杀掉期间,headless task 攒下的围栏事件在这里补上——watches/ + // scheduleToListener 刚灌好,handleTaskEvent 才查得到对应的 watch。 + await this.replayPendingEvents(); + + return handles; + } + + async getLastSample(): Promise { + return this.lastSample; + } + + async getCurrentSample(): Promise { + try { + const { status } = await Location.getForegroundPermissionsAsync(); + if (status !== 'granted') return this.lastSample; + const position = await Location.getCurrentPositionAsync({}); + const sample = toSample(position); + this.lastSample = sample; + return sample; + } catch { + return this.lastSample; + } + } + + dispose(): void { + this.unsubscribeTask?.(); + this.unsubscribeTask = null; + this.appStateSub.remove(); + void Location.hasStartedGeofencingAsync(GEOFENCE_TASK_NAME) + .then((started) => (started ? Location.stopGeofencingAsync(GEOFENCE_TASK_NAME) : undefined)) + .catch(() => undefined); + } + + private readonly handleAppState = (state: AppStateStatus): void => { + if (state !== 'active' || this.watches.size === 0) return; + void this.enqueueSync(); + }; + + /** 补上 headless task 期间(没有订阅者时)攒下的围栏事件,按正常路径重放一遍。 */ + private async replayPendingEvents(): Promise { + const pending = await drainPendingGeofenceEvents(); + for (const payload of pending) { + this.handleTaskEvent(payload); + } + } + + private readonly handleTaskEvent = (payload: GeofenceTaskPayload): void => { + const listenerId = this.scheduleToListener.get(payload.schedule_id); + const watch = listenerId != null ? this.watches.get(listenerId) : undefined; + if (watch == null) return; + + const sample: LocationSample = + payload.event === 'enter' + ? { + latitude: payload.latitude, + longitude: payload.longitude, + accuracy_meters: payload.radius, + observed_at: payload.observed_at, + } + : { + latitude: payload.latitude + OUTSIDE_OFFSET_DEGREES, + longitude: payload.longitude, + accuracy_meters: payload.radius, + observed_at: payload.observed_at, + }; + this.lastSample = sample; + watch.listener({ + schedule_id: watch.request.schedule_id, + sample, + phase: payload.event === 'enter' ? 'entered' : 'left', + }); + }; + + private async removeWatch(listenerId: string, sync: boolean): Promise { + const watch = this.watches.get(listenerId); + if (watch == null) return; + this.watches.delete(listenerId); + this.scheduleToListener.delete(watch.request.schedule_id); + if (sync) { + await this.enqueueSync(); + } + } + + private enqueueSync(): Promise { + this.syncChain = this.syncChain.then( + () => this.syncRegions(), + () => this.syncRegions(), + ); + return this.syncChain; + } + + private async syncRegions(): Promise { + if (this.watches.size === 0) { + const started = await Location.hasStartedGeofencingAsync(GEOFENCE_TASK_NAME).catch( + () => false, + ); + if (started) { + await Location.stopGeofencingAsync(GEOFENCE_TASK_NAME).catch(() => undefined); + } + return; + } + + const { status: foreground } = await Location.getForegroundPermissionsAsync(); + if (foreground !== 'granted') return; + const { status: background } = await Location.getBackgroundPermissionsAsync(); + if (background !== 'granted') return; + + const regions: Location.LocationRegion[] = [...this.watches.values()].map((watch) => ({ + identifier: watch.request.schedule_id, + latitude: watch.request.center.latitude, + longitude: watch.request.center.longitude, + radius: watch.request.radius_meters, + notifyOnEnter: true, + notifyOnExit: true, + })); + + try { + await Location.startGeofencingAsync(GEOFENCE_TASK_NAME, regions); + } catch { + // 系统围栏注册失败(比如权限被收回);下次 watch()/rebuild() 会再重试。 + } + } +} + +function toSample(position: Location.LocationObject): LocationSample { + return { + latitude: position.coords.latitude, + longitude: position.coords.longitude, + accuracy_meters: position.coords.accuracy ?? 0, + observed_at: new Date(position.timestamp).toISOString(), + }; +} diff --git a/frontend/src/infrastructure/location/ExpoLocationProvider.ts b/frontend/src/infrastructure/location/ExpoLocationProvider.ts index f9907df1..c0ce1b77 100644 --- a/frontend/src/infrastructure/location/ExpoLocationProvider.ts +++ b/frontend/src/infrastructure/location/ExpoLocationProvider.ts @@ -4,27 +4,76 @@ import type { LocationSample } from '../../features/reminder/domain'; import type { LocationProvider } from './LocationProvider'; +const LAST_KNOWN_MAX_AGE_MS = 60_000; +const LAST_KNOWN_REQUIRED_ACCURACY_METERS = 200; + /** - * 真实定位实现。权限被拒绝或定位失败都返回 null 而不是抛错——调用方(目前是 - * session.hello 的位置字段)拿不到就不带这个字段,不应该因为定位失败连不上。 + * 真实定位实现。语音握手优先使用短时间内的缓存位置,避免等待 GPS 冷启动;同时 + * 在后台刷新当前位置,让下一次连接获得更新的位置。权限被拒绝或定位失败都返回 + * null 而不是抛错,调用方拿不到就不带 session.hello 的位置字段,不影响连通性。 */ export class ExpoLocationProvider implements LocationProvider { + private freshSampleInFlight: Promise | null = null; + async getCurrentSample(): Promise { const { status } = await Location.requestForegroundPermissionsAsync(); if (status !== 'granted') { + console.warn('[location-search] foreground location permission is unavailable', { status }); return null; } + const cached = await this.getRecentSample(); + if (cached !== null) { + void this.getFreshSample(); + return cached; + } + + return this.getFreshSample(); + } + + private async getRecentSample(): Promise { try { - const position = await Location.getCurrentPositionAsync({}); - return { - accuracy_meters: position.coords.accuracy ?? 0, - latitude: position.coords.latitude, - longitude: position.coords.longitude, - observed_at: new Date(position.timestamp).toISOString(), - }; - } catch { + const position = await Location.getLastKnownPositionAsync({ + maxAge: LAST_KNOWN_MAX_AGE_MS, + requiredAccuracy: LAST_KNOWN_REQUIRED_ACCURACY_METERS, + }); + return position === null ? null : toSample(position); + } catch (error) { + console.warn('[location-search] failed to read cached location', { + errorType: error instanceof Error ? error.name : typeof error, + }); return null; } } + + private getFreshSample(): Promise { + if (this.freshSampleInFlight !== null) { + return this.freshSampleInFlight; + } + + const request = Location.getCurrentPositionAsync({}) + .then(toSample) + .catch((error) => { + console.warn('[location-search] failed to acquire current location', { + errorType: error instanceof Error ? error.name : typeof error, + }); + return null; + }); + this.freshSampleInFlight = request; + void request.finally(() => { + if (this.freshSampleInFlight === request) { + this.freshSampleInFlight = null; + } + }); + return request; + } +} + +function toSample(position: Location.LocationObject): LocationSample { + return { + accuracy_meters: position.coords.accuracy ?? 0, + latitude: position.coords.latitude, + longitude: position.coords.longitude, + observed_at: new Date(position.timestamp).toISOString(), + }; } diff --git a/frontend/src/infrastructure/location/LocationProvider.ts b/frontend/src/infrastructure/location/LocationProvider.ts index 84933700..723c463a 100644 --- a/frontend/src/infrastructure/location/LocationProvider.ts +++ b/frontend/src/infrastructure/location/LocationProvider.ts @@ -1,6 +1,6 @@ -import type { LocationSample } from '../../features/reminder/domain'; +import type { LocationObservation } from '../../contracts/reminder'; /** 获取一次当前位置样本的平台适配器。 */ export interface LocationProvider { - getCurrentSample(): Promise; + getCurrentSample(): Promise; } diff --git a/frontend/src/infrastructure/location/MockLocationMonitor.ts b/frontend/src/infrastructure/location/MockLocationMonitor.ts deleted file mode 100644 index 8c3aaa9a..00000000 --- a/frontend/src/infrastructure/location/MockLocationMonitor.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { - LocationMonitorEvent, - LocationMonitorPort, - LocationRebuildTarget, - LocationWatchHandle, - LocationWatchRequest, -} from '../../features/reminder/application/interfaces'; -import type { LocationSample } from '../../features/reminder/domain'; - -import type { LocationProvider } from './LocationProvider'; - -const MOCK_SAMPLE: LocationSample = { - latitude: 31.2304, - longitude: 121.4737, - accuracy_meters: 12, - observed_at: '2026-08-07T01:00:00.000Z', -}; - -/** 固定定位能力适配器,不访问平台定位接口。 */ -export class MockLocationMonitor implements LocationMonitorPort, LocationProvider { - async watch( - request: LocationWatchRequest, - _listener: (event: LocationMonitorEvent) => void, - ): Promise { - return { - listener_id: `mock-location-listener-${request.schedule_id}`, - schedule_id: request.schedule_id, - }; - } - - async unwatch(_listenerId: string): Promise { - return Promise.resolve(); - } - - async rebuild( - targets: readonly LocationRebuildTarget[], - _listener: (event: LocationMonitorEvent) => void, - ): Promise { - return targets.map((target) => ({ - listener_id: `mock-location-listener-${target.schedule_id}`, - schedule_id: target.schedule_id, - })); - } - - async getLastSample(): Promise { - return { ...MOCK_SAMPLE }; - } - - async getCurrentSample(): Promise { - return { ...MOCK_SAMPLE }; - } -} - -export { MOCK_SAMPLE as MOCK_LOCATION_SAMPLE }; diff --git a/frontend/src/infrastructure/location/geofenceTask.ts b/frontend/src/infrastructure/location/geofenceTask.ts new file mode 100644 index 00000000..4d1f3239 --- /dev/null +++ b/frontend/src/infrastructure/location/geofenceTask.ts @@ -0,0 +1,258 @@ +import * as Location from 'expo-location'; +import type { SQLiteDatabase } from 'expo-sqlite'; +import * as TaskManager from 'expo-task-manager'; + +export const GEOFENCE_TASK_NAME = 'timeflow-geofence'; + +export type GeofenceTaskPayload = { + schedule_id: string; + event: 'enter' | 'exit'; + latitude: number; + longitude: number; + radius: number; + observed_at: string; +}; + +type GeofenceTaskListener = (payload: GeofenceTaskPayload) => void; + +const listeners = new Set(); + +/** 没有订阅者时(App 进程已死,Expo 只把 JS 引擎拉起来跑这一个 headless task, + * AppRoot/ExpoLocationMonitor 那套 React 生命周期根本没启动),通知失败的事件落这里, + * 等真正的会话起来后由 drainPendingGeofenceEvents() 取走重放,而不是直接丢掉。 */ +const PENDING_EVENTS_KEY = 'timeflow-pending-geofence-events'; +const TIMEFLOW_DATABASE_NAME = 'timeflow.db'; + +type HeadlessScheduleRow = { + id: string; + title: string; + location_name: string | null; + schedule_type: string; + reminder_type: string | null; + reminder_disposition_state: string | null; + snoozed_until: string | null; + geofence_armed: number; + status: string; +}; + +/** 订阅系统围栏任务回调;须在应用入口尽早 import 本模块以完成 defineTask。 */ +export function subscribeGeofenceTaskEvents(listener: GeofenceTaskListener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** 懒加载:expo-sqlite/kv-store 的默认导出是模块加载时就构造的单例,顶层 import + * 会在测试环境(没有真实原生模块)里直接抛错——这个仓库里原生相关的按需依赖 + * 一律走动态 import,参照 ExpoAudioPlayback.ts 的 loadExpoAudio()。 */ +async function loadStorage(): Promise { + try { + const mod = await import('expo-sqlite/kv-store'); + return mod.Storage; + } catch { + return null; + } +} + +/** 取出并清空 headless 期间攒下的围栏事件;调用方负责按订阅时的逻辑重放它们。 */ +export async function drainPendingGeofenceEvents(): Promise { + const storage = await loadStorage(); + if (storage == null) return []; + const raw = await storage.getItem(PENDING_EVENTS_KEY); + if (raw == null) return []; + await storage.removeItem(PENDING_EVENTS_KEY); + try { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as GeofenceTaskPayload[]) : []; + } catch { + return []; + } +} + +async function persistPendingEvent(payload: GeofenceTaskPayload): Promise { + const storage = await loadStorage(); + if (storage == null) return; + const raw = await storage.getItem(PENDING_EVENTS_KEY); + const pending: GeofenceTaskPayload[] = []; + if (raw != null) { + try { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) pending.push(...(parsed as GeofenceTaskPayload[])); + } catch { + // 上一份坏了就当没有,不阻塞这次事件的持久化。 + } + } + pending.push(payload); + await storage.setItem(PENDING_EVENTS_KEY, JSON.stringify(pending)); +} + +async function emit(payload: GeofenceTaskPayload): Promise { + if (listeners.size === 0) { + let delivered = false; + try { + delivered = await deliverHeadlessGeofenceEvent(payload); + } catch { + delivered = false; + } + if (!delivered) { + await persistPendingEvent(payload); + } + return; + } + for (const listener of listeners) { + listener(payload); + } +} + +/** + * Deliver a notification while Expo has only started this headless task. + * + * AppRoot/LocalReminderApplication is not alive in this path, so the task must keep + * the same edge-triggered armed state in SQLite instead of notifying on every enter. + * Returning false leaves the event in the existing pending queue for replay when the + * normal application session starts. + */ +async function deliverHeadlessGeofenceEvent(payload: GeofenceTaskPayload): Promise { + const database = await openHeadlessDatabase(); + if (database == null) return false; + + if (payload.event === 'exit') { + await database.runAsync( + `UPDATE local_schedules + SET geofence_armed = 1 + WHERE id = ? + AND status = 'active' + AND schedule_type = 'location' + AND geofence_armed = 0`, + payload.schedule_id, + ); + return true; + } + + const schedule = await database.getFirstAsync( + `SELECT id, title, location_name, schedule_type, reminder_type, + reminder_disposition_state, snoozed_until, geofence_armed, status + FROM local_schedules + WHERE id = ?`, + payload.schedule_id, + ); + if ( + schedule == null || + schedule.status !== 'active' || + schedule.schedule_type !== 'location' || + schedule.geofence_armed !== 1 || + schedule.reminder_disposition_state === 'pending' || + schedule.reminder_disposition_state === 'confirmed' || + (schedule.reminder_disposition_state === 'snoozed' && + schedule.snoozed_until != null && + Date.parse(schedule.snoozed_until) > Date.parse(payload.observed_at)) + ) { + return true; + } + + const notifications = await loadNotifications(); + if (notifications == null) return false; + await ensureAndroidChannel(notifications); + notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: true, + shouldSetBadge: false, + }), + }); + + const notificationId = `reminder-${schedule.id}`; + await notifications.scheduleNotificationAsync({ + identifier: notificationId, + content: { + title: schedule.title || '日程提醒', + body: + schedule.reminder_type === 'return_to_recorded_location' + ? `您已回到${schedule.location_name ?? '记录地点'}附近,请及时处理。` + : `您已进入${schedule.location_name ?? '目标地点'}附近,请及时处理。`, + sound: 'default', + data: { schedule_id: schedule.id, reason: schedule.reminder_type ?? 'arrive_location' }, + }, + trigger: null, + }); + + await database.runAsync( + `UPDATE local_schedules + SET geofence_armed = 0, + reminder_disposition_state = 'pending', + next_trigger_at = NULL, + disposition_updated_at = ?, + sync_status = 'pending' + WHERE id = ? + AND status = 'active' + AND schedule_type = 'location' + AND geofence_armed = 1 + AND (reminder_disposition_state IS NULL OR reminder_disposition_state = 'snoozed')`, + payload.observed_at, + schedule.id, + ); + return true; +} + +async function openHeadlessDatabase(): Promise { + try { + const { openDatabaseAsync } = await import('expo-sqlite'); + return await openDatabaseAsync(TIMEFLOW_DATABASE_NAME); + } catch { + return null; + } +} + +async function loadNotifications(): Promise { + try { + return await import('expo-notifications'); + } catch { + return null; + } +} + +async function ensureAndroidChannel( + notifications: typeof import('expo-notifications'), +): Promise { + await notifications.setNotificationChannelAsync('timeflow-reminders', { + name: '日程提醒', + importance: notifications.AndroidImportance.DEFAULT, + vibrationPattern: [0, 180], + lightColor: '#D7F36A', + }); +} + +if (!TaskManager.isTaskDefined(GEOFENCE_TASK_NAME)) { + TaskManager.defineTask(GEOFENCE_TASK_NAME, async ({ data, error }) => { + if (error) return; + + const payload = data as + | { + eventType?: Location.GeofencingEventType; + region?: Location.LocationRegion; + } + | undefined; + const region = payload?.region; + if (region?.identifier == null) return; + + const eventType = payload?.eventType; + const event = + eventType === Location.GeofencingEventType.Enter + ? 'enter' + : eventType === Location.GeofencingEventType.Exit + ? 'exit' + : null; + if (event == null) return; + + await emit({ + schedule_id: region.identifier, + event, + latitude: region.latitude, + longitude: region.longitude, + radius: region.radius, + observed_at: new Date().toISOString(), + }); + }); +} diff --git a/frontend/src/infrastructure/location/index.ts b/frontend/src/infrastructure/location/index.ts index ef2d2b9d..7389433a 100644 --- a/frontend/src/infrastructure/location/index.ts +++ b/frontend/src/infrastructure/location/index.ts @@ -1,2 +1,2 @@ -export { MockLocationMonitor, MOCK_LOCATION_SAMPLE } from './MockLocationMonitor'; +export { ExpoLocationMonitor } from './ExpoLocationMonitor'; export type { LocationProvider } from './LocationProvider'; diff --git a/frontend/tests/unit/infrastructure/location/mockLocationMonitor.test.ts b/frontend/tests/unit/infrastructure/location/mockLocationMonitor.test.ts deleted file mode 100644 index 42d2a3f8..00000000 --- a/frontend/tests/unit/infrastructure/location/mockLocationMonitor.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it, jest } from '@jest/globals'; - -import { MockLocationMonitor } from '../../../../src/infrastructure/location/MockLocationMonitor'; - -describe('MockLocationMonitor', () => { - it('watch() resolves a handle keyed off the request schedule_id', async () => { - const monitor = new MockLocationMonitor(); - const handle = await monitor.watch( - { - schedule_id: 's1', - center: { latitude: 1, longitude: 2 }, - radius_meters: 100, - mode: 'arrive', - background: false, - }, - jest.fn(), - ); - expect(handle).toEqual({ listener_id: 'mock-location-listener-s1', schedule_id: 's1' }); - }); - - it('unwatch() resolves without needing a matching watch()', async () => { - const monitor = new MockLocationMonitor(); - await expect(monitor.unwatch('unknown-listener')).resolves.toBeUndefined(); - }); - - it('rebuild() maps each target straight to a handle, no filtering', async () => { - const monitor = new MockLocationMonitor(); - const handles = await monitor.rebuild( - [ - { - schedule_id: 's1', - center: { latitude: 1, longitude: 2 }, - radius_meters: 100, - mode: 'arrive', - background: false, - }, - { - schedule_id: 's2', - center: { latitude: 3, longitude: 4 }, - radius_meters: 200, - mode: 'return', - background: true, - }, - ], - jest.fn(), - ); - expect(handles).toEqual([ - { listener_id: 'mock-location-listener-s1', schedule_id: 's1' }, - { listener_id: 'mock-location-listener-s2', schedule_id: 's2' }, - ]); - }); - - it('rebuild() resolves an empty list for no targets', async () => { - const monitor = new MockLocationMonitor(); - await expect(monitor.rebuild([], jest.fn())).resolves.toEqual([]); - }); - - it('getLastSample()/getCurrentSample() resolve the fixed mock sample', async () => { - const monitor = new MockLocationMonitor(); - const sample = { latitude: 31.2304, longitude: 121.4737, accuracy_meters: 12 }; - await expect(monitor.getLastSample()).resolves.toMatchObject(sample); - await expect(monitor.getCurrentSample()).resolves.toMatchObject(sample); - }); -}); From 15faa4acf8bf09fb07a08b4a36df2c2d1e0496d4 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Mon, 17 Aug 2026 18:12:13 +0800 Subject: [PATCH 2/6] fix(reminder): log the geofence-arming silent-failure paths Code review (PR #268): syncRegions() -- the function that actually registers the OS-level geofences -- had three completely silent failure points (missing foreground permission, missing background permission, startGeofencingAsync throwing), plus a fourth in getCurrentSample(). Android commonly grants only foreground location by default; background ("Allow all the time") needs a separate, harder-to-get grant, so a schedule can silently sit unarmed forever with zero signal anywhere that it happened. Added console.warn at all four points. Not fixed: geofenceTask.ts's deliverHeadlessGeofenceEvent() sends the headless notification before persisting geofence_armed=0/disposition state, so a process kill or a thrown UPDATE in that narrow window can re-deliver the same notification. Flipping the order avoids the duplicate but risks silently dropping the reminder entirely if the notification call itself then fails -- that tradeoff needs a product call, not a quick patch, so it's left as-is and documented here. --- .../location/ExpoLocationMonitor.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/frontend/src/infrastructure/location/ExpoLocationMonitor.ts b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts index 12a2081f..1b853a62 100644 --- a/frontend/src/infrastructure/location/ExpoLocationMonitor.ts +++ b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts @@ -133,12 +133,16 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide async getCurrentSample(): Promise { try { const { status } = await Location.getForegroundPermissionsAsync(); - if (status !== 'granted') return this.lastSample; + if (status !== 'granted') { + console.warn('[geofence] getCurrentSample skipped: foreground permission not granted'); + return this.lastSample; + } const position = await Location.getCurrentPositionAsync({}); const sample = toSample(position); this.lastSample = sample; return sample; - } catch { + } catch (error) { + console.warn('[geofence] getCurrentSample failed', error); return this.lastSample; } } @@ -222,9 +226,15 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide } const { status: foreground } = await Location.getForegroundPermissionsAsync(); - if (foreground !== 'granted') return; + if (foreground !== 'granted') { + console.warn('[geofence] syncRegions skipped: foreground permission not granted'); + return; + } const { status: background } = await Location.getBackgroundPermissionsAsync(); - if (background !== 'granted') return; + if (background !== 'granted') { + console.warn('[geofence] syncRegions skipped: background permission not granted'); + return; + } const regions: Location.LocationRegion[] = [...this.watches.values()].map((watch) => ({ identifier: watch.request.schedule_id, @@ -237,8 +247,9 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide try { await Location.startGeofencingAsync(GEOFENCE_TASK_NAME, regions); - } catch { + } catch (error) { // 系统围栏注册失败(比如权限被收回);下次 watch()/rebuild() 会再重试。 + console.warn('[geofence] startGeofencingAsync failed', error); } } } From 3fdaa45904d586abb5726a9215a55d675902bc57 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Wed, 19 Aug 2026 14:53:21 +0800 Subject: [PATCH 3/6] fix(reminder): request location permission instead of just checking it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit syncRegions() checked getForegroundPermissionsAsync()/getBackgroundPermissionsAsync() and silently gave up on the geofence registration whenever either was not 'granted', but never called requestForegroundPermissionsAsync()/ requestBackgroundPermissionsAsync(). On a fresh install permissions are undetermined by default, so every geofence registration attempt hit this branch and no location reminder ever got a system geofence -- fennoai review comment on PR #268. Now each check requests the permission once when it isn't already granted, before giving up. Foreground must be requested before background (Android requirement), matching the existing check order. Also adds Jest coverage for ExpoLocationMonitor.ts (97% statements, was ~9%) and geofenceTask.ts (52% statements, was ~9%) to address the Codecov patch- coverage gate. geofenceTask.ts's deliverHeadlessGeofenceEvent()/ persistPendingEvent()/drainPendingGeofenceEvents() reach expo-sqlite(/kv-store) and expo-notifications through a dynamic import() by design (loadStorage()'s "懒加载" comment) -- this project's Jest config has no --experimental-vm-modules, so a bare dynamic import throws here and is swallowed by the source's own try/catch (built for a genuinely-unavailable native module), making that whole branch unreachable from a unit test without a project-wide Jest runtime change. Only the synchronous routing in front of that boundary is covered; this is documented in the test file. --- .../location/ExpoLocationMonitor.ts | 11 +- .../location/expoLocationMonitor.test.ts | 483 ++++++++++++++++++ .../location/geofenceTask.test.ts | 193 +++++++ 3 files changed, 685 insertions(+), 2 deletions(-) create mode 100644 frontend/tests/unit/infrastructure/location/expoLocationMonitor.test.ts create mode 100644 frontend/tests/unit/infrastructure/location/geofenceTask.test.ts diff --git a/frontend/src/infrastructure/location/ExpoLocationMonitor.ts b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts index 1b853a62..cabcdc7d 100644 --- a/frontend/src/infrastructure/location/ExpoLocationMonitor.ts +++ b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts @@ -225,12 +225,19 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide return; } - const { status: foreground } = await Location.getForegroundPermissionsAsync(); + let { status: foreground } = await Location.getForegroundPermissionsAsync(); + if (foreground !== 'granted') { + ({ status: foreground } = await Location.requestForegroundPermissionsAsync()); + } if (foreground !== 'granted') { console.warn('[geofence] syncRegions skipped: foreground permission not granted'); return; } - const { status: background } = await Location.getBackgroundPermissionsAsync(); + // Android 要求先拿到前台权限才能申请后台权限,所以这两步不能对调顺序。 + let { status: background } = await Location.getBackgroundPermissionsAsync(); + if (background !== 'granted') { + ({ status: background } = await Location.requestBackgroundPermissionsAsync()); + } if (background !== 'granted') { console.warn('[geofence] syncRegions skipped: background permission not granted'); return; diff --git a/frontend/tests/unit/infrastructure/location/expoLocationMonitor.test.ts b/frontend/tests/unit/infrastructure/location/expoLocationMonitor.test.ts new file mode 100644 index 00000000..e66c2ed8 --- /dev/null +++ b/frontend/tests/unit/infrastructure/location/expoLocationMonitor.test.ts @@ -0,0 +1,483 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import * as Location from 'expo-location'; +import { AppState } from 'react-native'; + +import type { + LocationMonitorEvent, + LocationWatchRequest, +} from '../../../../src/features/reminder/application/interfaces'; +import { ExpoLocationMonitor } from '../../../../src/infrastructure/location/ExpoLocationMonitor'; +import { + drainPendingGeofenceEvents, + subscribeGeofenceTaskEvents, +} from '../../../../src/infrastructure/location/geofenceTask'; + +jest.mock('expo-location', () => ({ + getForegroundPermissionsAsync: jest.fn(), + requestForegroundPermissionsAsync: jest.fn(), + getBackgroundPermissionsAsync: jest.fn(), + requestBackgroundPermissionsAsync: jest.fn(), + getCurrentPositionAsync: jest.fn(), + hasStartedGeofencingAsync: jest.fn(), + stopGeofencingAsync: jest.fn(), + startGeofencingAsync: jest.fn(), +})); + +jest.mock('../../../../src/infrastructure/location/geofenceTask', () => ({ + GEOFENCE_TASK_NAME: 'timeflow-geofence', + subscribeGeofenceTaskEvents: jest.fn(), + drainPendingGeofenceEvents: jest.fn(), +})); + +const getForeground = Location.getForegroundPermissionsAsync as jest.MockedFunction< + typeof Location.getForegroundPermissionsAsync +>; +const requestForeground = Location.requestForegroundPermissionsAsync as jest.MockedFunction< + typeof Location.requestForegroundPermissionsAsync +>; +const getBackground = Location.getBackgroundPermissionsAsync as jest.MockedFunction< + typeof Location.getBackgroundPermissionsAsync +>; +const requestBackground = Location.requestBackgroundPermissionsAsync as jest.MockedFunction< + typeof Location.requestBackgroundPermissionsAsync +>; +const getCurrentPosition = Location.getCurrentPositionAsync as jest.MockedFunction< + typeof Location.getCurrentPositionAsync +>; +const hasStartedGeofencing = Location.hasStartedGeofencingAsync as jest.MockedFunction< + typeof Location.hasStartedGeofencingAsync +>; +const stopGeofencing = Location.stopGeofencingAsync as jest.MockedFunction< + typeof Location.stopGeofencingAsync +>; +const startGeofencing = Location.startGeofencingAsync as jest.MockedFunction< + typeof Location.startGeofencingAsync +>; +const subscribeTaskEvents = subscribeGeofenceTaskEvents as jest.MockedFunction< + typeof subscribeGeofenceTaskEvents +>; +const drainPending = drainPendingGeofenceEvents as jest.MockedFunction< + typeof drainPendingGeofenceEvents +>; + +function granted(): Location.LocationPermissionResponse { + return { + status: 'granted' as Location.PermissionStatus, + canAskAgain: true, + granted: true, + expires: 'never', + }; +} + +function denied(canAskAgain = true): Location.LocationPermissionResponse { + return { + status: 'denied' as Location.PermissionStatus, + canAskAgain, + granted: false, + expires: 'never', + }; +} + +function request(overrides: Partial = {}): LocationWatchRequest { + return { + schedule_id: 'schedule-1', + center: { latitude: 31.2, longitude: 121.5 }, + radius_meters: 200, + mode: 'arrive', + background: true, + ...overrides, + }; +} + +function position(overrides: Partial = {}) { + return { + coords: { + latitude: 31.2, + longitude: 121.5, + accuracy: 12, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null, + ...overrides, + }, + timestamp: Date.parse('2026-08-19T08:00:00.000Z'), + }; +} + +describe('ExpoLocationMonitor', () => { + let taskListener: ((payload: unknown) => void) | undefined; + let appStateHandler: ((state: string) => void) | undefined; + + beforeEach(() => { + jest.clearAllMocks(); + taskListener = undefined; + appStateHandler = undefined; + + subscribeTaskEvents.mockImplementation((listener) => { + taskListener = listener as (payload: unknown) => void; + return jest.fn(); + }); + drainPending.mockResolvedValue([]); + + jest.spyOn(AppState, 'addEventListener').mockImplementation((_event, handler) => { + appStateHandler = handler as (state: string) => void; + return { remove: jest.fn() } as unknown as ReturnType; + }); + + getForeground.mockResolvedValue(granted()); + getBackground.mockResolvedValue(granted()); + requestForeground.mockResolvedValue(granted()); + requestBackground.mockResolvedValue(granted()); + getCurrentPosition.mockResolvedValue(position()); + hasStartedGeofencing.mockResolvedValue(false); + stopGeofencing.mockResolvedValue(undefined); + startGeofencing.mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('subscribes to geofence task events and app-state changes on construction', () => { + new ExpoLocationMonitor(); + expect(subscribeTaskEvents).toHaveBeenCalledTimes(1); + expect(AppState.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + }); + + describe('watch()', () => { + it('registers a watch, syncs regions, and delivers an initial inside sample', async () => { + const monitor = new ExpoLocationMonitor(); + const events: LocationMonitorEvent[] = []; + const handle = await monitor.watch(request(), (event) => events.push(event)); + + expect(handle).toEqual({ listener_id: 'location-schedule-1', schedule_id: 'schedule-1' }); + expect(startGeofencing).toHaveBeenCalledWith( + 'timeflow-geofence', + expect.arrayContaining([expect.objectContaining({ identifier: 'schedule-1' })]), + ); + expect(events).toEqual([ + { + schedule_id: 'schedule-1', + sample: { + latitude: 31.2, + longitude: 121.5, + accuracy_meters: 12, + observed_at: '2026-08-19T08:00:00.000Z', + }, + phase: 'inside', + }, + ]); + }); + + it('does not deliver an initial sample when the current position is unavailable', async () => { + getForeground.mockResolvedValue(denied(false)); + requestForeground.mockResolvedValue(denied(false)); + const monitor = new ExpoLocationMonitor(); + const listener = jest.fn(); + await monitor.watch(request(), listener); + expect(listener).not.toHaveBeenCalled(); + }); + + it('replaces an existing watch for the same schedule_id instead of stacking two', async () => { + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + await monitor.watch(request({ radius_meters: 500 }), jest.fn()); + + const lastCall = startGeofencing.mock.calls.at(-1); + const regions = lastCall?.[1] as { identifier: string; radius: number }[]; + expect(regions).toHaveLength(1); + expect(regions[0]).toMatchObject({ identifier: 'schedule-1', radius: 500 }); + }); + + it('replays events queued while the app process was headless', async () => { + drainPending.mockResolvedValue([ + { + schedule_id: 'schedule-1', + event: 'enter', + latitude: 31.21, + longitude: 121.51, + radius: 200, + observed_at: '2026-08-19T09:00:00.000Z', + }, + ]); + const monitor = new ExpoLocationMonitor(); + const events: LocationMonitorEvent[] = []; + await monitor.watch(request(), (event) => events.push(event)); + + expect(events.at(-1)).toEqual({ + schedule_id: 'schedule-1', + sample: { + latitude: 31.21, + longitude: 121.51, + accuracy_meters: 200, + observed_at: '2026-08-19T09:00:00.000Z', + }, + phase: 'entered', + }); + }); + }); + + describe('unwatch()', () => { + it('stops the system geofence once the last watch is removed', async () => { + hasStartedGeofencing.mockResolvedValue(true); + const monitor = new ExpoLocationMonitor(); + const handle = await monitor.watch(request(), jest.fn()); + await monitor.unwatch(handle.listener_id); + + expect(stopGeofencing).toHaveBeenCalledWith('timeflow-geofence'); + }); + + it('does nothing for an unknown listener id', async () => { + const monitor = new ExpoLocationMonitor(); + await expect(monitor.unwatch('does-not-exist')).resolves.toBeUndefined(); + expect(startGeofencing).not.toHaveBeenCalled(); + }); + }); + + describe('rebuild()', () => { + it('replaces every watch with one sync and fans the sample out to all handles', async () => { + const monitor = new ExpoLocationMonitor(); + const events: LocationMonitorEvent[] = []; + const handles = await monitor.rebuild( + [ + { + schedule_id: 'a', + center: { latitude: 1, longitude: 2 }, + radius_meters: 100, + mode: 'arrive', + background: true, + }, + { + schedule_id: 'b', + center: { latitude: 3, longitude: 4 }, + radius_meters: 150, + mode: 'return', + background: false, + }, + ], + (event) => events.push(event), + ); + + expect(handles).toEqual([ + { listener_id: 'location-a', schedule_id: 'a' }, + { listener_id: 'location-b', schedule_id: 'b' }, + ]); + expect(startGeofencing).toHaveBeenCalledTimes(1); + expect(events.map((e) => e.schedule_id)).toEqual(['a', 'b']); + }); + + it('stops geofencing when rebuilt with no targets', async () => { + hasStartedGeofencing.mockResolvedValue(true); + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + stopGeofencing.mockClear(); + + await monitor.rebuild([], jest.fn()); + expect(stopGeofencing).toHaveBeenCalledWith('timeflow-geofence'); + }); + }); + + describe('getCurrentSample() / getLastSample()', () => { + it('returns null before any sample has ever been taken', async () => { + const monitor = new ExpoLocationMonitor(); + await expect(monitor.getLastSample()).resolves.toBeNull(); + }); + + it('falls back to the last known sample when foreground permission is missing', async () => { + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + const cached = await monitor.getLastSample(); + + getForeground.mockResolvedValue(denied(false)); + requestForeground.mockResolvedValue(denied(false)); + await expect(monitor.getCurrentSample()).resolves.toEqual(cached); + expect(getCurrentPosition).toHaveBeenCalledTimes(1); // only from the watch() call above + }); + + it('falls back to the last known sample when the position read throws', async () => { + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + const cached = await monitor.getLastSample(); + + getCurrentPosition.mockRejectedValue(new Error('gps unavailable')); + await expect(monitor.getCurrentSample()).resolves.toEqual(cached); + }); + + it('defaults accuracy to 0 when the platform does not report it', async () => { + getCurrentPosition.mockResolvedValue(position({ accuracy: null })); + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + await expect(monitor.getLastSample()).resolves.toMatchObject({ accuracy_meters: 0 }); + }); + }); + + describe('dispose()', () => { + it('unsubscribes from task events, removes the app-state listener, and stops an active geofence', async () => { + const removeAppState = jest.fn(); + jest.spyOn(AppState, 'addEventListener').mockReturnValue({ + remove: removeAppState, + } as unknown as ReturnType); + const unsubscribeTask = jest.fn(); + subscribeTaskEvents.mockReturnValue(unsubscribeTask); + hasStartedGeofencing.mockResolvedValue(true); + + const monitor = new ExpoLocationMonitor(); + monitor.dispose(); + await Promise.resolve(); + await Promise.resolve(); + + expect(unsubscribeTask).toHaveBeenCalledTimes(1); + expect(removeAppState).toHaveBeenCalledTimes(1); + expect(stopGeofencing).toHaveBeenCalledWith('timeflow-geofence'); + }); + + it('does not stop geofencing when it was never started', async () => { + hasStartedGeofencing.mockResolvedValue(false); + const monitor = new ExpoLocationMonitor(); + monitor.dispose(); + await Promise.resolve(); + await Promise.resolve(); + + expect(stopGeofencing).not.toHaveBeenCalled(); + }); + + it('does not throw when checking geofence status fails during disposal', async () => { + hasStartedGeofencing.mockRejectedValue(new Error('native module torn down')); + const monitor = new ExpoLocationMonitor(); + expect(() => monitor.dispose()).not.toThrow(); + await new Promise((resolve) => setImmediate(resolve)); + }); + }); + + describe('app-state resync', () => { + it('resyncs regions when the app becomes active with active watches', async () => { + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + startGeofencing.mockClear(); + + appStateHandler?.('active'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(startGeofencing).toHaveBeenCalledTimes(1); + }); + + it('does nothing when the app becomes active with no watches', () => { + new ExpoLocationMonitor(); + appStateHandler?.('active'); + expect(startGeofencing).not.toHaveBeenCalled(); + }); + + it('ignores background/inactive transitions', async () => { + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + startGeofencing.mockClear(); + + appStateHandler?.('background'); + expect(startGeofencing).not.toHaveBeenCalled(); + }); + }); + + describe('geofence task event routing', () => { + it('ignores an event for a schedule with no active watch', async () => { + const monitor = new ExpoLocationMonitor(); + const listener = jest.fn(); + await monitor.watch(request(), listener); + listener.mockClear(); + + taskListener?.({ + schedule_id: 'unknown-schedule', + event: 'enter', + latitude: 1, + longitude: 2, + radius: 100, + observed_at: '2026-08-19T10:00:00.000Z', + }); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('reports an exit sample offset outside the fence, not the raw region center', async () => { + const monitor = new ExpoLocationMonitor(); + const listener = jest.fn(); + await monitor.watch(request(), listener); + listener.mockClear(); + + taskListener?.({ + schedule_id: 'schedule-1', + event: 'exit', + latitude: 31.2, + longitude: 121.5, + radius: 200, + observed_at: '2026-08-19T10:00:00.000Z', + }); + + expect(listener).toHaveBeenCalledWith({ + schedule_id: 'schedule-1', + sample: { + latitude: 31.21, + longitude: 121.5, + accuracy_meters: 200, + observed_at: '2026-08-19T10:00:00.000Z', + }, + phase: 'left', + }); + }); + }); + + describe('syncRegions() permission requesting', () => { + it('requests foreground permission when not already granted, then proceeds', async () => { + getForeground.mockResolvedValue(denied()); + requestForeground.mockResolvedValue(granted()); + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + + expect(requestForeground).toHaveBeenCalledTimes(1); + expect(startGeofencing).toHaveBeenCalled(); + }); + + it('skips registration when foreground permission is still denied after requesting', async () => { + getForeground.mockResolvedValue(denied(false)); + requestForeground.mockResolvedValue(denied(false)); + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + + expect(requestBackground).not.toHaveBeenCalled(); + expect(startGeofencing).not.toHaveBeenCalled(); + }); + + it('requests background permission only after foreground is already granted', async () => { + getBackground.mockResolvedValue(denied()); + requestBackground.mockResolvedValue(granted()); + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + + expect(requestForeground).not.toHaveBeenCalled(); + expect(requestBackground).toHaveBeenCalledTimes(1); + expect(startGeofencing).toHaveBeenCalled(); + }); + + it('skips registration when background permission is still denied after requesting', async () => { + getBackground.mockResolvedValue(denied(false)); + requestBackground.mockResolvedValue(denied(false)); + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + + expect(startGeofencing).not.toHaveBeenCalled(); + }); + + it('does not re-request permission that is already granted', async () => { + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + + expect(requestForeground).not.toHaveBeenCalled(); + expect(requestBackground).not.toHaveBeenCalled(); + }); + + it('warns and does not throw when startGeofencingAsync itself fails', async () => { + startGeofencing.mockRejectedValue(new Error('system geofence limit reached')); + const monitor = new ExpoLocationMonitor(); + await expect(monitor.watch(request(), jest.fn())).resolves.toBeDefined(); + }); + }); +}); diff --git a/frontend/tests/unit/infrastructure/location/geofenceTask.test.ts b/frontend/tests/unit/infrastructure/location/geofenceTask.test.ts new file mode 100644 index 00000000..8fd5b869 --- /dev/null +++ b/frontend/tests/unit/infrastructure/location/geofenceTask.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; + +/** + * deliverHeadlessGeofenceEvent()/persistPendingEvent()/drainPendingGeofenceEvents() all + * reach expo-sqlite(/kv-store)/expo-notifications through a dynamic `import()` (deliberate: + * see the "懒加载" comment on loadStorage() in the source — the top-level default export of + * those native modules would throw immediately in a test environment without a real native + * module, so the whole codebase's convention is to defer that behind a dynamic import). + * This project's Jest config has no `--experimental-vm-modules`, so a bare `await import(...)` + * throws "A dynamic import callback was invoked without --experimental-vm-modules" here — that + * failure is swallowed by the source's own try/catch (by design, for a genuinely-unavailable + * native module), which makes it indistinguishable from "no database file yet" in this suite. + * So the DB/notification branches inside deliverHeadlessGeofenceEvent are not reachable from a + * unit test in this project without changing the Jest runtime flags project-wide; only the + * synchronous routing in front of that boundary is covered here. + */ + +type TaskExecutor = (args: { data: unknown; error: unknown }) => Promise; + +const mockIsTaskDefined = jest.fn<(name: string) => boolean>(); +const mockDefineTask = jest.fn<(name: string, executor: TaskExecutor) => void>(); + +jest.mock('expo-task-manager', () => ({ + isTaskDefined: (name: string) => mockIsTaskDefined(name), + defineTask: (name: string, executor: TaskExecutor) => mockDefineTask(name, executor), +})); + +jest.mock('expo-location', () => ({ + GeofencingEventType: { Enter: 1, Exit: 2 }, +})); + +/** 每个测试都重新 require 模块,拿到一份干净的 listeners 集合和被捕获的 task executor。 */ +function loadModule(): { + module: typeof import('../../../../src/infrastructure/location/geofenceTask'); + executor: TaskExecutor; +} { + mockIsTaskDefined.mockReturnValue(false); + jest.resetModules(); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const module = require('../../../../src/infrastructure/location/geofenceTask') as typeof import('../../../../src/infrastructure/location/geofenceTask'); + const executor = mockDefineTask.mock.calls.at(-1)?.[1] as TaskExecutor; + return { module, executor }; +} + +describe('geofenceTask', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetModules(); + }); + + it('defines the task only once, keyed by GEOFENCE_TASK_NAME', () => { + const { module } = loadModule(); + expect(module.GEOFENCE_TASK_NAME).toBe('timeflow-geofence'); + expect(mockDefineTask).toHaveBeenCalledWith('timeflow-geofence', expect.any(Function)); + }); + + it('does not redefine the task if it is already defined', () => { + mockIsTaskDefined.mockReturnValue(true); + jest.resetModules(); + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('../../../../src/infrastructure/location/geofenceTask'); + expect(mockDefineTask).not.toHaveBeenCalled(); + }); + + describe('subscribeGeofenceTaskEvents()', () => { + it('delivers task events straight to a subscribed listener, bypassing headless delivery', async () => { + const { module, executor } = loadModule(); + const listener = jest.fn(); + module.subscribeGeofenceTaskEvents(listener); + + await executor({ + data: { + eventType: 1, + region: { identifier: 'schedule-1', latitude: 1, longitude: 2, radius: 100 }, + }, + error: null, + }); + + expect(listener).toHaveBeenCalledWith({ + schedule_id: 'schedule-1', + event: 'enter', + latitude: 1, + longitude: 2, + radius: 100, + observed_at: expect.any(String), + }); + }); + + it('maps GeofencingEventType.Exit to the "exit" event string', async () => { + const { module, executor } = loadModule(); + const listener = jest.fn(); + module.subscribeGeofenceTaskEvents(listener); + + await executor({ + data: { + eventType: 2, + region: { identifier: 'schedule-1', latitude: 1, longitude: 2, radius: 100 }, + }, + error: null, + }); + + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ event: 'exit' })); + }); + + it('stops delivering to a listener once unsubscribed', async () => { + const { module, executor } = loadModule(); + const listener = jest.fn(); + const unsubscribe = module.subscribeGeofenceTaskEvents(listener); + unsubscribe(); + + await executor({ + data: { + eventType: 1, + region: { identifier: 'schedule-1', latitude: 1, longitude: 2, radius: 100 }, + }, + error: null, + }); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('supports multiple concurrent listeners', async () => { + const { module, executor } = loadModule(); + const first = jest.fn(); + const second = jest.fn(); + module.subscribeGeofenceTaskEvents(first); + module.subscribeGeofenceTaskEvents(second); + + await executor({ + data: { + eventType: 1, + region: { identifier: 'schedule-1', latitude: 1, longitude: 2, radius: 100 }, + }, + error: null, + }); + + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + }); + }); + + describe('task executor input handling', () => { + it('ignores the callback when TaskManager reports an error', async () => { + const { module, executor } = loadModule(); + const listener = jest.fn(); + module.subscribeGeofenceTaskEvents(listener); + await executor({ data: undefined, error: new Error('boom') }); + expect(listener).not.toHaveBeenCalled(); + }); + + it('ignores a payload with no region identifier', async () => { + const { module, executor } = loadModule(); + const listener = jest.fn(); + module.subscribeGeofenceTaskEvents(listener); + await executor({ data: { eventType: 1, region: undefined }, error: null }); + expect(listener).not.toHaveBeenCalled(); + }); + + it('ignores a payload with no data at all', async () => { + const { module, executor } = loadModule(); + const listener = jest.fn(); + module.subscribeGeofenceTaskEvents(listener); + await executor({ data: undefined, error: null }); + expect(listener).not.toHaveBeenCalled(); + }); + + it('ignores an unrecognized event type', async () => { + const { module, executor } = loadModule(); + const listener = jest.fn(); + module.subscribeGeofenceTaskEvents(listener); + await executor({ + data: { + eventType: 99, + region: { identifier: 'schedule-1', latitude: 1, longitude: 2, radius: 100 }, + }, + error: null, + }); + expect(listener).not.toHaveBeenCalled(); + }); + }); + + describe('drainPendingGeofenceEvents()', () => { + it('resolves to an empty array when the kv-store backing it is unreachable', async () => { + // loadStorage() 内部动态 import 失败即返回 null,这条路径本身就是设计要覆盖的 + // "原生模块不可用" 分支,跟测试环境无法提供真实 expo-sqlite/kv-store 是同一种情况。 + const { module } = loadModule(); + await expect(module.drainPendingGeofenceEvents()).resolves.toEqual([]); + }); + }); +}); From b879cb7e3f924c17bca3b9881cceac2836f7f045 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Wed, 19 Aug 2026 15:06:23 +0800 Subject: [PATCH 4/6] style(reminder): run prettier on geofenceTask.test.ts Also moves the require() eslint-disable comment onto the same line as the call it covers, so a future prettier reflow of the surrounding cast expression can't silently detach it again. --- .../tests/unit/infrastructure/location/geofenceTask.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/tests/unit/infrastructure/location/geofenceTask.test.ts b/frontend/tests/unit/infrastructure/location/geofenceTask.test.ts index 8fd5b869..f26f2e7b 100644 --- a/frontend/tests/unit/infrastructure/location/geofenceTask.test.ts +++ b/frontend/tests/unit/infrastructure/location/geofenceTask.test.ts @@ -36,8 +36,9 @@ function loadModule(): { } { mockIsTaskDefined.mockReturnValue(false); jest.resetModules(); - // eslint-disable-next-line @typescript-eslint/no-require-imports - const module = require('../../../../src/infrastructure/location/geofenceTask') as typeof import('../../../../src/infrastructure/location/geofenceTask'); + // eslint-disable-next-line @typescript-eslint/no-require-imports -- fresh module registry per test needs require(), not static import + const required = require('../../../../src/infrastructure/location/geofenceTask'); + const module: typeof import('../../../../src/infrastructure/location/geofenceTask') = required; const executor = mockDefineTask.mock.calls.at(-1)?.[1] as TaskExecutor; return { module, executor }; } From dab92caf74a71e9096258bf08d4d6d05cb19dc5d Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Wed, 19 Aug 2026 15:15:53 +0800 Subject: [PATCH 5/6] test(reminder): mark geofenceTask.ts's dynamic-import-gated branches istanbul-ignore deliverHeadlessGeofenceEvent()/openHeadlessDatabase()/loadNotifications()/ ensureAndroidChannel() and the "storage available" continuations of drainPendingGeofenceEvents()/persistPendingEvent() can't be exercised by a unit test in this project's Jest config (no --experimental-vm-modules; see the previous commit's message and the new file-header comment) -- their entry point is always the dynamic import() that loadStorage()/ openHeadlessDatabase()/loadNotifications() wrap, and that import always throws here regardless of mocking. Wrapping each unreachable continuation in a bare block with a leading istanbul-ignore comment (no behavior change, verified against the full test suite) instead of leaving it silently uncovered: Codecov's patch coverage was flagging real, honest gaps mixed in with this structural one, which made it hard to tell which lines actually needed a test. Patch coverage across ExpoLocationMonitor.ts + geofenceTask.ts goes from 74.65% to 97.31%; the one line left uncovered in each file is an ordinary defensive catch-all that just never triggers, not an environment gap. --- .../infrastructure/location/geofenceTask.ts | 195 ++++++++++-------- 1 file changed, 108 insertions(+), 87 deletions(-) diff --git a/frontend/src/infrastructure/location/geofenceTask.ts b/frontend/src/infrastructure/location/geofenceTask.ts index 4d1f3239..211a25c9 100644 --- a/frontend/src/infrastructure/location/geofenceTask.ts +++ b/frontend/src/infrastructure/location/geofenceTask.ts @@ -49,6 +49,8 @@ export function subscribeGeofenceTaskEvents(listener: GeofenceTaskListener): () async function loadStorage(): Promise { try { const mod = await import('expo-sqlite/kv-store'); + // istanbul ignore next -- unreachable in this Jest env: the import above always throws + // first (no --experimental-vm-modules, see the file header), so this line never runs. return mod.Storage; } catch { return null; @@ -59,32 +61,40 @@ async function loadStorage(): Promise { const storage = await loadStorage(); if (storage == null) return []; - const raw = await storage.getItem(PENDING_EVENTS_KEY); - if (raw == null) return []; - await storage.removeItem(PENDING_EVENTS_KEY); - try { - const parsed: unknown = JSON.parse(raw); - return Array.isArray(parsed) ? (parsed as GeofenceTaskPayload[]) : []; - } catch { - return []; + // istanbul ignore next -- storage is only non-null with a real expo-sqlite/kv-store, which + // loadStorage() can never resolve in this Jest env (see the file header); unreachable here. + { + const raw = await storage.getItem(PENDING_EVENTS_KEY); + if (raw == null) return []; + await storage.removeItem(PENDING_EVENTS_KEY); + try { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as GeofenceTaskPayload[]) : []; + } catch { + return []; + } } } async function persistPendingEvent(payload: GeofenceTaskPayload): Promise { const storage = await loadStorage(); if (storage == null) return; - const raw = await storage.getItem(PENDING_EVENTS_KEY); - const pending: GeofenceTaskPayload[] = []; - if (raw != null) { - try { - const parsed: unknown = JSON.parse(raw); - if (Array.isArray(parsed)) pending.push(...(parsed as GeofenceTaskPayload[])); - } catch { - // 上一份坏了就当没有,不阻塞这次事件的持久化。 + // istanbul ignore next -- same as drainPendingGeofenceEvents() above: unreachable without a + // real expo-sqlite/kv-store. + { + const raw = await storage.getItem(PENDING_EVENTS_KEY); + const pending: GeofenceTaskPayload[] = []; + if (raw != null) { + try { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) pending.push(...(parsed as GeofenceTaskPayload[])); + } catch { + // 上一份坏了就当没有,不阻塞这次事件的持久化。 + } } + pending.push(payload); + await storage.setItem(PENDING_EVENTS_KEY, JSON.stringify(pending)); } - pending.push(payload); - await storage.setItem(PENDING_EVENTS_KEY, JSON.stringify(pending)); } async function emit(payload: GeofenceTaskPayload): Promise { @@ -117,94 +127,103 @@ async function deliverHeadlessGeofenceEvent(payload: GeofenceTaskPayload): Promi const database = await openHeadlessDatabase(); if (database == null) return false; - if (payload.event === 'exit') { + // istanbul ignore next -- database is only non-null with a real expo-sqlite, which + // openHeadlessDatabase() can never resolve in this Jest env (see the file header); + // unreachable here. + { + if (payload.event === 'exit') { + await database.runAsync( + `UPDATE local_schedules + SET geofence_armed = 1 + WHERE id = ? + AND status = 'active' + AND schedule_type = 'location' + AND geofence_armed = 0`, + payload.schedule_id, + ); + return true; + } + + const schedule = await database.getFirstAsync( + `SELECT id, title, location_name, schedule_type, reminder_type, + reminder_disposition_state, snoozed_until, geofence_armed, status + FROM local_schedules + WHERE id = ?`, + payload.schedule_id, + ); + if ( + schedule == null || + schedule.status !== 'active' || + schedule.schedule_type !== 'location' || + schedule.geofence_armed !== 1 || + schedule.reminder_disposition_state === 'pending' || + schedule.reminder_disposition_state === 'confirmed' || + (schedule.reminder_disposition_state === 'snoozed' && + schedule.snoozed_until != null && + Date.parse(schedule.snoozed_until) > Date.parse(payload.observed_at)) + ) { + return true; + } + + const notifications = await loadNotifications(); + if (notifications == null) return false; + await ensureAndroidChannel(notifications); + notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: true, + shouldSetBadge: false, + }), + }); + + const notificationId = `reminder-${schedule.id}`; + await notifications.scheduleNotificationAsync({ + identifier: notificationId, + content: { + title: schedule.title || '日程提醒', + body: + schedule.reminder_type === 'return_to_recorded_location' + ? `您已回到${schedule.location_name ?? '记录地点'}附近,请及时处理。` + : `您已进入${schedule.location_name ?? '目标地点'}附近,请及时处理。`, + sound: 'default', + data: { schedule_id: schedule.id, reason: schedule.reminder_type ?? 'arrive_location' }, + }, + trigger: null, + }); + await database.runAsync( `UPDATE local_schedules - SET geofence_armed = 1 + SET geofence_armed = 0, + reminder_disposition_state = 'pending', + next_trigger_at = NULL, + disposition_updated_at = ?, + sync_status = 'pending' WHERE id = ? AND status = 'active' AND schedule_type = 'location' - AND geofence_armed = 0`, - payload.schedule_id, + AND geofence_armed = 1 + AND (reminder_disposition_state IS NULL OR reminder_disposition_state = 'snoozed')`, + payload.observed_at, + schedule.id, ); return true; } - - const schedule = await database.getFirstAsync( - `SELECT id, title, location_name, schedule_type, reminder_type, - reminder_disposition_state, snoozed_until, geofence_armed, status - FROM local_schedules - WHERE id = ?`, - payload.schedule_id, - ); - if ( - schedule == null || - schedule.status !== 'active' || - schedule.schedule_type !== 'location' || - schedule.geofence_armed !== 1 || - schedule.reminder_disposition_state === 'pending' || - schedule.reminder_disposition_state === 'confirmed' || - (schedule.reminder_disposition_state === 'snoozed' && - schedule.snoozed_until != null && - Date.parse(schedule.snoozed_until) > Date.parse(payload.observed_at)) - ) { - return true; - } - - const notifications = await loadNotifications(); - if (notifications == null) return false; - await ensureAndroidChannel(notifications); - notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowBanner: true, - shouldShowList: true, - shouldPlaySound: true, - shouldSetBadge: false, - }), - }); - - const notificationId = `reminder-${schedule.id}`; - await notifications.scheduleNotificationAsync({ - identifier: notificationId, - content: { - title: schedule.title || '日程提醒', - body: - schedule.reminder_type === 'return_to_recorded_location' - ? `您已回到${schedule.location_name ?? '记录地点'}附近,请及时处理。` - : `您已进入${schedule.location_name ?? '目标地点'}附近,请及时处理。`, - sound: 'default', - data: { schedule_id: schedule.id, reason: schedule.reminder_type ?? 'arrive_location' }, - }, - trigger: null, - }); - - await database.runAsync( - `UPDATE local_schedules - SET geofence_armed = 0, - reminder_disposition_state = 'pending', - next_trigger_at = NULL, - disposition_updated_at = ?, - sync_status = 'pending' - WHERE id = ? - AND status = 'active' - AND schedule_type = 'location' - AND geofence_armed = 1 - AND (reminder_disposition_state IS NULL OR reminder_disposition_state = 'snoozed')`, - payload.observed_at, - schedule.id, - ); - return true; } async function openHeadlessDatabase(): Promise { try { const { openDatabaseAsync } = await import('expo-sqlite'); + // istanbul ignore next -- unreachable in this Jest env: the import above always throws + // first (no --experimental-vm-modules, see the file header), so this line never runs. return await openDatabaseAsync(TIMEFLOW_DATABASE_NAME); } catch { return null; } } +// istanbul ignore next -- only ever called from the unreachable branch of +// deliverHeadlessGeofenceEvent() above (see the file header), so never entered here. async function loadNotifications(): Promise { try { return await import('expo-notifications'); @@ -213,6 +232,8 @@ async function loadNotifications(): Promise { From a2455f3a9fc0e9dd91e97e9ae950285408ae8379 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Thu, 20 Aug 2026 12:00:42 +0800 Subject: [PATCH 6/6] refactor(reminder): rename enqueueSync to chainSync "Enqueue" implied distinct queued work items processed in order; every call actually just chains another syncRegions() re-read of the current watches state onto syncChain, purely to keep calls from running concurrently. That's serialization, not a queue -- matches the existing chainPlayback()/playbackChain pattern in AssistantContinuousConversationService.ts. Review comment from MeteorsLiu on PR #268. --- .../location/ExpoLocationMonitor.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/frontend/src/infrastructure/location/ExpoLocationMonitor.ts b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts index cabcdc7d..079e60ee 100644 --- a/frontend/src/infrastructure/location/ExpoLocationMonitor.ts +++ b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts @@ -66,7 +66,7 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide const listener_id = `location-${request.schedule_id}`; this.watches.set(listener_id, { listener_id, request: { ...request }, listener }); this.scheduleToListener.set(request.schedule_id, listener_id); - await this.enqueueSync(); + await this.chainSync(); const sample = await this.getCurrentSample(); if (sample != null) { @@ -89,7 +89,7 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide await this.removeWatch(listenerId, false); } - // 直接灌 Map,不逐个调用 watch():watch() 自己的 enqueueSync()+getCurrentSample() + // 直接灌 Map,不逐个调用 watch():watch() 自己的 chainSync()+getCurrentSample() // 是为单条增量注册设计的,N 个 target 各跑一次会把 startGeofencingAsync 的区域 // 列表越注册越长(O(N²) 总区域数)、定位请求也打 N 次;这里全部灌完只同步一次、 // 取一次定位,再扇给每个刚注册的 watch。 @@ -110,7 +110,7 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide this.scheduleToListener.set(target.schedule_id, listener_id); handles.push({ listener_id, schedule_id: target.schedule_id }); } - await this.enqueueSync(); + await this.chainSync(); const sample = await this.getCurrentSample(); if (sample != null) { @@ -158,7 +158,7 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide private readonly handleAppState = (state: AppStateStatus): void => { if (state !== 'active' || this.watches.size === 0) return; - void this.enqueueSync(); + void this.chainSync(); }; /** 补上 headless task 期间(没有订阅者时)攒下的围栏事件,按正常路径重放一遍。 */ @@ -202,11 +202,15 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide this.watches.delete(listenerId); this.scheduleToListener.delete(watch.request.schedule_id); if (sync) { - await this.enqueueSync(); + await this.chainSync(); } } - private enqueueSync(): Promise { + /** 把一次区域同步接到 syncChain 末尾,保证上一次真正执行完(不管成功与否)才轮到 + * 这一次——不是排队等着处理各自不同的输入,每次都是重新读 this.watches 当前 + * 状态,纯粹为了不让 syncRegions() 并发跑,参照 AssistantContinuousConversationService + * 的 chainPlayback()/playbackChain 同一个模式。 */ + private chainSync(): Promise { this.syncChain = this.syncChain.then( () => this.syncRegions(), () => this.syncRegions(),