Skip to content

fix(reminder): 三档铃声 + 原生时间触发 + 后台守护补挂 - #355

Merged
LUPENGHAN merged 7 commits into
1024XEngineer:mainfrom
LUPENGHAN:pr1-reliable-alerting
Aug 24, 2026
Merged

LUPENGHAN merged 7 commits into
1024XEngineer:mainfrom
LUPENGHAN:pr1-reliable-alerting

Conversation

@LUPENGHAN

Copy link
Copy Markdown
Contributor

Closes #354

这个 PR 做了什么

1. 原生闹钟三档铃声(none/ping/full)

  • 新增 AlarmContract.SOUND_TIER_*,按提醒强度换算:低=ping 不震动,中=ping+震动,高=循环响铃(full)+震动。
  • RingActivity/AlarmSoundService 统一按 soundTier 播放,不再是简单的布尔 sound 开关。

2. 原生闹钟接管时间型提醒的触发与响铃

  • 时间型日程排上原生精确闹钟后,响铃 UI 和声音全权交给原生 RingActivity/AlarmSoundService;JS 侧不再弹 Alert/放音跟原生抢事件。

3. 后台守护任务:时间兜底轮询 + 持续定位

  • 新增 ReminderGuardCoordinator + reminderGuardTask:定期核对"该挂上原生闹钟的日程有没有真的挂上",没挂上就补挂。
  • 系统围栏不可用/失效时,提供持续定位轮询兜底。

4. presentNow 统一全屏响铃路径

  • 地点提醒和时间提醒统一走 presentNow 原生全屏响铃优先路径;只有 presentNow 本身不可用(iOS、或原生模块拿不到)时才回退到 JS 通道。

验证

  • npx tsc --noEmit:通过
  • npx jest:661/661 通过
  • 原生 Java/Kotlin 改动已人工核对调用点签名一致(本地 gradle 环境这次跑测试时反复 OOM,没能拿到 CI 之外的绿色跑分,麻烦 CI 或 reviewer 跑一下 :timeflow-alarm:testDebugUnitTest 把关)。

拆分说明

这是一条更大分支(地理围栏后台提醒 + 本地 TTS)拆出来的第一个 PR,按依赖顺序还有:

  • PR2:SQLite 连接生命周期修复 + 围栏半径/轮询重设计(建在这个 PR 之上)
  • PR3:本地 TTS 高强度提醒播报(同样建在这个 PR 之上)

🤖 Generated with Claude Code

LUPENGHAN and others added 4 commits August 24, 2026 14:57
…red ring sound

JS's 30s poll used to race the native AlarmManager alarm for every
time-type reminder — whichever fired first won, and the JS path would
actively cancel the (more reliable) native alarm on a win. That race
is why background delivery looked intermittent: whenever the JS timer
happened to still be alive when a reminder came due, it would hijack
delivery from the native alarm and fall back to a weaker, easy-to-miss
channel. runHandleTime() now skips any time-type schedule that already
has a native alarm registered, full stop — native ownership is no
longer just a same-tick head start, it's exclusive.

Alongside that: every strength now surfaces through the native
full-screen ring page (via presentNow) instead of low strength getting
a plain system notification, with a three-tier ring sound (low/medium
= a one-shot ping, high keeps the looping speech) replacing the old
sound on/off boolean end to end. Also fixed two adjacent notification
bugs — the notifications permission gap was never surfaced to the
permission-nudge dialog, and ExpoSystemNotification shared an Android
notification channel with the geofence path despite the two wanting
opposite sound behavior (channel sound is immutable after creation on
Android 8+, so whichever side created it first won for both) — plus
added native fallback notifications for the two previously-silent
delivery failure paths (OEM-blocked foreground service start, and an
alarm queued behind a still-open ring page).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… sound commit

DeliveryChannel/AlarmSchedulerPort's interface barrel were missing the
native_full_screen/presentNow additions this commit's history depends on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…us location

Native alarms and system geofencing cover the common case, but two
gaps had no safety net at all: a time-type reminder whose native alarm
never got scheduled (e.g. exact-alarm permission missing) had nothing
watching it in the background, and Android's Fused Location Provider
is opportunistic enough that geofence transitions can lag well behind
the actual crossing when nothing else is driving fresh location fixes.

