feat(reminder): merge gac0812 reminder engine, wire real local data + native-ring race fix - #245
feat(reminder): merge gac0812 reminder engine, wire real local data + native-ring race fix#245LUPENGHAN wants to merge 9 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
6a4bafd to
4eef8bc
Compare
| }, | ||
| runtime: { | ||
| reminder_disposition_state: row.reminder_disposition_state, | ||
| next_trigger_at: row.next_trigger_at, |
There was a problem hiding this comment.
[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) { |
There was a problem hiding this comment.
[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" |
There was a problem hiding this comment.
[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[] = []; |
There was a problem hiding this comment.
[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.
432d50f to
7528383
Compare
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 提醒端口,一边是无认证 + 全真实提醒端口),手工合成了一套。改动
冲突解决与组合根合并
git merge-tree核实过)逐个处理:app.json、package.json/package-lock.json、AppProviders.tsx/createAppServices.ts、reminder模块的几个 barrel 文件、原生适配器(NativeAlarmScheduler/NativeDeviceCapability/TimeflowAlarmBridge/useReminderPermissionsOnLaunch)reminderPorts换成 fork 的真实实现,reminder从MockReminderApplication换成真的LocalReminderApplicationapp.json→app.config.jsprocess.env.TIMEFLOW_BAIDU_LOCATION_API_KEY,不再明文写进仓库android.package/android.permissions两边各写了一份)真正接上本地数据源
SqliteLocalScheduleReader(attach()/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-stream的build.gradle:AGP ≥ 8 时javac/kotlinc目标 JVM 版本对不上导致编译失败(第三方包自己的 gap,通过patch-package打的)验证
npm run check:lint / format / typecheck / 全部测试(vitest + jest)全过本次不含
SqliteScheduleSyncService)没接——生产代码里现在还是零调用点,这次只接了语音写入这一条刷新路径,另开 issue 跟踪geofence_radius_meters本地表没有这一列,硬编码 200 米,不新增迁移