Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions frontend/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// 必须在根组件注册前加载,确保 TaskManager.defineTask 进入顶层作用域。
import './src/infrastructure/location/geofenceTask';

import { registerRootComponent } from 'expo';

import App from './App';
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/app/composition/createAppServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
275 changes: 275 additions & 0 deletions frontend/src/infrastructure/location/ExpoLocationMonitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
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<string, ActiveWatch>();
private readonly scheduleToListener = new Map<string, string>();
private lastSample: LocationSample | null = null;
private syncChain: Promise<void> = 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<LocationWatchHandle> {
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.chainSync();

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<void> {
await this.removeWatch(listenerId, true);
}

async rebuild(
targets: readonly LocationRebuildTarget[],
listener: (event: LocationMonitorEvent) => void,
): Promise<readonly LocationWatchHandle[]> {
for (const listenerId of [...this.watches.keys()]) {
await this.removeWatch(listenerId, false);
}

// 直接灌 Map,不逐个调用 watch():watch() 自己的 chainSync()+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.chainSync();

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<LocationSample | null> {
return this.lastSample;
}

async getCurrentSample(): Promise<LocationSample | null> {
try {
const { status } = await Location.getForegroundPermissionsAsync();
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 (error) {
console.warn('[geofence] getCurrentSample failed', error);
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.chainSync();
};

/** 补上 headless task 期间(没有订阅者时)攒下的围栏事件,按正常路径重放一遍。 */
private async replayPendingEvents(): Promise<void> {
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<void> {
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.chainSync();
}
}

/** 把一次区域同步接到 syncChain 末尾,保证上一次真正执行完(不管成功与否)才轮到
* 这一次——不是排队等着处理各自不同的输入,每次都是重新读 this.watches 当前
* 状态,纯粹为了不让 syncRegions() 并发跑,参照 AssistantContinuousConversationService
* 的 chainPlayback()/playbackChain 同一个模式。 */
private chainSync(): Promise<void> {
this.syncChain = this.syncChain.then(
() => this.syncRegions(),
() => this.syncRegions(),
);
return this.syncChain;
}

private async syncRegions(): Promise<void> {
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;
}

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;
}
// 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;
}

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 (error) {
// 系统围栏注册失败(比如权限被收回);下次 watch()/rebuild() 会再重试。
console.warn('[geofence] startGeofencingAsync failed', error);
}
}
}

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(),
};
}
69 changes: 59 additions & 10 deletions frontend/src/infrastructure/location/ExpoLocationProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LocationSample | null> | null = null;

async getCurrentSample(): Promise<LocationSample | null> {
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<LocationSample | null> {
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<LocationSample | null> {
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(),
};
}
4 changes: 2 additions & 2 deletions frontend/src/infrastructure/location/LocationProvider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { LocationSample } from '../../features/reminder/domain';
import type { LocationObservation } from '../../contracts/reminder';

/** 获取一次当前位置样本的平台适配器。 */
export interface LocationProvider {
getCurrentSample(): Promise<LocationSample | null>;
getCurrentSample(): Promise<LocationObservation | null>;
}
Loading
Loading