Rather than hand-rolling a new native foreground service, this reuses
expo-location's already-configured startLocationUpdatesAsync foreground
service (ACCESS_BACKGROUND_LOCATION / FOREGROUND_SERVICE_LOCATION and
isAndroidForegroundServiceEnabled were already wired up in app.config.js
for the geofencing path). ReminderGuardCoordinator starts/stops/reconfigures
it based on whether anything is still unconfirmed, and reminderGuardTask.ts
(a TaskManager-defined task, same shape as the existing geofenceTask.ts)
does the actual work on every wake: routes live samples into the existing
handleLocation()/evaluateGeofence() path when a session is alive, falls
back to a direct-SQLite pass mirroring deliverHeadlessGeofenceEvent()
when headless, re-presents any time-type reminder whose trigger has
passed with no native alarm currently armed (checked via a new
hasArmedAlarm bridge method reading AlarmScheduler's persisted alarm
list, since in-memory registrations aren't visible from a headless
context), and re-presents anything stuck in a pending disposition for
over two minutes as a safety net for deliveries that fired natively but
never reached the user. Poll interval is a distance-based interpolation
(15s near a target, 5min far from all of them) recomputed on every
schedule change and every sample, not a fixed cadence.

The 'return_to_recorded_location' mode's recorded_location isn't
persisted to SQLite yet (confirmed against the three existing readers,
which all hardcode it to null) — this task's headless pass inherits
that same pre-existing gap rather than working around it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…eftover diagnostics

The presentNow feature (native full-screen delivery for both time and
location schedules) originally shipped bundled with geofence diagnostics
in a single upstream commit; cherry-picking only the tiered-sound commit
missed several pieces presentNow depends on:
- AlarmScheduler.immediateRequestCode()
- AlarmSchedulerPort/barrel-export additions for AlarmPresentationRequest/Receipt
- the placeholder-registration + awaited location listener behavior in
  LocalReminderApplication.rebuildInternal()/watchLocation()
- AlarmScheduler.schedule() call sites in RingActivity/AlarmSoundService
  missing the speechText param after the signature changed

Also strips the geofence diagnostic recording that came along for the
ride in geofenceTask.ts, and updates AlarmIntentForwardingTest.java to
the current sound_tier-based contract instead of the old boolean sound
field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.92593% with 38 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...d/src/infrastructure/location/reminderGuardTask.ts 70.37% 24 Missing ⚠️
...ontend/src/infrastructure/location/geofenceTask.ts 45.45% 12 Missing ⚠️
...s/reminder/application/LocalReminderApplication.ts 95.34% 2 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.

审阅了原生闹钟调度/响铃、JS 侧送达互斥,以及 headless 围栏与时间兜底流程。原生时间触发的主路径整体一致,但后台守护的启动条件会让时间兜底在一类常见权限组合下完全不运行,建议合入前修正。

本地未能运行 TypeScript/Jest:工作区没有可用的 node_modules/.bin/tsc(npm run typecheck 以 tsc: Permission denied 结束)。

Comment thread frontend/src/features/reminder/application/ReminderGuardCoordinator.ts Outdated
LUPENGHAN and others added 3 commits August 24, 2026 15:41
…d location permission

Fixes review findings on this PR: ensureLocationUpdates() required
both foreground AND background location permission before calling
startLocationUpdatesAsync(), even for schedules with no location
component at all. On Android, with the exact-alarm permission denied
(the case the fallback exists for) and only "while using the app"
location granted (no "always allow", a very common choice), the guard
task -- and with it runTimeFallbackPass()/runStuckPendingPass(), which
run on every wakeup regardless of whether a location sample arrived --
never got registered, so overdue time reminders were never rescued.

