feat(reminder): merge gac0812 reminder engine, wire real local data + native-ring race fix - #245
Closed
LUPENGHAN wants to merge 9 commits into
Closed
feat(reminder): merge gac0812 reminder engine, wire real local data + native-ring race fix#245LUPENGHAN wants to merge 9 commits into
LUPENGHAN wants to merge 9 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
LUPENGHAN
force-pushed
the
feature/reminder-integration
branch
from
August 14, 2026 02:45
6a4bafd to
4eef8bc
Compare
LUPENGHAN
marked this pull request as ready for review
August 14, 2026 02:46
LUPENGHAN
marked this pull request as draft
August 14, 2026 04:10
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
force-pushed
the
feature/reminder-integration
branch
from
August 14, 2026 04:20
432d50f to
7528383
Compare
This was referenced Aug 17, 2026
Closed
Contributor
Author
|
按层拆成 4 个可独立审查的 PR,关掉这个,改走:
|
Contributor
Author
|
拆分方案撤回,先恢复这个。 |
Contributor
Author
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 米,不新增迁移