Skip to content

feat(reminder): merge gac0812 reminder engine, wire real local data + native-ring race fix - #245

Draft
LUPENGHAN wants to merge 9 commits into
1024XEngineer:mainfrom
LUPENGHAN:feature/reminder-integration
Draft

feat(reminder): merge gac0812 reminder engine, wire real local data + native-ring race fix#245
LUPENGHAN wants to merge 9 commits into
1024XEngineer:mainfrom
LUPENGHAN:feature/reminder-integration

Conversation

@LUPENGHAN

Copy link
Copy Markdown
Contributor

Base 是 #243(还没合并),这个 PR 会同时显示 #243 的改动 + 这次提醒功能合并的改动——#243 合并后 diff 会自动收窄成只剩提醒这部分。

跟 gac0812 在组织仓库拆的几个提醒相关 PR(#208/#215/#222/#223/#224,互相之间还有冲突、没合完)是同一个功能范围,但这个 PR 走的是完全独立的路径:从他 fork 的 new-main 分支整体合并过来,不依赖那几个 PR 是否/如何合并,这几个 PR 后续大概率需要关掉或者重新 rebase。

概述

把 gac0812 在自己 fork 里做的提醒功能(本地提醒引擎、原生 Android 闹钟、地理围栏、系统通知/震动、权限申请)合并进来,接上真实数据源和真机验证过的几个关键修复。这不是简单的冲突消解——两边的组合根(AppProviders/createAppServices)设计目的完全不一样(一边是认证/WS/日程视图骨架 + 全 mock 提醒端口,一边是无认证 + 全真实提醒端口),手工合成了一套。

改动

冲突解决与组合根合并

  • 12 个真实冲突(git merge-tree 核实过)逐个处理:app.jsonpackage.json/package-lock.jsonAppProviders.tsx/createAppServices.tsreminder 模块的几个 barrel 文件、原生适配器(NativeAlarmScheduler/NativeDeviceCapability/TimeflowAlarmBridge/useReminderPermissionsOnLaunch
  • 保留 feat(intelligence): hands-free continuous voice mode + real location_search wiring #243 的认证/WebSocket/日程视图骨架,reminderPorts 换成 fork 的真实实现,reminderMockReminderApplication 换成真的 LocalReminderApplication

app.jsonapp.config.js

  • 百度定位 API Key 改走 process.env.TIMEFLOW_BAIDU_LOCATION_API_KEY,不再明文写进仓库
  • 合并双方的 iOS 权限说明(麦克风+定位)、Android 权限并集,修了自动合并区里一处潜藏的重复键 bug(android.package/android.permissions 两边各写了一份)

真正接上本地数据源

  • 新建 SqliteLocalScheduleReaderattach()/detach() 延迟绑定——组合根构造时账号和数据库仓储都还没有,整个 App 生命周期只有一个实例),替换掉占位用的 mock 读取器
  • 新建 SqliteReminderStateStore,把提醒运行时状态(响没响、确认没确认)真正落 SQLite,替换掉 MemoryReminderStateStore(纯内存实现,进程一杀状态就丢——真机测试中发现,已经触发过的提醒下次启动会被当成全新的整批重新弹一遍,这是这次合并里优先级最高的修复)
  • LocalScheduleWriter 语音写入成功后触发 reader 刷新,日历/提醒引擎能立刻看到新日程

真机测试中发现并修的问题

  • 时间型提醒只要成功排上了原生精确闹钟,弹窗和语音就完全交给原生 RingActivity/AlarmSoundService,JS 不再重复投递——原设计里有防重复逻辑,但存在一个时序竞态窗口(原生响铃通知到 JS 之间跨语言桥有延迟),真机上能复现"应用内弹窗和全屏响铃同时出现、谁都关不掉谁"
  • 新建 ExpoLocationMonitor:用 expo-location 的系统原生地理围栏(GeofencingClient/CLCircularRegion)做围栏检测,不依赖百度定位 SDK 的账号/Key 绑定关系(百度那个 Key 是按包名+签名指纹注册的,这次把 Android 包名统一成 com.anonymous.timeflow 之后,百度那边的安全码就对不上了,定位请求全部返回"AK 不存在或非法");NativeLocationMonitor(百度)保留在仓库里没删,createAppServices.ts 换一行 import 能切回去
  • exact_alarm 权限跳过确认弹窗直接跳系统设置页——它没有系统授权框,先弹说明框只会多一次无意义的点击
  • 修了 @irvingouj/expo-audio-streambuild.gradle:AGP ≥ 8 时 javac/kotlinc 目标 JVM 版本对不上导致编译失败(第三方包自己的 gap,通过 patch-package 打的)

验证

  • 前端 npm run check:lint / format / typecheck / 全部测试(vitest + jest)全过
  • Android 真机(无线 adb + USB)手动验证:权限申请流程(精确闹钟/悬浮窗/全屏通知/电池优化/定位)、三种提醒强度(低=系统通知,中=弹窗+震动,高=弹窗+震动+语音)、原生全屏响铃、应用内弹窗兜底、语音创建提醒后立刻可见

本次不含

  • 云端日程同步(SqliteScheduleSyncService)没接——生产代码里现在还是零调用点,这次只接了语音写入这一条刷新路径,另开 issue 跟踪
  • geofence_radius_meters 本地表没有这一列,硬编码 200 米,不新增迁移
  • 百度定位 SDK 相关的历史遗留(fork 历史里明文 Key)不在这个 PR 里清理,需要仓库所有者单独处理

@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
timeflow Ready Ready Preview Aug 14, 2026 10:55am

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

Found four functional/performance issues in the reminder integration. TypeScript typechecking and the changed frontend test suites pass; the findings concern lifecycle cases not covered by those tests.

View job run

},
runtime: {
reminder_disposition_state: row.reminder_disposition_state,
next_trigger_at: row.next_trigger_at,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Initialize the occurrence cursor for recurring reminders. Newly inserted cloud schedules persist next_trigger_at as NULL, and this reader passes that through unchanged. resolveTimeTriggerAt() explicitly returns null for every recurring schedule without this cursor, so rebuildInternal() excludes it from alarmRequests; a newly synced recurring reminder therefore never fires. Compute/persist the next occurrence from start_time + recurrence_rule during import/rebuild, and advance it after each disposition.

}