expo-location's own native module only requires ACCESS_BACKGROUND_LOCATION
when starting location updates without a foregroundService config
(LocationModule.kt: "as a user-initiated foreground service with
notification, this does NOT require the background location
permission"). Since this call always passes foregroundService, the
background permission check was stricter than what the platform
actually needs and is safe to drop; the foreground check stays, since
the native side still requires it unconditionally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…outing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e guards

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@LUPENGHAN

Copy link
Copy Markdown
Contributor Author

关于 reminderGuardTask.ts / geofenceTask.ts / LocalReminderApplication.ts 的 patch coverage:

  1. LocalReminderApplication.ts:已补测试,行覆盖率从 83.66% 提到 91.5%(新增 9 个用例,覆盖 runDeliver 的 inflight/missing/rollback 分支、location watch 回调的竞态守卫、unwatch() 失败容错)。
  2. reminderGuardTask.ts / geofenceTask.ts:剩余未覆盖行全部在 await import('expo-sqlite') / await import('expo-notifications') 之后。这两个包在当前 Jest 配置下(无 --experimental-vm-modules)裸 import() 必定同步抛错,jest.mock() 对它也不生效——实测过,mock 了照样抛。这是仓库既有约定(见文件里的 istanbul ignore next 注释),不是这次改动引入的缺口。
  3. 要打开这部分覆盖,得给 Jest 全局加 --experimental-vm-modules,这是项目级配置变更,会影响所有测试文件,超出这个 PR 的范围,需要单独提。

@LUPENGHAN
LUPENGHAN merged commit 5ab403d into 1024XEngineer:main Aug 24, 2026
4 of 5 checks passed
LUPENGHAN added a commit that referenced this pull request Aug 24, 2026
* feat(reminder): add local TTS for high-strength alarms

Adds on-device text-to-speech for high-strength reminders, replacing
the discarded upstream design (which spoke every alarm with sound
enabled) with a strength-gated one layered on top of the existing
tiered ring sound: only reminder_strength === 'high' triggers speech,
via AlarmSoundTier 'full' + a non-empty speech_text.

- composeReminderSpeech()/strengthDelivery.ts builds the spoken text
  as "title, time to go, it's now HH:MM" (or a date-based line for
  all-day schedules), threaded through AlarmSchedulerPort ->
  NativeAlarmScheduler -> TimeflowAlarmBridge -> AlarmModule ->
  AlarmScheduler/AlarmReceiver/AlarmSoundService/RingActivity.
- AlarmTtsEngine.kt: a shared TextToSpeech singleton, eagerly bound at
  AlarmModule construction time (app-launch, almost always foreground)
  rather than lazily inside AlarmSoundService.onCreate() (often
  background/restricted) -- reduces the risk of an Android background
  process restriction blocking the engine bind.
- AndroidManifest.xml <queries> entry for
  android.intent.action.TTS_SERVICE: without it, targetSdk 30+ package
  visibility hides installed TTS engines from PackageManager and
  TextToSpeech init fails with status=ERROR even when an engine is
  genuinely installed and works from system Settings.
- Drops upstream's ReminderSpeechFormatter.java/reminderSpeech.ts
  (the "speak every alarm" formatter) and the now-fully-redundant
  plugins/withTimeflowAlarm.js, whose permission declarations were
  already covered by the module's own AndroidManifest.xml.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(reminder): drop leftover TTS diagnostic logs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(reminder): forward speech_text placeholder arg in presentNow assertions

PR1's presentNow tests (now on main via #355) assert 6 native args;
this branch's TTS commit added a 7th speech_text param to
presentNow's native call. Update the two assertions to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reminder): speak the effective trigger time, fall back on synchronous TTS enqueue failure

Fixes two P1 findings from fennoai's review of #363:

- composeReminderSpeech() formatted schedule.start_time unconditionally,
  but the alarm actually fires at resolveEffectiveTriggerAt(schedule) --
  earlier than start_time for before_start reminders, and snoozed_until
  once snoozed. A 15-minutes-before reminder announced the event's
  start time instead of the moment it actually rang, and a snoozed
  reminder kept repeating the original stale time.

- AlarmTtsEngine.speak() returns TextToSpeech.ERROR when the engine
  can't enqueue speech synchronously, but speakCurrent() ignored the
  return value. No utterance callback fires in that case, so onError()
  is never reached, and by then the bundled-audio fallback has already
  been stopped -- the high-strength alarm goes silent. Check the
  return value and route into the same onSpeechError() fallback used
  by the async error path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reminder): drop remaining leftover TTS diagnostic logs

6ba1979 already removed two of these "定位问题排查完可以删" markers but
missed four more with the same marker, still firing unconditionally:

- reminderGuardTask.ts: [guard] tick fired on every single guard task
  wakeup (as often as every 15s in the densest polling tier), and
  [guard] geofence eval fired on every headless location evaluation.
- LocalReminderApplication.ts: the foreground-session equivalents,
  applyLocationSample and geofence eval, fired on every location
  callback.

Also drops distanceMeters/center, which only existed to feed the
deleted logs.
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.

fix(reminder): 时间/地点提醒响铃不分强度、原生闹钟无补挂兜底

2 participants