Skip to content

Ring the watch natively before Rust boots, plus a dev fan-out toggle - #299

Open
ScottMorris wants to merge 2 commits into
feat/255-p3a-alarm-manager-firedfrom
feat/255-p3b-wear-sync-listener
Open

Ring the watch natively before Rust boots, plus a dev fan-out toggle#299
ScottMorris wants to merge 2 commits into
feat/255-p3a-alarm-manager-firedfrom
feat/255-p3b-wear-sync-listener

Conversation

@ScottMorris

@ScottMorris ScottMorris commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

What this is

Phase 3B of #255's implementation plan — wear-sync's listener side of the fired→watch-ring fan-out. This is the piece that actually closes #254: a production ContentProvider (WearRingInitProvider, always-on, not debug-gated — distinct from the earlier throwaway spike in #293) registers a native listener before any Activity/Service/BroadcastReceiver callback can run, even on a cold multi-plugin process start. When alarm-manager (#298) publishes a fired event, this listener rings the watch directly via Play Services, entirely natively, with no dependency on Rust/WebView having booted.

Built against the same frozen payload contract as #298 and #300.

What's here

  • NativeFiredListener — skips (no tag) if the event is stale (>90s) or the dev toggle disables fan-out; otherwise builds the ring payload from WearSyncCache and sends it, returning "watch-ring" after initiating (not confirming) the send, per Design: shared native event-queue layer for plugin↔Rust handoff, plus cross-plugin native fan-out #255's at-least-once design decision.
  • WearSyncPlugin.sendAlarmRing's payload construction and connected-nodes send loop are now shared helpers used by both the Rust-invoked path and the new native listener.
  • Rust-side staleness/tag gate on wear-sync's own alarm:fired listener — the staleness half alone retroactively fixes the shipped "2:30 AM ghost ring" bug for events queued before this ships.
  • A "Disable native watch fan-out" developer toggle, end-to-end (Kotlin command → Rust → guest-js → a Settings screen switch), as cheap insurance while dogfooding.
  • event_id threaded through watch-originated dismiss/snooze (WatchDismissAlarm/WatchSnoozeAlarm) so those topics can also participate in the dedup mechanism Carry the watch-ring tag through AlarmFired and dedup redelivered native events #300 builds — found and fixed independently while implementing this (mirrors a bug flagged in alarm-manager's own queue dispatch).

Testing checklist (automated, no device needed)

  • 48 wear-sync Rust tests (incl. 2 new ACL parity tests), cargo clippy -D warnings and cargo fmt --check clean
  • pnpm -r run typecheck, 83 frontend tests
  • Full Kotlin rebuild (testDebugUnitTest/assembleDebug/assembleRelease) — 30 tests pass; confirmed the provider is present in both debug and release merged manifests (genuinely always-on)

Review status

Reviewed via /code-review high. The dominant first-pass finding ("this feature is unreachable, nothing publishes to the bus") was an expected cross-branch artifact of reviewing this worktree in isolation — resolved once combined with #298. Real fixes applied: a synchronous SharedPreferences read on the listener's hot path (now a pre-warmed in-memory cache, with the reasoning for not simply deferring it documented — deferring would have broken the toggle's synchronous gating of the returned tag); a duplicated Play Services send loop consolidated into the shared helper; a double-negative UI toggle simplified; and an ACL parity test added, mirroring alarm-manager's existing pattern from a prior silent-ACL-denial incident (#195).

Stacked on #298. Part of #255.

@ScottMorris ScottMorris added android Android toolchain and mobile CI concerns architecture System design plugin Plugin work labels Aug 13, 2026
@ScottMorris ScottMorris changed the title feat/255 p3b wear sync listener Ring the watch natively before Rust boots, plus a dev fan-out toggle Aug 13, 2026
@ScottMorris ScottMorris added wear-os Wear OS platform ringing Ringing features and issues labels Aug 13, 2026
@ScottMorris
ScottMorris marked this pull request as ready for review August 14, 2026 04:44
@ScottMorris

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a7d9ac9231

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/wear-sync/rollup.config.js
@ScottMorris
ScottMorris force-pushed the feat/255-p3b-wear-sync-listener branch from a7d9ac9 to 7ff293e Compare August 18, 2026 16:56
ScottMorris and others added 2 commits August 18, 2026 13:06
…v fan-out toggle

**Native fan-out (issue #254 fix):** a `ContentProvider` (`WearRingInitProvider`) registers `NativeFiredListener` with `NativeEventBus` in `onCreate()`, which is guaranteed to run before `AlarmReceiver.onReceive()` even on a cold multi-plugin process start. The listener builds the ring payload entirely from `WearSyncCache` (no Rust involved), skips stale events (>90s per the frozen Phase 3 payload contract) and skips when the new developer toggle disables it, then sends the ring via Play Services and returns the `"watch-ring"` tag *after initiating* the send (at-least-once semantics). `WearSyncPlugin.sendAlarmRing`'s JSON-construction logic is factored into a shared `buildAlarmRingPayload` helper so both the Rust-invoked and native paths build an identical wire format.

**Rust-side gate:** wear-sync's `alarm:fired` listener now skips `send_alarm_ring` when the event's `handled_natively` already contains `"watch-ring"`, or when the event is stale by the same 90s window -- the staleness half alone retroactively fixes the shipped "2:30 AM ghost ring" bug for events queued before this ships. `handled_natively: Vec<String>` (`#[serde(default)]`) is added to wear-sync's own local `AlarmFired` copy in `models.rs` (a separate type from the app crate's `events.rs`, which Phase 3C owns).

**Developer toggle:** `getNativeFanOutEnabled`/`setNativeFanOutEnabled` Tauri commands (SharedPreferences-backed on the Kotlin side via `NativeFanOutPrefs`), wired through `mobile.rs`/`desktop.rs` (desktop no-ops, always reports enabled) and a new `tauri-plugin-wear-sync-api` guest-js package, with a "Disable native watch fan-out" switch in Settings' Developer > Testing section (Android-only). `wear-sync:default` added to the app's default capability so the two new commands aren't silently ACL-denied from the webview.

**Watch-originated dedup groundwork:** `WatchDismissAlarm`/`WatchSnoozeAlarm` (and the `WatchMessage` envelope they're threaded from) gain an `event_id`/`eventId` field, sourced from `WearSyncEventQueue`'s envelope id, so a same-process dedup pass keyed on event id (issue #255 Phase 3C) has something to key watch-originated dismiss/snooze on too -- not just the fired path, which Phase 3A already wires an id into.

Kotlin JUnit (`NativeFiredListenerTest`, 17 tests) and Rust unit tests (`should_skip_native_watch_ring`/`is_stale` in `lib.rs`, `event_id` serde round-trips in `models.rs`) cover the new pure logic. Full `cargo test --workspace`, `cargo clippy -p tauri-plugin-wear-sync -- -D warnings`, `pnpm -r run typecheck`, `pnpm --filter threshold test`, and wear-sync's Kotlin `testDebugUnitTest`/`assembleDebug`/`assembleRelease` (via the CI mobile image) all pass -- no cross-branch dependency actually blocked this worktree's build, since wear-sync's `AlarmFired`/`WatchMessage` are its own local types, not shared with the app crate or alarm-manager.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WVm6JTTpiSY8KSKJ3p5Qq
…ut listener

Five fixes from review, all confirmed with a full re-run of cargo test/clippy/fmt, the Kotlin JUnit suite (via the CI mobile Docker image), and the frontend typecheck/test suite:

- `NativeFiredListener.handle()` was reading `NativeFanOutPrefs` (a `SharedPreferences` boolean) synchronously inline, violating `NativeEventBus`'s own documented threading contract (cheap/non-blocking work only). The toggle's value has to be known synchronously though -- deferring it would mean returning the `"watch-ring"` tag optimistically, which would make the Rust-side gate skip its own ring too and drop the ring entirely whenever the toggle is off. Resolved by caching the toggle in an in-memory `@Volatile` field, warmed once by `WearRingInitProvider.onCreate()` (before the listener is even registered) rather than literally moving the read into the async coroutine, which would have reintroduced that correctness gap.
- Factored the duplicated "iterate connected nodes, send message" loop out of `WearSyncPlugin.sendAlarmRing` and `NativeFiredListener.ringWatch` into a shared `sendAlarmRingToConnectedNodes`, alongside the already-shared `buildAlarmRingPayload` -- the two copies had already drifted (only one logged to `NativeEventLog`).
- Cross-referenced the independently-defined 90s staleness window between Kotlin's `NativeFiredListener.STALENESS_WINDOW_MS` and Rust's `lib.rs::STALENESS_WINDOW_MS` with comments pointing at each other, so a future tuning of one doesn't silently leave the other out of sync.
- Renamed `Settings.tsx`'s toggle state from a negated `nativeFanOutEnabled` (bound via `!nativeFanOutEnabled`/`!e.target.checked`) to `disableNativeFanOut`, bound straight through to the switch with no inversion at the render site -- the negation now lives only at the two points that cross the `enabled`-phrased API boundary.
- Added an `acl_tests` module to wear-sync's `lib.rs`, mirroring alarm-manager's own (added after issue #195): asserts every `generate_handler!`-registered command has a matching `allow-*` permission in `permissions/default.toml`, and that the plugin's Rust-internal (`run_mobile_plugin`-only) commands stay out of that list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WVm6JTTpiSY8KSKJ3p5Qq
@ScottMorris
ScottMorris force-pushed the feat/255-p3b-wear-sync-listener branch from 7ff293e to bacb63c Compare August 18, 2026 17:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

android Android toolchain and mobile CI concerns architecture System design plugin Plugin work ringing Ringing features and issues wear-os Wear OS platform

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Watch doesn't ring when the phone alarm fires cold and the screen is on/in use elsewhere

1 participant