function emit(payload: GeofenceTaskPayload): void {
for (const listener of listeners) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Do not drop headless geofence events when no listener exists. When Expo launches this task after the app process was terminated, the module is loaded to run defineTask, but ExpoLocationMonitor/the authenticated runtime has not mounted a subscriber. The in-memory listeners set is empty, so the enter/exit event is discarded and the location reminder is never armed or delivered. Persist/queue the task payload (or handle delivery directly in the task) so startup can consume it later.

android:theme="@android:style/Theme.DeviceDefault.NoActionBar"/>
<receiver
android:name="com.timeflow.alarm.AlarmReceiver"
android:exported="false"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Reschedule persisted alarms after device reboot. AlarmManager registrations are cleared on reboot, while this manifest only registers the fire receiver. Although AlarmScheduler persists alarm records, there is no BOOT_COMPLETED/package-replaced receiver to restore them, so every pending reminder is silently lost until the user opens the app and triggers a rebuild. Add the boot permission and a receiver that reloads and reschedules future records.

}
await this.enqueueSync();

const handles: LocationWatchHandle[] = [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Batch geofence rebuild instead of registering each prefix. Each iteration calls watch(), which immediately runs startGeofencingAsync with the growing region list and then requests a fresh current position. Rebuilding N reminders therefore performs N geofence registrations carrying O(N²) total regions plus N location fixes at login/startup. Populate the maps first, synchronize regions once, and obtain one current sample to fan out to the listeners.

Rebased onto upstream/main now that 1024XEngineer#243 is merged. Same content as the
original merge commit (fcc9f15) plus the reminder_offset_minutes=15 fix
(6a4bafd), replayed cleanly on top of 1024XEngineer#240/1024XEngineer#241's schedule UI redesign
(no overlap, verified via git merge-tree before rebasing).

将 gac0812 fork(提醒引擎、原生闹钟、地理围栏、系统通知/震动、权限申请)合
并进来,解决 12 个真实冲突,并把两套组合根手工合成一套:保留 1024XEngineer#243 的认证/
WebSocket/日程视图骨架,reminderPorts 换成 fork 的真实实现
(NativeAlarmScheduler/NativeDeviceCapability/ExpoAudioPlayback/
NativeLocationMonitor),reminder 从 MockReminderApplication 换成真的
LocalReminderApplication。

冲突解决之外真实接入的部分:
- app.json 转成 app.config.js:百度定位 Key 走 process.env,合并双方的
  iOS 权限说明/Android 权限并集,去重重复键。
- 新建 SqliteLocalScheduleReader(attach/detach 延迟绑定,供 reminder 引擎
  读真实本地日程)和 SqliteReminderStateStore(提醒运行时状态真正落
  SQLite,替换掉纯内存的 MemoryReminderStateStore——原来进程一杀已触发的
  提醒状态就丢,下次启动会整批重新弹一遍)。
- LocalScheduleWriter 语音写入成功后触发 SqliteLocalScheduleReader.refresh()。
- AppProviders 里接上权限申请 hook + reminder.rebuild(),按认证态门控。
- LocalReminderApplication 的时间型提醒投递加了原生闹钟归属判断:只要排上
  了原生精确闹钟,弹窗和语音就完全交给原生 RingActivity/AlarmSoundService,
  JS 不再重复弹——避免真机上应用内弹窗和全屏响铃互相抢、谁都关不掉的问题。
- 新增 ExpoLocationMonitor:用 expo-location 的系统原生地理围栏
  (GeofencingClient/CLCircularRegion)接围栏检测,不依赖百度定位 SDK 的
  账号/Key 绑定;NativeLocationMonitor(百度)保留在仓库里,createAppServices
  换个 import 就能切回去。
- exact_alarm 权限跳过确认弹窗直接跳系统设置页(没有系统授权框,多一次点
  击没有意义)。
- 修了 @irvingouj/expo-audio-stream 的 build.gradle patch:AGP>=8 时
  javac/kotlinc 目标版本对不上导致编译失败。
- reminder_offset_minutes 默认值从 200 分钟改成 15 分钟(backend/
  instructions.py,200 分钟太长,真机测试等不到触发)。

删掉了 3 个测试老版本原生适配器 API 的过期单测(useReminderPermissionsOnLaunch/
nativeAlarmScheduler/nativeDeviceCapability),新增 SqliteLocalScheduleReader
和 LocalScheduleWriter 刷新触发的集成测试。

已知缺口:云端日程同步(SqliteScheduleSyncService)这次没接,生产代码里还
是零调用点,只接了语音写入这条刷新路径;geofence_radius_meters 本地表没有
这一列,硬编码 200 米。
expo export 只是导出/校验配置,不产出真机可用的原生包,但
withTimeflowBaiduLocation 插件在 apiKey 缺失时会直接 throw,把 Export
build 这一步卡死。CI 不需要真的百度 Key,塞一个占位值就够通过配置校验。
Recurring schedules only ever fired once (or not at all for freshly
synced ones): resolveTimeTriggerAt() requires a non-null runtime
occurrence cursor for recurring schedules and returns null otherwise,
but nothing ever computed or advanced that cursor -- confirmInternal()
explicitly clears it back to null on every confirm (that's its signal
for "this occurrence is done"), and a freshly-synced recurring row never
had one to begin with.

Fixing this in SqliteLocalScheduleReader would have been dead code:
LocalReminderApplication.withStoredRuntime() unconditionally overrides
whatever runtime the reader returns with SqliteReminderStateStore.read()'s
result, so the real fix has to live there. When a recurring schedule's
cursor is null, read() now computes the next RRULE occurrence at/after
now from start_time + recurrence_rule, and resets
reminder_disposition_state (otherwise canDeliver() would keep treating
the new occurrence as already confirmed forever). A series that's run
out of occurrences (COUNT/UNTIL exhausted) is left alone rather than
looping.
geofenceTask.ts emitted straight to subscribers and nothing else. When
Android launches the JS engine headlessly to run just this TaskManager
task (app process killed, no React tree mounted), ExpoLocationMonitor
was never constructed, so the listener set was empty and the enter/exit
event was silently discarded -- a location reminder armed while the app
was dead would just never fire. Events now persist to
expo-sqlite/kv-store when there are no subscribers, and get drained and
replayed once a real session mounts (in both watch() and rebuild(), so
either an incremental registration or a full startup rebuild picks them
up). The kv-store import has to be lazy (dynamic import inside the two
functions that need it, not a top-level import) -- its default export is
a singleton constructed at module load, which throws in Jest where no
real native module exists; this matches the lazy-import pattern already
used for native-backed ports elsewhere (ExpoAudioPlayback.ts).

rebuild() also called watch() once per target, so N targets meant N
separate startGeofencingAsync calls (O(N^2) total registered regions)
plus N redundant location fixes for what's a single rebuild pass. It now
populates the watch maps directly, syncs geofencing once, and fans one
location fix out to all newly-registered listeners.
AlarmManager registrations are cleared by the OS on both BOOT_COMPLETED
and MY_PACKAGE_REPLACED. AlarmScheduler already persisted every alarm
record to SharedPreferences, but nothing ever reloaded and re-armed them
-- every pending reminder went silent until the user happened to open
the app and trigger a rebuild.

Added a BootReceiver for both actions, and
AlarmScheduler.rescheduleAfterBoot() to reload persisted records and
re-arm each one that's still in the future (same PendingIntent/alarmId,
not a fresh one -- schedule()'s own re-arming logic is now shared via a
new rearm() helper instead of duplicated). Already-expired records are
dropped rather than fired here: LocalReminderApplication's own JS-side
catch-up already delivers overdue reminders once the app reopens, and
firing them again natively would deliver the same reminder twice.

Verified via a real Gradle build: :timeflow-alarm:compileDebugJavaWithJavac
succeeds, and :app:processDebugMainManifest shows BootReceiver correctly
merged into the app's final manifest with its intent-filter.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants