Skip to content

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

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

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
Contributor

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

Comment thread frontend/src/infrastructure/location/geofenceTask.ts
Comment thread frontend/src/infrastructure/location/ExpoLocationMonitor.ts
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.
@LUPENGHAN

Copy link
Copy Markdown
Contributor Author

按层拆成 4 个可独立审查的 PR,关掉这个,改走:

  1. feat(reminder): add native alarm and Baidu location modules #258 — 原生模块 + 脚手架(ready for review)
  2. feat(reminder): add reminder engine core (domain + application) #259 — 提醒引擎核心(draft,依赖 feat(reminder): add native alarm and Baidu location modules #258
  3. feat(reminder): wire device adapters and SQLite data layer #260 — 设备适配器 + SQLite 数据层(draft,依赖 feat(reminder): add reminder engine core (domain + application) #259
  4. feat(reminder): wire real engine into the app, drop remaining mocks #261 — 接线 + 呈现层(draft,依赖 feat(reminder): wire device adapters and SQLite data layer #260,替代这个 PR 原本要做的事)

对应的拆分说明分别在 #254/#255/#256/#257

@LUPENGHAN LUPENGHAN closed this Aug 17, 2026
@LUPENGHAN

Copy link
Copy Markdown
Contributor Author

拆分方案撤回,先恢复这个。

@LUPENGHAN

Copy link
Copy Markdown
Contributor Author

#262(bug)/ #263(feat)重新拆成 8 个 PR,关掉这个:

  1. feat(reminder): add alarm ring UI toolkit and build scaffolding #264 闹钟响铃 UI 工具 + 脚手架(ready for review)
  2. feat(reminder): add alarm scheduling, RN bridge, and ring activity/service #265 闹钟调度 + RN 桥接 + 响铃 Activity/Service(draft,依赖 feat(reminder): add alarm ring UI toolkit and build scaffolding #264
  3. feat(reminder): add reminder engine core (domain + application) #266 提醒引擎核心(draft,依赖 feat(reminder): add alarm scheduling, RN bridge, and ring activity/service #265
  4. feat(reminder): add audio playback and interval time adapters #267 音频/定时器适配器(draft,依赖 feat(reminder): add reminder engine core (domain + application) #266,跟 268/269/270 并行)
  5. feat(reminder): add system geofencing location adapter #268 定位/地理围栏适配器(draft,依赖 feat(reminder): add reminder engine core (domain + application) #266,跟 267/269/270 并行)
  6. feat(reminder): add notification, alarm, and dialog device adapters #269 通知/闹钟/弹窗适配器(draft,依赖 feat(reminder): add reminder engine core (domain + application) #266,跟 267/268/270 并行)
  7. feat(reminder): add SQLite-backed reminder data layer #270 SQLite 数据层(draft,依赖 feat(reminder): add reminder engine core (domain + application) #266,跟 267/268/269 并行)
  8. feat(reminder): wire real engine into the app, drop remaining mocks #271 接线 + 呈现层(draft,依赖 267-270 全部,含 bug(reminder): 权限申请流程弹完第一项就卡住,不会自动续弹 #262 的 bug 修复)

@LUPENGHAN LUPENGHAN closed this Aug 17, 2026
@LUPENGHAN LUPENGHAN reopened this Aug 17, 2026
LUPENGHAN added a commit to LUPENGHAN/timeflow that referenced this pull request Aug 20, 2026
AssistantConversationService.handleClose() nulled unsubscribeConnection
without calling it. Switching from push-to-talk to continuous mode makes
the shared AuthenticatedWebSocketClient drop and reopen the connection
(the two modes negotiate different voiceMode), so the old service stayed
subscribed to the new connection's TTS/PCM and pushed the same reply into
the player alongside the continuous service -- audible as one sentence
played twice, overlapping.

dispose() already unsubscribed correctly; only the close path was missing
it. Carried over from 1024XEngineer#245, which this stacked series replaces -- the fix
is not reminder-scoped so none of 1024XEngineer#264-1024XEngineer#270 picked it up.
LUPENGHAN added a commit to LUPENGHAN/timeflow that referenced this pull request Aug 20, 2026
A barge-in that lands after the model already finished delivering a reply
cancelled whatever was playing *next*, not the reply the phone was still
sounding out. Two halves:

