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
8 changes: 8 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
93 changes: 93 additions & 0 deletions frontend/patches/expo-task-manager+57.0.9.patch
Original file line number Diff line number Diff line change
@@ -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);
}
}

114 changes: 104 additions & 10 deletions frontend/src/features/reminder/application/ReminderGuardCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() 只能回答把后两种
* 合并之后的那个布尔值("注册着吗"):
Expand Down Expand Up @@ -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<void> = Promise.resolve();
/** 独立于心跳事件的兜底定时器,见 WATCHDOG_INTERVAL_MS 的说明。 */
private watchdogTimer: ReturnType<typeof setInterval> | null = null;

constructor(private readonly dependencies: ReminderGuardDependencies) {}

Expand All @@ -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<void> {
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<void> {
this.lastProgressAt = Date.now();
this.lastSample = { latitude: sample.latitude, longitude: sample.longitude };
await this.dependencies.handleLocation(sample);
await this.reconcile();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Comment thread
LUPENGHAN marked this conversation as resolved.
// 临时诊断:这几个值就是下面全部分流的依据,卡住时只看这一行就够。
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)。
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/features/reminder/domain/geofence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/** 离围栏边界(不是中心点)≤ 此距离时,按最密的轮询间隔查。门槛直接等于围栏
* 半径本身:沿着半径这段路程加密轮询,正好在真正跨过边界前进入最密档。之前
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/infrastructure/location/reminderGuardTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
3 changes: 2 additions & 1 deletion frontend/tests/integration/sqliteLocalScheduleReader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading