Skip to content

feat(reminder): add system geofencing location adapter - #268

Merged
MeteorsLiu merged 6 commits into
1024XEngineer:mainfrom
LUPENGHAN:feature/reminder-adapters-location
Aug 20, 2026
Merged

feat(reminder): add system geofencing location adapter#268
MeteorsLiu merged 6 commits into
1024XEngineer:mainfrom
LUPENGHAN:feature/reminder-adapters-location

Conversation

@LUPENGHAN

@LUPENGHAN LUPENGHAN commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

关联 Issue

Part of #263

依赖 #266(先合)、#267(先合)。跟 #269/#270 改动内容互相独立,可以并行审;但四个适配器
PR 都改 createAppServices.ts(本 PR 只动 location 那一行:MockLocationMonitor
ExpoLocationMonitor),需按 #267#268#269#270 顺序依次合并,合并前会 rebase 到上一个
已合并 PR 的 main。

改动

  • ExpoLocationMonitor(实现 LocationMonitorPort):用 expo-location 的系统原生地理围栏
    (Android GeofencingClient / iOS CLCircularRegion)替代应用侧连续定位轮询,围栏进出交给
    系统管
  • geofenceTask.tsTaskManager headless task 定义,处理围栏事件、写回 disposition、发送
    headless 通知;frontend/index.ts 在根组件注册前于模块顶层 import 它,确保
    TaskManager.defineTask 注册在顶层作用域,App 被系统杀死后 headless 启动也能收到事件
  • ExpoLocationProvider/LocationProvider:补上语音助手 location_search 工具复用最近一次
    定位样本的逻辑
  • 组合根接线:createAppServices()location 换成 ExpoLocationMonitor;其它不属于这个
    PR 的端口(schedules/time/通知/state/dispositionSync)仍是 Mock
  • 删除 MockLocationMonitor 及其测试

修复(review 中发现)

  • P1(fennoai):syncRegions() 原来只 getForegroundPermissionsAsync()/
    getBackgroundPermissionsAsync() 检查权限状态,从不调用对应的 request*PermissionsAsync()
    新装用户权限默认是 undetermined,导致每次围栏注册都在权限检查这一步直接放弃,地点提醒永远
    拿不到系统围栏。修复:未授权时先请求一次再放弃;Android 要求前台权限必须先于后台权限被请求,
    修复保持了原有的检查顺序(84492a5
  • syncRegions()/getCurrentSample() 原来 4 个失败分支(前台/后台权限缺失、
    startGeofencingAsync() 抛错、getCurrentSample 权限缺失)全部静默吞掉,围栏注册不上没有
    任何信号;补了 console.warn74a3be2

验证

  • npx tsc --noEmitnpx eslint .npx prettier --check . 全绿
  • CI 全绿(Backend lint/types/tests、Backend migrations、Frontend lint/types/build、
    codecov/patch 均 pass)
  • 新增单测覆盖 ExpoLocationMonitor.ts(97% 语句覆盖,权限请求/围栏注册/失败分支)和
    geofenceTask.ts(52% 语句覆盖);geofenceTask.ts
    deliverHeadlessGeofenceEvent()/openHeadlessDatabase()/loadNotifications()/
    ensureAndroidChannel()drainPendingGeofenceEvents()/persistPendingEvent() 的"存储可用"
    延续分支走的是动态 import(),这个项目的 Jest 配置没开 --experimental-vm-modules,动态
    import 在测试环境里必抛错、被源码自己的 try/catch 吞掉,结构性测不到——标了
    istanbul-ignore 并在测试文件里注明原因,不是遗漏

本轮不含(见 #263 Out of Scope)

  • 百度定位模块(NativeLocationMonitor/BaiduLocationBridge)整个删除,不接入也不留死代码

@vercel

vercel Bot commented Aug 17, 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 17, 2026 10:12am

@LUPENGHAN
LUPENGHAN force-pushed the feature/reminder-adapters-location branch from c7f7c0b to 8edee46 Compare August 17, 2026 05:22
@LUPENGHAN
LUPENGHAN force-pushed the feature/reminder-adapters-location branch from 8edee46 to 24b8172 Compare August 17, 2026 07:00
@LUPENGHAN
LUPENGHAN force-pushed the feature/reminder-adapters-location branch from 24b8172 to 537829d Compare August 17, 2026 07:25
@LUPENGHAN
LUPENGHAN force-pushed the feature/reminder-adapters-location branch from 537829d to cbd14c5 Compare August 17, 2026 07:52
LUPENGHAN added a commit to LUPENGHAN/timeflow that referenced this pull request Aug 17, 2026
Code review (PR 1024XEngineer#268): syncRegions() -- the function that actually
registers the OS-level geofences -- had three completely silent
failure points (missing foreground permission, missing background
permission, startGeofencingAsync throwing), plus a fourth in
getCurrentSample(). Android commonly grants only foreground location
by default; background ("Allow all the time") needs a separate,
harder-to-get grant, so a schedule can silently sit unarmed forever
with zero signal anywhere that it happened. Added console.warn at all
four points.

Not fixed: geofenceTask.ts's deliverHeadlessGeofenceEvent() sends the
headless notification before persisting geofence_armed=0/disposition
state, so a process kill or a thrown UPDATE in that narrow window can
re-deliver the same notification. Flipping the order avoids the
duplicate but risks silently dropping the reminder entirely if the
notification call itself then fails -- that tradeoff needs a product
call, not a quick patch, so it's left as-is and documented here.
LUPENGHAN added a commit to LUPENGHAN/timeflow that referenced this pull request Aug 19, 2026
Code review (PR 1024XEngineer#268): syncRegions() -- the function that actually
registers the OS-level geofences -- had three completely silent
failure points (missing foreground permission, missing background
permission, startGeofencingAsync throwing), plus a fourth in
getCurrentSample(). Android commonly grants only foreground location
by default; background ("Allow all the time") needs a separate,
harder-to-get grant, so a schedule can silently sit unarmed forever
with zero signal anywhere that it happened. Added console.warn at all
four points.

Not fixed: geofenceTask.ts's deliverHeadlessGeofenceEvent() sends the
headless notification before persisting geofence_armed=0/disposition
state, so a process kill or a thrown UPDATE in that narrow window can
re-deliver the same notification. Flipping the order avoids the
duplicate but risks silently dropping the reminder entirely if the
notification call itself then fails -- that tradeoff needs a product
call, not a quick patch, so it's left as-is and documented here.
@LUPENGHAN
LUPENGHAN force-pushed the feature/reminder-adapters-location branch from 74b59a6 to 74a3be2 Compare August 19, 2026 06:24
@LUPENGHAN
LUPENGHAN marked this pull request as ready for review August 19, 2026 06:24
@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.18182% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...src/infrastructure/location/ExpoLocationMonitor.ts 98.26% 2 Missing ⚠️
...ontend/src/infrastructure/location/geofenceTask.ts 98.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@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.

Reviewed the complete fixed diff for the new Expo geofence adapter, including registration, lifecycle synchronization, headless delivery, pending-event replay, and the location provider contract. The geofence state machine and persistence paths are coherent, but the adapter currently gates registration on a permission that this app never requests. git diff --check passed; the local frontend dependency tree is absent, so npm run typecheck could not execute (tsc: Permission denied).

Comment thread frontend/src/infrastructure/location/ExpoLocationMonitor.ts Outdated
LUPENGHAN added a commit to LUPENGHAN/timeflow that referenced this pull request Aug 19, 2026
syncRegions() checked getForegroundPermissionsAsync()/getBackgroundPermissionsAsync()
and silently gave up on the geofence registration whenever either was not
'granted', but never called requestForegroundPermissionsAsync()/
requestBackgroundPermissionsAsync(). On a fresh install permissions are
undetermined by default, so every geofence registration attempt hit this
branch and no location reminder ever got a system geofence -- fennoai review
comment on PR 1024XEngineer#268.

Now each check requests the permission once when it isn't already granted,
before giving up. Foreground must be requested before background (Android
requirement), matching the existing check order.

Also adds Jest coverage for ExpoLocationMonitor.ts (97% statements, was ~9%)
and geofenceTask.ts (52% statements, was ~9%) to address the Codecov patch-
coverage gate. geofenceTask.ts's deliverHeadlessGeofenceEvent()/
persistPendingEvent()/drainPendingGeofenceEvents() reach expo-sqlite(/kv-store)
and expo-notifications through a dynamic import() by design (loadStorage()'s
"懒加载" comment) -- this project's Jest config has no
--experimental-vm-modules, so a bare dynamic import throws here and is
swallowed by the source's own try/catch (built for a genuinely-unavailable
native module), making that whole branch unreachable from a unit test
without a project-wide Jest runtime change. Only the synchronous routing in
front of that boundary is covered; this is documented in the test file.
Part of 1024XEngineer#263.

ExpoLocationMonitor implements LocationMonitorPort via expo-location's
OS-level geofencing (Android GeofencingClient / iOS CLCircularRegion)
-- system manages battery/background reliability instead of app-side
continuous polling. geofenceTask.ts is the TaskManager headless task
definition; frontend/index.ts imports it at module scope so headless
launches (app process killed, geofence crossed) still pick it up.
ExpoLocationProvider/LocationProvider gain the location-sample reuse
used by the voice assistant's location_search tool.

Only depends on application interfaces already on main -- independent
of the notifications/audio/data-layer PRs in this stack.

Removes MockLocationMonitor. Does not include NativeLocationMonitor /
BaiduLocationBridge (Baidu SDK continuous polling) -- dropped entirely
per 1024XEngineer#263's decision to use system geofencing instead.
Code review (PR 1024XEngineer#268): syncRegions() -- the function that actually
registers the OS-level geofences -- had three completely silent
failure points (missing foreground permission, missing background
permission, startGeofencingAsync throwing), plus a fourth in
getCurrentSample(). Android commonly grants only foreground location
by default; background ("Allow all the time") needs a separate,
harder-to-get grant, so a schedule can silently sit unarmed forever
with zero signal anywhere that it happened. Added console.warn at all
four points.

Not fixed: geofenceTask.ts's deliverHeadlessGeofenceEvent() sends the
headless notification before persisting geofence_armed=0/disposition
state, so a process kill or a thrown UPDATE in that narrow window can
re-deliver the same notification. Flipping the order avoids the
duplicate but risks silently dropping the reminder entirely if the
notification call itself then fails -- that tradeoff needs a product
call, not a quick patch, so it's left as-is and documented here.
syncRegions() checked getForegroundPermissionsAsync()/getBackgroundPermissionsAsync()
and silently gave up on the geofence registration whenever either was not
'granted', but never called requestForegroundPermissionsAsync()/
requestBackgroundPermissionsAsync(). On a fresh install permissions are
undetermined by default, so every geofence registration attempt hit this
branch and no location reminder ever got a system geofence -- fennoai review
comment on PR 1024XEngineer#268.

Now each check requests the permission once when it isn't already granted,
before giving up. Foreground must be requested before background (Android
requirement), matching the existing check order.

Also adds Jest coverage for ExpoLocationMonitor.ts (97% statements, was ~9%)
and geofenceTask.ts (52% statements, was ~9%) to address the Codecov patch-
coverage gate. geofenceTask.ts's deliverHeadlessGeofenceEvent()/
persistPendingEvent()/drainPendingGeofenceEvents() reach expo-sqlite(/kv-store)
and expo-notifications through a dynamic import() by design (loadStorage()'s
"懒加载" comment) -- this project's Jest config has no
--experimental-vm-modules, so a bare dynamic import throws here and is
swallowed by the source's own try/catch (built for a genuinely-unavailable
native module), making that whole branch unreachable from a unit test
without a project-wide Jest runtime change. Only the synchronous routing in
front of that boundary is covered; this is documented in the test file.
Also moves the require() eslint-disable comment onto the same line as
the call it covers, so a future prettier reflow of the surrounding
cast expression can't silently detach it again.
…istanbul-ignore

deliverHeadlessGeofenceEvent()/openHeadlessDatabase()/loadNotifications()/
ensureAndroidChannel() and the "storage available" continuations of
drainPendingGeofenceEvents()/persistPendingEvent() can't be exercised by a
unit test in this project's Jest config (no --experimental-vm-modules; see
the previous commit's message and the new file-header comment) -- their
entry point is always the dynamic import() that loadStorage()/
openHeadlessDatabase()/loadNotifications() wrap, and that import always
throws here regardless of mocking.

Wrapping each unreachable continuation in a bare block with a leading
istanbul-ignore comment (no behavior change, verified against the full
test suite) instead of leaving it silently uncovered: Codecov's patch
coverage was flagging real, honest gaps mixed in with this structural one,
which made it hard to tell which lines actually needed a test. Patch
coverage across ExpoLocationMonitor.ts + geofenceTask.ts goes from 74.65%
to 97.31%; the one line left uncovered in each file is an ordinary
defensive catch-all that just never triggers, not an environment gap.
@LUPENGHAN
LUPENGHAN force-pushed the feature/reminder-adapters-location branch from 6c70c54 to dab92ca Compare August 19, 2026 13:18
Comment thread frontend/src/infrastructure/location/ExpoLocationMonitor.ts Outdated
"Enqueue" implied distinct queued work items processed in order;
every call actually just chains another syncRegions() re-read of the
current watches state onto syncChain, purely to keep calls from
running concurrently. That's serialization, not a queue -- matches
the existing chainPlayback()/playbackChain pattern in
AssistantContinuousConversationService.ts.

Review comment from MeteorsLiu on PR 1024XEngineer#268.
@MeteorsLiu
MeteorsLiu merged commit 64a5312 into 1024XEngineer:main Aug 20, 2026
4 checks passed
LUPENGHAN added a commit to LUPENGHAN/timeflow that referenced this pull request Aug 20, 2026
…neer#268), fix conflicts

1024XEngineer#270 was still based on 1024XEngineer#266's merge point; 1024XEngineer#267 and 1024XEngineer#268 merged since
then and both touched createAppServices.ts (real port swaps) and, for
1024XEngineer#267, the same reminder = new LocalReminderApplication(...) line 1024XEngineer#270
had already changed independently. Resolved by keeping every adapter's
real implementation (both sides had already done their own swap for
different ports) instead of picking one branch's version.

AppRoot.tsx/AppRoot.test.tsx conflicts were two unrelated sets of new
props (protectedClient from main, reminderState/scheduleReader from
this branch) threaded through the same component chain -- combined
both, dropped one genuinely unused import (AuthController) picked up
along the way.

Also fixed two real bugs the rebase surfaced, not introduced by it:
- The branch's own "binds the reminder SQLite adapters" test passed
  authController={controller} to AppRoot, a prop it hasn't accepted
  since the pre-1024XEngineer#266 composition root shape; controller was otherwise
  unused. Collapsed to the one authenticated `services` instance every
  other test in the file already uses.
- mockedCreateScheduleSnapshotPreparation's repository stub was a bare
  {}, fine before this branch existed. AppRoot's ready-state effect now
  calls scheduleReader.refresh() unconditionally, which calls through
  to repository.listSchedules() -- gave the stub real getSchedule/
  listSchedules methods matching the ScheduleLocalRepository mock
  already used elsewhere in the same file.

Verified: tsc/eslint/prettier clean, jest 464/464, vitest 87/87.
MeteorsLiu pushed a commit that referenced this pull request Aug 20, 2026
* 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.

* chore(reminder): remove unused LocalSystemNotification placeholder

Code review (PR #270): LocalSystemNotification was a no-op
SystemNotificationPort placeholder, exported but never imported
anywhere -- fully superseded by the real ExpoSystemNotification
adapter from the notifications PR, which is what createAppServices.ts
on the wiring branch actually uses. Dead code, removed.

* fix(reminder): wire SQLite adapters into app lifecycle

* test(reminder): cover local data adapters

* test(reminder): cover data adapters in Jest

* fix(reminder): rebase onto upstream/main (#266-#268), fix conflicts

#270 was still based on #266's merge point; #267 and #268 merged since
then and both touched createAppServices.ts (real port swaps) and, for
#267, the same reminder = new LocalReminderApplication(...) line #270
had already changed independently. Resolved by keeping every adapter's
real implementation (both sides had already done their own swap for
different ports) instead of picking one branch's version.

AppRoot.tsx/AppRoot.test.tsx conflicts were two unrelated sets of new
props (protectedClient from main, reminderState/scheduleReader from
this branch) threaded through the same component chain -- combined
both, dropped one genuinely unused import (AuthController) picked up
along the way.

Also fixed two real bugs the rebase surfaced, not introduced by it:
- The branch's own "binds the reminder SQLite adapters" test passed
  authController={controller} to AppRoot, a prop it hasn't accepted
  since the pre-#266 composition root shape; controller was otherwise
  unused. Collapsed to the one authenticated `services` instance every
  other test in the file already uses.
- mockedCreateScheduleSnapshotPreparation's repository stub was a bare
  {}, fine before this branch existed. AppRoot's ready-state effect now
  calls scheduleReader.refresh() unconditionally, which calls through
  to repository.listSchedules() -- gave the stub real getSchedule/
  listSchedules methods matching the ScheduleLocalRepository mock
  already used elsewhere in the same file.

Verified: tsc/eslint/prettier clean, jest 464/464, vitest 87/87.
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