Backend: _Turn reset _audio_id before that late interrupted() ran, so it
sent AudioCanceled(audio_id=""). Added _last_audio_id, which survives the
reset, and send that instead -- the cancellation now names the audio it
actually refers to.

Frontend: voice.tts.canceled routed stop() through playbackChain, so every
PCM chunk already queued was still fed to the native player before the stop
landed. It now bypasses the chain via stopPlaybackImmediately(), and
chainPlayback() tags each queued operation with a playbackGeneration that
stop bumps, so the stale queue is dropped rather than replayed. tts.end and
tts.canceled are both matched against currentAudioId/canceledAudioId, so
the server's follow-up tts.end for a cancelled reply no longer ends a newer
stream or flips interrupted back to listening.

The empty-audio_id case is still handled on the client so a not-yet-updated
backend cannot stop a newer reply.

1024XEngineer#297 removed only the user-facing interrupt button; backend barge-in still
drives voice.tts.canceled, so this path is live. Carried over from 1024XEngineer#245,
which this stacked series replaces -- not reminder-scoped, so none of
1024XEngineer#264-1024XEngineer#270 picked it up.
gac0812 pushed a commit that referenced this pull request Aug 20, 2026
…271)

* feat(reminder): add SQLite-backed reminder data layer

Part of #263.

SqliteLocalScheduleReader / SqliteReminderStateStore read and persist
against the real local database (ScheduleLocalRepository) instead of
in-memory fixtures; geofence_radius_meters is hardcoded to 200m for
now (known simplification, see Issue #263 Out of Scope).
InMemoryLocalScheduleReader is kept as a non-persisted alternative
implementation of the same port. LocalScheduleWriter's post-write hook
refreshes the new reader after a voice-driven schedule mutation lands.

Only depends on application interfaces already on main and the
existing ScheduleLocalRepository -- independent of the audio/location/
notifications adapter PRs in this stack.

Removes MockLocalScheduleReader, MockReminderApplication,
MockReminderDispositionSync, MockReminderStateStore, mockReminderSchedules.

* feat(reminder): wire real engine into the app, drop remaining mocks

Swaps the composition root over to the real implementations added in
the previous three commits: LocalReminderApplication replaces
MockReminderApplication, SqliteLocalScheduleReader/SqliteReminderStateStore
replace their Mock counterparts, and every device port
(audio/notification/vibration/alarm/location) now points at its real
adapter. ExpoLocationMonitor (system geofencing) is used for location
monitoring; NativeLocationMonitor (Baidu SDK) stays in the repo but
unwired -- see the comment in createAppServices.ts for how to switch.

AppProviders/AppRoot gain the reminder permission-request flow
(useReminderPermissionsOnLaunch, now driven by an injected AlertDialogPort
instead of calling Alert.alert directly, with a settings-page fallback
for denied background-location permission) and rebuild() the engine
once permissions change.

Removes MockReminderPresenter, the last remaining Mock* adapter.

* fix(reminder): permission flow stalled after the first prompt

Every branch in promptNext() except the overlay/full_screen/battery
confirm-and-continue path returns from inside the try block, so the
setTimeout(runPrompt, 250) that was meant to advance to the next
missing permission -- placed after the try/finally -- was dead code
for those branches. In practice: grant notifications, and exact_alarm
(the next permission in line) would just never get prompted; same for
any declined dialog, or a granted/denied location permission. Moved
the continuation check into the finally block so it always runs
regardless of which branch returned.

Also replaces the old mock-based useReminderPermissionsOnLaunch test
(deleted upstream when this hook's signature changed to take an
injected AlertDialogPort + onPermissionsUpdated callback, with no
replacement written) and drops the now-redundant .gitkeep placeholders
left over from directories that have had real files in them since
earlier commits in this stack.

* fix(voice): remove stale push-to-talk listeners

AssistantConversationService.handleClose() nulled unsubscribeConnection
without calling it. Switching from push-to-talk to continuous mode makes
the shared AuthenticatedWebSocketClient drop and reopen the connection
(the two modes negotiate different voiceMode), so the old service stayed
subscribed to the new connection's TTS/PCM and pushed the same reply into
the player alongside the continuous service -- audible as one sentence
played twice, overlapping.

dispose() already unsubscribed correctly; only the close path was missing
it. Carried over from #245, which this stacked series replaces -- the fix
is not reminder-scoped so none of #264-#270 picked it up.

* fix(voice): preserve new TTS after interruption

A barge-in that lands after the model already finished delivering a reply
cancelled whatever was playing *next*, not the reply the phone was still
sounding out. Two halves:

Backend: _Turn reset _audio_id before that late interrupted() ran, so it
sent AudioCanceled(audio_id=""). Added _last_audio_id, which survives the
reset, and send that instead -- the cancellation now names the audio it
actually refers to.

Frontend: voice.tts.canceled routed stop() through playbackChain, so every
PCM chunk already queued was still fed to the native player before the stop
landed. It now bypasses the chain via stopPlaybackImmediately(), and
chainPlayback() tags each queued operation with a playbackGeneration that
stop bumps, so the stale queue is dropped rather than replayed. tts.end and
tts.canceled are both matched against currentAudioId/canceledAudioId, so
the server's follow-up tts.end for a cancelled reply no longer ends a newer
stream or flips interrupted back to listening.

The empty-audio_id case is still handled on the client so a not-yet-updated
backend cannot stop a newer reply.

#297 removed only the user-facing interrupt button; backend barge-in still
drives voice.tts.canceled, so this path is live. Carried over from #245,
which this stacked series replaces -- not reminder-scoped, so none of
#264-#270 picked it up.

* fix(reminder): reset permission-prompt state when the effect unmounts

Code review (PR #271, fennoai): the cleanup only cleared the timer and
unsubscribed onAppActive, not awaitingReturnRef/skippedRef. If a user opened
settings for exact_alarm (or a denied location permission) and logged out
before returning, AppProviders reruns this effect with device=null, but the
stale awaitingReturnRef stayed true. On the next login the new effect's
promptNext() reads that same ref (it's a component-level useRef, not reset
by the effect re-running) and returns immediately every time, and since the
app is already active there's no new onAppActive event left to clear it --
every permission prompt stays disabled until the process restarts.

Reset both refs in the cleanup so a fresh login starts a clean prompt round.

* test(reminder,voice): close Codecov patch-coverage gaps

Codecov flagged 60.79% patch coverage across six files from this branch's
recent commits. Closed each:

- InMemoryLocalScheduleReader.ts: 0% because nothing in the app or tests
  actually uses it -- only re-exported from two barrels, never imported
  or instantiated anywhere in feature/reminder-wiring's own history.
  Deleted the file and its two re-exports instead of testing dead code.
- AlertReminderPresenter.ts: new AlertReminderPresenter.test.ts covers
  every reason-specific message, the title fallback, confirm/snooze
  dispatch, unsubscribe, and hide()'s suppression window. The `?? '...'`
  message fallback is unreachable (MESSAGE_BY_REASON already covers every
  ReminderTriggerReason), so it's istanbul-ignored with a stated reason
  instead of faked with an invalid reason value.
- useReminderPermissionsOnLaunch.ts: added 7 tests for branches the
  existing suite didn't reach -- denied notifications, failed
  openSettings on both the direct-settings and location paths, granted
  location, the bottom settings-redirect branch, a rejected getStatus(),
  and a dismissed (vs declined) dialog. Its own similarly-unreachable
  `prompt == null` branch (all 7 DevicePermission values already have a
  prompt) got the same istanbul-ignore treatment.
- AppProviders.tsx: new AppProviders.test.tsx isolates the
  onPermissionsUpdated -> reminder.rebuild() wiring with a mocked
  useReminderPermissionsOnLaunch, instead of relying on AppRoot.test.tsx's
  much heavier integration setup for one line.
- AssistantContinuousConversationService.ts: dismissReply() had no
  coverage at all before this branch touched one line of it (routing
  through stopPlaybackImmediately()); added a test that drives a reply
  through voice.tts.start/voice.dialogue.reply and asserts dismissReply()
  clears it and stops playback.
- backend agent.py: the interrupted()-with-nothing-ever-spoken branch
  (_last_audio_id is None) wasn't exercised; added
  test_a_barge_in_before_any_reply_started_sends_no_cancellation.

Verified: frontend tsc/eslint/prettier clean, Jest 520/520, Vitest 87/87;
backend ruff/mypy clean, pytest 97.53% coverage.
@LUPENGHAN LUPENGHAN closed this Aug 20, 2026
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