Skip to content

Add Live Activity and watchOS support for mapping sessions - #29

Merged
MrAlders0n merged 104 commits into
MeshMapper:devfrom
agessaman:feat/watch-app
Aug 20, 2026
Merged

Add Live Activity and watchOS support for mapping sessions#29
MrAlders0n merged 104 commits into
MeshMapper:devfrom
agessaman:feat/watch-app

Conversation

@agessaman

Copy link
Copy Markdown
Contributor

Summary

This PR adds two Apple companion surfaces to MeshMapper:

  • a native watchOS companion app for monitoring and controlling a MeshMapper session from the wrist
  • iOS Live Activities for at-a-glance session and ping-cycle status

Both surfaces remain projections of the iPhone-owned session state. The phone continues to own the MeshCore connection, GPS fix, session lifecycle, transmit policy, and command admission; the Watch and Live Activity surfaces render that state and send user intent back to the phone.

These features are included together because they share the same underlying session-state projection and lifecycle work rather than maintaining independent copies of application state.

Apple Watch companion

The watch app includes:

  • map view with current position, recent pings, repeaters, and response links
  • session controls for Passive/Hybrid operation, stop, and manual ping
  • heard-node list
  • wrist-local map and layout settings
  • transport/debug diagnostics in debug builds
  • stale-state presentation when the phone has not supplied current state

The watch does not independently drive a MeshCore session. Commands from the wrist are treated as intent and are revalidated by the phone before anything can transmit.

WatchConnectivity transport

The Watch bridge was designed to keep radio and processing overhead low during wardriving:

  • snapshot fingerprinting suppresses unchanged state
  • updatedAt metadata does not itself defeat deduplication
  • non-urgent updates are throttled
  • small GPS changes are suppressed with a movement gate
  • map-only geography can be suppressed while the map is not visible
  • map suppression uses a renewable lease so a lost command fails safely back to full geography
  • queued wrist commands carry IDs for redelivery deduplication
  • transmitting commands carry timestamps and are rejected if they arrive too late or with unreasonable clock skew

Explicit refreshes are distinguished from map-demand updates. A genuine request for current state can defeat payload deduplication, while map-geo lease renewals remain deduplicatable.

Watch lifecycle / stale-state handling

WatchConnectivity may retain application context across watch app launches, so retained state is aged from the phone's original updatedAt timestamp rather than from the moment the watch process reads it.

The watch also reconciles state when its scene becomes active again. watchOS suspends the app while the wrist is down, so onAppear alone is insufficient for normal wrist-raise behavior.

On resume the watch:

  1. consumes any application context already retained locally
  2. keeps using it without radio traffic when it is still fresh
  3. requests a current snapshot only when the local state is missing or stale

This avoids both presenting old retained state as current and placing a WatchConnectivity round trip behind every wrist raise.

Live Activities

The iOS app now exposes session progress through ActivityKit, including current mode/phase, countdown state, recent repeater information, ping status, and session counters.

The implementation is local to the app and does not require a push server or separate App Group state.

Compatibility

  • iPhone application deployment target remains unchanged
  • Live Activity extension: iOS 16.2+
  • Apple Watch companion: watchOS 11+
  • Watch wire additions are optional/additive so missing fields from older peers retain safe defaults

Testing

The branch adds coverage for:

  • Watch/Dart wire contracts
  • command redelivery and admission
  • queued-command age and clock-skew handling
  • explicit refresh vs. map-geo lease intent
  • forced refresh through payload deduplication
  • forced-refresh persistence after a native delivery refusal
  • map geography suppression/restoration
  • movement gating
  • Watch diagnostics
  • Live Activity models and service behavior

CI now runs:

dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test

Bjorkan and others added 30 commits July 15, 2026 01:29
Add an ActivityKit and WidgetKit integration for iOS mapping sessions.

- show the current wardriving phase, including sending, listening, cooldown, and waiting states
- render system-driven countdowns from shared phase deadlines
- display up to three heard repeaters with SNR and hop information
- include session counters, queue state, zone, GPS, and connection status
- provide layouts for the Lock Screen, Dynamic Island, and compact CarPlay presentation
- throttle native updates and avoid duplicate Live Activities
- mark stale session data and show a final summary when a session ends
- keep the integration dependency-free and isolated from existing map presentation logic
The merged Live Activity work introduced the repo's only analyzer issue
(unnecessary_brace_in_string_interps). `dev` analyzes clean, so this
restores that baseline on the watch-app branch.
Phase 1 of the Apple Watch companion: the target skeleton only. The watch is
a mirror-and-remote for a session the iPhone owns, so this ships no session
logic — WatchConnectivity, map, node list, and controls follow in later phases.

Single-target watchOS app (WKApplication), watchOS 26.0, embedded into Runner
via an Embed Watch Content phase so `flutter build ipa` carries it along.

Also routes bundle IDs and signing team through MESHMAPPER_BUNDLE_PREFIX and
MESHMAPPER_DEVELOPMENT_TEAM, defined once at project level. Both resolve to
the previous literals, so nothing changes by default. This exists because a
watch app's bundle ID must be prefixed by its companion's: testing on a
Personal Team means moving every ID together, which is now one field instead
of four targets.

Verified: builds for simulator, embeds at Runner.app/Watch/MeshMapperWatch.app
with all variables resolved, installs and launches on a paired
iPhone 17 Pro / Apple Watch Series 11 simulator pair.
Phase 2 of the Apple Watch companion. The wire is now real in both
directions; the wrist UI is still a raw debug dump, replaced by the map in
Phase 3.

Shared contract in ios/Shared/MeshMapperWatchPayload.swift is compiled into
both Runner and the watch target so it cannot drift, with the Dart mirror in
lib/services/watch/. WatchSnapshot composes LiveActivitySnapshot rather than
re-deriving phase and counter semantics, so both surfaces always agree.

Three decisions worth keeping:

- Countdowns ship as absolute deadlines, never ticks. The watch renders them
  with Text(timerInterval:), so a session sends about one update per phase
  transition instead of one per second.
- Colours resolve on the phone. Dart owns the colour-vision palettes, so
  accessibility palettes work on the wrist with no duplicated code.
- The watch sends intents, never state. Every guard is re-evaluated in
  _handleWatchCommand, so a stale payload cannot cause a transmit.

Unlike the Live Activity, the watch receives snapshots even with no session
running — otherwise the start button could never be reached from the wrist.

Two bugs found by testing rather than review:

- WatchSessionManager held its FlutterMethodChannel weakly. setMethodCallHandler
  makes the messenger retain the handler block, not the channel, so a channel
  left in an AppDelegate local deallocates and every inbound command was
  silently dropped. The other channels survive as locals because they only
  receive; this is the first that invokes Dart from native.
- The watch pre-checked isReachable before sending. That flag lags reality —
  the simulator reported unreachable while still delivering messages seconds
  later — which turned a stale flag into a refused tap. errorHandler is now
  the source of truth.

Verified on paired iPhone 17 Pro / Apple Watch Series 11 simulators: phone
state renders on the wrist (phase, counters, GPS fix, disabled controls with
reason), and requestSnapshot round-trips watch → Swift → Dart → ack.
138 tests pass, 27 of them new.
Phase 3 of the Apple Watch companion. The map is Apple's basemap with
MeshMapper's data drawn on top: ping squares in the phone-resolved ping
colours, repeater pins with a highlight ring for those heard this cycle,
optional lines from the fix to each responding repeater, and a fix puck with
heading. A countdown pill renders the phase deadline via Text(timerInterval:).

MeshMapper's own basemap cannot come along — MKTileOverlay is
API_UNAVAILABLE(watchos), so the OpenFreeMap styles, ArcGIS satellite raster,
and coverage vector tiles have no route onto the wrist. Only the data layer is
ours; a satellite toggle uses Apple imagery instead.

The fix is drawn as a custom annotation rather than UserAnnotation, so the
watch renders the *phone's* position and needs no location permission of its
own.

Follow mode recentres on the fix, yields when the wearer pans, and drifts back
after 8s. Two bugs fixed while verifying it: the initial `.automatic` camera
settle was misread as a pan (suspending follow before the first fix arrived),
and the resume deadline was only ever read during a render, so a stationary
phone — which sends no updates — would stay unfollowed indefinitely.

Initial span is ~3 km rather than ~1 km: wardriving is about what is around
you, and the tighter default opened with every nearby repeater off-screen.

SampleSnapshot is DEBUG-only behind a launch argument
(-MeshMapperSampleData YES). The simulator has no Bluetooth and so can never
produce pings or repeaters, which would leave the map permanently empty there.
Verified excluded from Release by building the watch target in Release.

Known: Apple Maps basemap tiles do not load in the watch simulator — watchOS
proxies tile requests through the paired phone and the simulator's companion
proxy returns GEOErrorDomain -11. Annotations, geometry, and camera are all
verified; the basemap itself needs real hardware.
Phase 4 of the Apple Watch companion. NodeListView shows the repeaters that
answered the most recent ping — SNR-coloured dot, name, SNR, and a context
line of hop count and distance — with a detail view carrying RSSI, seen count
and last-heard time.

Both placements read the same view, so this is a presentation toggle rather
than two implementations:

- sheet: the map keeps a tappable summary bar showing the strongest node
  inline, opening the full list over the map.
- page:  the map stays clean full-bleed and the list gets its own tab.

The bar is a tap, not the swipe-up originally planned: on watchOS a swipe from
the bottom edge is the Control Center gesture, so it would fight the system.
Surfacing the strongest node inline also answers the common question — "what
just answered?" — with no interaction at all.

Rows use standard text styles and never shrink type to hit a row count. The
payload carries up to 7; the list renders what fits at the wearer's text size
and scrolls for the rest.

The map chrome gains a stale badge, so data that has stopped updating can
never read as live.

Two DEBUG-only launch arguments (-MeshMapperShowNodeSheet,
-MeshMapperInitialPage) make specific screens capturable headlessly; the
simulator offers no way to tap or swipe. Verified excluded from Release.
Reworks the heard-node model to match what the phone's map actually shows.
Wire version 2.

The watch was inventing its own idea of "recently responded" from
TxPing.heardRepeaters — names, hop counts, RSSI, seen counts. The app's map
overlay (_buildTopRepeatersOverlay) shows something different and simpler:
up to three rows from the latest ping plus the RX slot, each a dot coloured
by which ping type was answered, the hex path hash, and the SNR.

Three corrections:

- Hex ID is the identity, not the name. Path hashes are 1-3 bytes, so a short
  ID often maps to several repeaters. Names are resolved only through
  indexByHexPrefix, which drops any prefix owned by more than one repeater --
  a confidently wrong name is worse than none. The watch always shows the hex
  and treats a name as a secondary hint.
- No hop counts anywhere. _updateTopRepeaters is fed directRepeaters and
  multiHopEvents are explicitly excluded, so multi-hop was never part of this
  surface.
- The RX slot trails the top three rather than competing on SNR, matching the
  overlay's distinct trailing row.

Layout follows the phone: Top Heard hard against the upper-left corner
(drawing into the top safe area, which is free because the system clock is
right-aligned), countdown pill bottom-right. The bottom summary bar is gone,
superseded by the box.

Colours come from the same OverlayPingType mapping the map uses, resolved on
the phone, so colour-vision palettes carry across unchanged.
…orner

Two placement and legibility fixes on the watch map.

The countdown sat well clear of the bottom edge because the overlay still
honoured the bottom safe area while the map beneath it ignored it. The overlay
now ignores both edges, so the two corners are actually corners.

Top Heard is sized for the worst case rather than the sample case. A 3-byte
zone yields six-character path hashes, and four of those at full size ran the
box across a 40 mm screen. Row type now shrinks with ID length exactly as
RepeaterIdChip does on the phone (11/10/9 pt for 2/4/6 characters), rows are
pinned to a single line, and the box is held to a fixed type size: it is a HUD,
and at large accessibility sizes it would otherwise swallow the map. The
scrollable detail list is where the wearer's text-size setting is honoured.

Both overlays move from flat 70% black to a blurred material. The flat panel
let bright basemap labels bleed through and fight the SNR digits — visible as
a road label crossing the fourth row. Blurring removes the competing detail
and matches the platform's own overlay treatment.

Verified at the worst case — four six-character IDs on a 40 mm SE — via a new
DEBUG launch argument, -MeshMapperLongIds YES.
Both overlays were clipped by the display curvature on a real watch. The
simulator renders a flat rectangle and never showed it — .ignoresSafeArea was
reaching for corners that physically do not exist.

Rather than nudging insets, the two overlays become one panel across the
bottom of the map, inside the safe area, with no corner to lose:

- A depleting bar across the top, draining right to left, with the remaining
  time beside it. Fed by phaseEndsAt and a new phaseDurationMs, both absolute,
  so the bar is correct between updates and correct when the app opens midway
  through a phase. CountdownTimerService gains a durationMs getter and the
  provider identifies the owning timer by matching end times.
- Heard rows in two columns when they fit, one when they do not. ViewThatFits
  decides by measurement: a 3-byte zone's six-character hashes plus SNR cannot
  fit two columns on 40 mm, and the hex ID must never truncate — it is the
  repeater's identity.

Two layout traps worth recording. A Rectangle rule is a greedy child and
stretched the panel to its cap; spacing replaces it. And .frame(maxWidth:) is
expansive rather than merely limiting, so capping the panel made it that wide
always — only the phase title, which can run long, carries a ceiling now.

The translucent material is kept, and the whole panel remains the tap target
for the detail list.
…panel

The label rides on the timer bar instead of sitting beside it, with a shadow
so it stays readable over both the filled and empty parts of the track. A
separate column cost width permanently and left the phase title cramped.

The camera now places the fix in the middle of the band between the top of the
display and the top of the panel, rather than the middle of the display, so
the puck is no longer pushed down behind the panel. The panel measures itself
through a preference key, so this holds however tall the panel gets.

Also tracks the rendered region from onMapCameraChange. Without it a Digital
Crown zoom was discarded on the next follow update, since every recenter
reused the originally requested span.

Known incomplete: the offset under-shifts. Instrumentation on a 46 mm
simulator showed panel=54pt, view=159pt, frac=0.34, latSpan=0.03 — the
fraction is right, but two denominators are wrong. viewHeight measures the
ZStack (159pt) while the map draws into the safe area it excludes (~242pt),
and context.region.span reports the requested span rather than the visible
one. Deriving both from context.rect (MKMapRect) is the fix.
Two changes to the map page, both driven by what the panel actually
measures rather than by a guess.

Bar placement now differs by screen. A 46 mm watch has 184 pt of panel
width and a 40 mm one has 138, so the large screens set the phase title
and the countdown either side of the track and leave the bar as a pure
gauge, while the small ones keep the single label riding on the bar. The
column gutter widens on the roomy sizes too, which ViewThatFits will
still veto down to one column if six-character hashes need the space.

Placing the fix is now a question for the map, not a calculation. The
previous offset under-shot because it compared the panel's height to the
enclosing stack's, and that stack is not the map: SwiftUI reports the
map's frame as 159 pt tall on a 248 pt display, yet it draws to every
edge. No measurement of the view hierarchy predicts where a coordinate
lands. MapReader's proxy answers directly, so the camera translates by
the difference between the fix and whatever sits at the target point,
then a deadbanded correction on each camera change absorbs the first
render and any Digital Crown zoom. Verified: the puck settles at 78.75 pt
against a panel top of 158 on 46 mm, and 47.75 against 96 on 40 mm —
centred in the band, as asked, and stable across frames.

Worth remembering: the CGRect preference keys must ignore empty values.
Every sibling subtree contributes the default, so taking nextValue()
unconditionally let a later .zero overwrite the real frame — the
measurements read as zero until reduce learned to skip them.
…y curve

Three changes, implemented by Codex against a spec and verified here on
both simulator sizes.

The phase title and the countdown both ride the bar again, at opposite
ends of one overlay. Splitting them by screen size was wrong: it moved
"Listening" permanently to the left of the track on large watches and
left the right end of the bar empty, which read as the clock having
disappeared. One treatment now serves every size.

The panel is narrower and sits 4 pt from the bottom edge. It may descend
into the bottom safe area only in exchange for horizontal clearance,
because that safe area is what the display's curvature costs: modelling
the corner as a circle of radius R, a panel whose bottom edge is g from
the edge needs R - sqrt(2Rg - g^2) of inset, plus 4 pt because the
reported inset is a lower bound on the real glass. watchOS exposes no
corner radius, but its bottom safe-area inset is the clearance a
full-width element demands, which is that radius. The 46 mm reports 36
and the 40 mm 19, so the large watch narrows nearly twice as much — the
asymmetry that was asked for, arrived at rather than assumed.

Top Heard is two columns everywhere now, including 40 mm, with the type
size solved from the width actually available rather than picked from a
hash-length ladder. Only a six-character zone on the smallest screen
falls back to one column, and it now does so at the 9 pt cap instead of
inheriting the two-column floor.

Measured, in points, with the panel top and the fix both verified stable
across frames:

  46 mm  safeBottom 36  inset 23.5  panel 157.5 wide at y=190  font 9.8
  40 mm  safeBottom 19  inset 11.3  panel 135.5 wide at y=144  font 7.8
  40 mm  six-char       inset 11.3  panel 135.3 wide at y=115  font 9.0

Three measurement traps cost a build each and are worth remembering.
Reading the container's width to compute padding applied to that same
container is a feedback loop — the 40 mm reported itself 169 then 173 pt
wide on a 162 pt display, and the panel landed 2.3 pt from the edge
instead of the 9.3 it had just computed; WKInterfaceDevice.screenBounds
is static and cannot feed back. A GeometryReader in the map's background
reports no safe area, because the map ignores it. And `.ignoresSafeArea()`
on the reader itself reports none either, since it measures its own
expanded region — plain is correct here, and the first value is latched
so the panel can never perturb the number that positioned it.

The corner model still needs confirming on hardware: the simulator
renders a flat rectangle and cannot show a clip.
Placement and clearance gaps are now separate constants. The panel sits
8 pt from the bottom edge, but its horizontal inset is still evaluated
as if it sat at 4 — otherwise the geometry would hand back roughly 12 pt
of width on the 46 mm, undoing the narrowing that was the point. Keeping
the lower position as a clearance floor is also the conservative
direction for curvature the simulator cannot show.

Measured: both insets unchanged at 23.51 and 11.34, both panels 4 pt
higher, and the fix recentred with them — 92.75 pt against a 93.0 target
on the 46 mm, 69.75 against 70.0 on the 40 mm.

Also verified the six-character worst case on the 46 mm, which had only
been checked on the 40 mm: two columns at ~8 pt, no overflow. The 40 mm
remains the sole size that falls back to one column, at the 9 pt cap.
…d phases

Both gaps now scale with the measured corner radius rather than being
fixed. A panel sitting higher needs less horizontal clearance, so height
buys width — the trade this deliberately refused last commit, when the
ask was narrower-and-higher and only the height part had arrived. The
ask now includes the width, because six-character hashes on a 46 mm were
down at 8 pt.

The 4/19 and 8/19 ratios are calibrated from the 40 mm, the one size
confirmed good on hardware, so it reproduces its numbers exactly by
construction — inset 11.338, content 123.324, font 7.825, verified
unchanged. The 46 mm gains 11 pt of width and sits 15 pt off the bottom,
taking six-character IDs from 8.0 to 8.81 pt. Width still binds there
rather than the hash-length ladder's 9 pt ceiling.

A lapsed deadline no longer claims its phase. The title was never wrong
— the phone sends "Listening…" while the RX window runs and "Next ping"
while the auto-ping timer does, matching ping_controls — but the watch
rendered whatever it last heard forever, so a passed deadline with no
newer snapshot left "Listening" asserted over an empty track. That is
the state Adam photographed. Titles with a future deadline are unchanged;
titles with no deadline at all stay full strength, since "Device
disconnected" and "Waiting for GPS" are states rather than countdowns
and remain true until replaced; a title whose deadline has passed now
dims to 45%, reading as last-known rather than current.

The reason this took until now to surface is that SampleSnapshot
hardcoded "Listening", so no screenshot ever showed a wait phase.
-MeshMapperSamplePhase listen|wait|lapsed fixes that gap. Verified all
three render correctly, and that it and the three existing launch
arguments are absent from the Release binary.
Phase 5. The transport and all guards already existed — `_handleWatchCommand`
revalidates on arrival and the wire carries `canStartStop`, `canManualPing`,
`isSessionActive`, `blockedReason` and the cooldown deadline. What was
missing was the surface.

Start/stop, and manual ping behind a two-stage confirm: the first tap arms
the button for three seconds, the second sends. A wrist bump must not be
able to transmit, and disarming needs no round trip.

`WatchSessionClient` now tracks the in-flight command so a tap shows work
happening, and drops a reply that a newer tap has superseded — otherwise an
old answer lands under a fresh action. Silent refreshes stay out of that
state; the wearer never asked for them.

Three defects found by putting it on both simulator sizes:

A cooldown is the only unavailability the phone reports with **no**
`blockedReason` — `_buildWatchControls` sets one for "Not connected" and
"No GPS fix" only — so the ping button sat dead and unexplained for fifteen
seconds, which is precisely what this phase exists to prevent. It now
counts down on the button face from the absolute deadline already on the
wire, so it stays right without further snapshots.

The page title pushed `blockedReason` below the fold on a 40 mm, hiding the
one line that explains a disabled button. Dropped: the buttons name
themselves. The reason also finished flush against the bottom edge, which is
the class of bug that clipped on real hardware twice, so the content now
keeps clear of it.

Disabled buttons rendered inconsistently — a disabled green `borderedProminent`
desaturates to a pale grey that reads as tappable, while the accent-tinted one
went dark. Both grey out now.

Also fixes a debug affordance that never worked: `MeshMapperInitialPage` was
assigned in `onAppear`, and a `.verticalPage` TabView ignores a selection
change made that late, so every headless capture silently landed on the map.
It is the state's initial value now.

Node-list default is its own page, chosen for the room it gives the rows.
Sample controls gain `idle|blocked|cooldown` alongside `active`.
Three faults in one place, reported from the wrist as "the initial zoom
level was very high (multiple states)".

The opening view was never the default. `camera` starts `.automatic`,
which fits every annotation in the snapshot — continental with repeaters
spread wide — and `noteRenderedRegion` then adopted that span as though
the wearer had chosen it, so one auto-fit poisoned the zoom for the rest
of the session. Rendered spans are now ignored until `programmaticCenter`
exists, the same signal that already distinguishes our own camera updates
from the wearer's.

The default is 500 m rather than 3.3 km. The old comment argued the wide
span was deliberate for wardriving; that reasoning was mine and the wrist
disagreed, so it is gone rather than left contradicting the code.

Zoom now survives relaunch. It was `@State`, so every launch discarded it.
It lives in `WatchSettings` with the other preferences, clamped to
0.0005...0.5 on both read and write — persistence turns a stray Crown
flick into a permanent state, and the clamp is what stops a remembered
preference becoming a trap. Absence is distinguished from zero, because
`double(forKey:)` returns 0 for a missing key and would have opened every
fresh install at the 55 m minimum.

Persisting only on a material change (>1%), since every follow update
raises a camera change and writing an identical value would invalidate the
observable and re-render the map for nothing.

Verified on a fresh simulator container: opens at street level and stores
0.0045, which is proof the `.automatic` span was not adopted — that would
have stored a value orders of magnitude larger.
…, add the icon

Four wrist reports.

**The map flew across the north Pacific on launch.** Two faults stacked.
`recenterIfFollowing` always animated, so the first placement was a 0.25 s
flight from `.automatic`'s arbitrary opening position to the fix, dragging
tile loads the whole way. The first placement is now a cut; later ones
still animate, which is right for small follow nudges.

Underneath that, `.automatic` settles *after* our first request, centred on
the annotation cloud — measured 372 m from the fix — and `noteCameraChange`
read that disagreement as the wearer panning. It suspended following for
eight seconds and overwrote its own expectation, so our region landing then
looked like a *second* pan:

    [pan] SUSPEND dist=371.9 center=47.611891,-122.323025 expected=47.6122,-122.3181
    [pan] SUSPEND dist=371.9 center=47.6122,-122.3181     expected=47.611891,-122.323025

Nothing counts as a pan now until MapKit has confirmed a centre we asked
for. Zero suspensions at launch, down from two, and the fix lands 1.9 pt
from target on 46 mm and 0.25 pt on 40 mm. This also explains a transient
recenter button I dismissed as mid-animation two days ago, and it means the
placement regression in `a846153` was mine to catch and I did not — I said
the puck looked centred without measuring it. It was 57 pt low.

**Manual ping was offered where the phone would refuse it.** The watch gate
was `isConnected && hasGpsLock` plus cooldown; the app's own Send Ping
button requires twelve conditions. `_buildWatchControls` now mirrors that
set exactly, through the same `manualPingValidation` getter the widget uses,
rather than a second implementation of the policy. `blockedReason` gains
the app's own words for the two states it names, "Offline Mode" and
"Passive Only".

**Ping markers are circles.** They were squares under a comment claiming
they matched the iOS map, which is how it survived — `_CoverageMarkerPainter`
draws a filled circle with a white border. Mirrored at wrist scale, minus
the shadow, which would cost a blur each for up to sixty markers.

**The watch had no icon** because its `AppIcon.appiconset` held a
`Contents.json` expecting an image and no image. Copied the 1024 pt iOS
icon in; `AppIcon` now compiles into the watch's `Assets.car`, which
previously carried only `AccentColor`.
…orting

Three wrist reports, plus two defects found verifying them.

**The command handler's guards were weaker than the button's.** Its
manualPing case checked only connection, GPS and cooldown, then called
sendPing — while `_buildWatchControls` mirrored the app's twelve
conditions. So a wrist tap could reach the radio in a zone where TX is
not permitted, with nothing but sendPing's own checks between. That
contradicts the contract stated a few lines above it: every guard is
re-evaluated because a stale payload must never cause a transmit.

The condition set now lives once, in `_manualPingAvailability`, returning
availability and reason together. One caller decides what the wrist
offers; the other decides whether the radio transmits. Duplicating that
policy is exactly how the two drifted apart.

**"Stopping…" jumped to the right edge.** A SwiftUI ProgressView takes
the horizontal slack a stack offers it, so the spinner shoved the label
aside the moment a command went pending. Spinners are leading overlays
now, outside the layout that centres the label — feedback should not move
the thing under the wearer's thumb.

**A dead transport error sat under the button.** "Payload could not be
delivered." is WatchConnectivity's wording and it stayed on screen
indefinitely, reading as current state rather than one past action.
Refusals expire after six seconds, and the delivery-failure and
unreachable codes now say "iPhone didn't respond, try again". Refusals
from the phone stay verbatim — those are already written for people, and
rewording them would put the watch's guess above the phone's statement.
No retry: a send whose delivery is uncertain must not be repeated, or a
ping goes out twice.

Two defects caught in review, neither reported:

The cooldown label put two Text views in a `Group`, which applies each
modifier to every child — so `maxWidth: .infinity` went to the words and
the timer separately and threw them to opposite ends of the button.
Verified on screen before fixing. It is one HStack now.

The reason ladder fell through to "Another operation is in progress" even
when the ping *was* allowed, which would have printed that under two
working buttons. Reason is nil unless a refusal is actually happening.
Start and stop worked from the wrist but showed "iPhone didn't respond,
try again" every single time. The command was fine; the acknowledgement
was late. `relayCommand` replies only when Dart's future resolves, and
`_handleWatchCommand` awaited the entire action — `startSession` awaits
`toggleAutoPing`, which makes an API session check and drives BLE. That
outruns WatchConnectivity's reply window, so the watch's error handler
fired on every success.

Guards still run on arrival, unchanged: admission is decided
synchronously and refusals still travel in the reply. What changed is
that the action itself is no longer awaited before replying. Outcomes
were never the reply's job anyway — `isSessionActive`, the phase and the
ping colour all reach the wrist through snapshots.

That leaves failures that only appear later, and manual ping is the case
in point: it is refused inside `_checkSessionBeforeAction`, a *server*
call, so no local gate can predict it and the wrist got a bare "Ping
failed". `WatchHapticCue` already existed for events of this class, so it
gains an optional message; a failed action emits a unique-ID failure cue
and schedules a snapshot. The watch shows it through the same expiring
path as a refusal — one presentation for "the phone says something went
wrong", not two. Cue IDs are deduped against a bounded cache because
immediate messages and application context can deliver the same cue in
either order.

`_checkSessionBeforeAction` had `result.reason` and `result.message` and
discarded both. It now keeps them, so a refused ping says why, and
`zone_full` reuses the existing "Passive Only" wording rather than
inventing a third phrasing for one condition.

Wire version deliberately unchanged: the cue field is optional and both
sides ship in the same app.

Unverified end to end — reproducing it needs a phone doing real BLE work,
which the simulator cannot do. The reasoning and the gates are sound; the
wrist is the proof.
Start and stop kept reporting "iPhone didn't respond, try again" while
working. A device console capture ended the guessing:

    [WATCH] sendMessage(requestSnapshot) failed: ...device is not reachable.
    [WATCH] sendMessage(startSession)   failed: Payload could not be delivered.

`sendMessage` needs the counterpart app reachable, which for the phone
means roughly foreground — not the normal case when someone taps their
watch. WatchConnectivity delivered the payload and the phone acted on it,
but the reply could not return, so the watch reported `deliveryFailed`
after every success.

The previous fix, replying on admission rather than completion, was aimed
at reply latency. Latency was never the cause. Both of my diagnoses came
from the symptom; only the logged error settled it.

Commands and refresh requests now go by `transferUserInfo`: queued,
survives unreachability, wakes the counterpart, and has no reply to fail.
That is affordable only because outcomes and refusals already return as
snapshots and failure cues. `requestSnapshot` gains the most — it used to
fail outright with "not reachable", exactly when a refresh is most wanted.

Deliberately no opportunistic `sendMessage` and no retry on failure:
`deliveryFailed` is reported for payloads the phone *did* process, so a
fallback resend would transmit twice.

**Queued commands must expire.** A transfer can arrive whenever the phone
next becomes reachable, and a ping that fires minutes late is attributed
to where the vehicle now is rather than where it was. Commands carry
`issuedAtMs`; anything older than 30 s is refused before reaching
`_handleWatchCommand`. The ID is remembered first, so redelivery cannot
retry it later. `requestSnapshot` is exempt — a late refresh is harmless —
and a missing timestamp is still accepted, for watches running the older
build.

`pendingCommand` was cleared by the reply that no longer exists, so it now
clears on the next snapshot with a 10 s backstop; a spinner that never
stops is worse than none. `WatchCommandAck` is removed rather than left
describing a protocol we no longer speak.
Starting a session from the wrist did nothing, while stopping produced a
cooldown that passive mode never creates. `_handleWatchCommand` called
`toggleAutoPing(_autoMode)`, but `_autoMode` defaults to Active and is
only assigned inside `toggleAutoPing` when a mode actually starts. So
until a mode had been started *on the phone*, the wrist started Active —
which in Adam's passive-only region is the one mode forbidden there.

His Live Activity had been reporting this all along: the "circle with a
line through it" is the `txBlocked` phase, which fires on exactly
`(_autoMode == active|hybrid|targeted) && !txAllowed`. Pressing Passive
on the phone set `_autoMode`, which is why every wrist toggle worked
afterwards.

The phone never hit this because each of its buttons passes an explicit
mode. Only the wrist inherited an implicit one, and the default happened
to be the forbidden one.

`_resolvedWatchSessionMode` now decides: a running session keeps its own
mode, so the wrist stops what it started; otherwise a region that forbids
TX resolves to Passive; otherwise the wearer's last choice stands.

The button says which mode it will start — "Start Passive" — because a
wrist control that silently picks a mode is only safe while the guess is
right. That label reads from the same resolver as the action; sourcing it
from the ambient `_autoMode` would have traded a silent wrong action for
a visible lie. Only the watch payload's `mode` changed: the Live
Activity keeps `_liveActivityModeTitle`, since it reports the session
that is running rather than the one a button would start.
"The design of the live event panel on the watch and iphone leave a lot
to be desired. We have better design elements in the app we should
repurpose." The elements worth repurposing are the ones now signed off on
the wrist: a depleting countdown bar, and rows of hex identity with a
ping-type dot and a quality-coloured SNR.

Most of the gap was data, not styling. `ContentState` carried
`phaseEndsAt` but no duration, so a progress bar could only be full or
empty. `HeardRepeater` was `{id, name, snr}` with no ping type and no
colour, so every dot was painted the same grey-teal and the distinction
between a discovery answer, a flood answer and an RX packet — which the
map overlay is built around — could not be drawn at all. Nothing carried
the last ping's outcome, so an unanswered ping, a real negative result
when mapping coverage, looked identical to a cycle that had not reported
yet. And the extension hardcoded three colours, quietly ignoring the
colour-vision palettes that the watch honours for free.

So the wire gains `phaseDurationMs`, `pingColor`, and per-repeater
`typeColor`/`snrColor`, all resolved on the phone through the same
helpers the watch already uses rather than a second set. Colour policy
stays in Dart, where the palettes live.

Layouts, per surface's real constraints: the lock screen shows hex *and*
resolved name, because that is what the larger display is for; the watch
small family shows hex only, since the hash is the identity and there is
no room for more; the island's minimal presentation carries the outcome
colour, since one mark should be the most valuable one.

A lapsed deadline dims its title to 45% and drops the countdown, matching
the rule the watch already follows — these surfaces can sit on a stale
state for a long time, and neither should keep asserting a phase it can
no longer vouch for.

Reviewed as rendered pixels, not as code. The content views take a plain
`ContentState`, so `ImageRenderer` can draw them headlessly — nine images
across three states at each surface's real width. That caught what
reading could not: the metrics sat on the third repeater row's baseline,
so `91CE  -8.7 dB  TX 42  RX 318` read as one line and the session totals
looked like properties of a repeater. They now share the badge row,
taking the space back from `phaseDetail`, which on the lock screen only
restated the bar above it. The detail stays in the payload — several
phases carry information the bar does not, and the island's centre region
is the place for it.

Session-end summary is deliberately not here; it is the next round.
Adam, on the render: "Don't tint the bar. That's too much of an error
telegraph for a common case."

He is right, and the mistake was mine. An unanswered ping is the normal
outcome in thin coverage — it is the thing being mapped, not a fault.
Filling the whole progress bar red made an ordinary result look like a
system failure, and in doing so left nothing louder for actual errors.

The bar and the Dynamic Island keyline now take a neutral accent, and the
compact-leading glyph follows the phase rather than the last ping. The
outcome stays legible where it belongs: the outcome dot on the small and
minimal presentations, the coloured dot beside "Nothing heard", and the
per-repeater type dots. None of those were made louder to compensate —
the point is that a routine negative result should be available, not
announced.

The watch's own bar has the same tint and arguably the same problem, but
that surface was signed off on hardware, so it is asked about rather than
changed here.

Verified by re-rendering all nine states through the harness.
"The teal passive pings aren't being displayed as dots on the map or they
are being rendered as purple rx dots."

They were never displayable. `buildPings` took only `txPings` and
`rxPings`, and `_buildWatchGeo` passed exactly those — so the builder's
`pingColor('disc', …)` teal branch and its `trace` branch were
unreachable code, and RX purple was the only non-TX colour the watch
could draw. Green was fine: it is on the path that runs.

The phone draws these from sources the watch was never handed:
`discLogEntries`, where success is `discoveredNodes.isNotEmpty`, and
`traceLogEntries`, which carries `success` outright. Both now reach the
wire, with the phone's own success rules rather than new ones.

The cap needed rethinking with four sources. It applied to a list built
as "all TX, then all RX", which with discovery added could have kept
sixty TX markers and dropped every teal one — the same bug wearing a
different hat. Candidates are now sorted newest-first across all types
before the cap, so history thins evenly instead of a category vanishing.

Also mirrors the phone's multi-hop rule, which is the other half of what
he saw: a TX answered only through multi-hop draws as an RX marker,
because that is what it evidences — the packet returned, but not
directly. `pathHops == null` marks a direct echo.

Four types, four colour rules, and until now nothing asserted that a
discovery ping ever reached the wire at all — which is exactly how a
whole category went missing unnoticed. The tests do that now, including
that the cap starves no single type.

Not fixed here, and reported separately: RX-only repeaters never get the
current-cycle ring, because `heardIds` is built from `_topRepeatersOverlay`
alone and omits `_rxOverlaySlot`. That is the repeater pins, not the ping
markers, so it does not belong in this change.
A 71-minute walk cost ~40% of Adam's watch battery — about 34%/hour,
which makes the app useless for the long drive it exists for.

Two patterns account for the obvious waste, and neither was measured
here: watch power cannot be instrumented from this machine, so these are
the known-expensive things removed, not a proven culprit. A real walk is
the only test.

**Always-On was unhandled.** Phase 7 planned it and it was never built,
so for most of that walk — wrist down, app frontmost — watchOS was
rendering a live MapKit view with annotations. MapKit is the most
power-hungry thing on the device, and none of it is legible at reduced
luminance. The dimmed state now removes that subtree entirely rather than
covering it, and stops driving the camera: no recentring, no corrections,
no animations until full luminance returns.

**A 1 Hz TimelineView redrew the panel over the live map** for the whole
session, compositing translucent material every second. Most of it bought
nothing: `Text(timerInterval:)` already updates itself natively, and a
depleting bar can be one linear animation over the remaining phase rather
than thousands of view updates. The Live Activity's bar had the same
timeline and the same fix. No 1 Hz timeline remains anywhere.

Always-On also needed its own layout, which only became apparent once it
could be seen. Reusing the map's overlay panel left a small card pinned to
the bottom of a black screen at map-overlay type size — and then the phase
title truncated to "List…" on a 40 mm, hiding the one thing a dimmed
glance is for. The dimmed view now spends the space it actually has: title
at 18 pt wrapping rather than truncating, countdown at 24 pt beneath it,
Top Heard full width below. The progress bar is gone from that state —
beside an explicit countdown it was duplicated information and another
compositing pass.

The countdown reads "<1 min" / "3 min" there, because Always-On updates
about once a minute and a seconds figure would be silently up to a minute
wrong.

`MeshMapperForceDimmed` is kept, not scaffolding: Always-On cannot be
entered in the simulator and otherwise needs a wrist-down device, so
without it this surface goes back to being unreviewable — which is how it
shipped bottom-pinned and truncated in the first place.
… glanceable

Three things from real-device use.

**The bar stuttered in "ping skipped" mode.** `phaseKey` included the
phase and its title, and `.task(id:)` restarted the drain whenever either
changed. In skip mode the phase flips between waiting/"Next ping" and
skipped/"Ping skipped" while the *same* auto-ping timer runs to an
unchanged deadline — so each flip cancelled the animation, snapped the
fill back to its true fraction and started again. The bar was reporting a
change of wording as a change in time. It keys on the deadline and
duration alone now; the title is a caption over the drain, not part of it.

**A bar that cannot be refreshed should not pretend to move.** On the
iPhone's always-on lock screen, refreshing about once a minute, the
animated fill rendered frozen mid-drain — which reads as a stalled
session, worse than showing nothing. At reduced luminance the Live
Activity now draws the track alone and lets the countdown carry the
state, a coarse number being honest where a stopped bar is not. Adam's
rule, worth keeping: an element implying continuous motion must not be
drawn where the refresh rate cannot deliver it.

**The panel's text was too small to glance at.** Raising it buys width,
because the curvature clearance needed at a given height falls as the
panel moves up the curve, and width buys type size through the existing
solver. This re-couples placement to clearance, which `bf6f7ef`
deliberately decoupled — that was right when he wanted the panel
narrower, and this is right now that he wants it legible. His call both
times.

  46 mm  gap 18.9  inset 8.3  panel 191  font 11.0
  45 mm  gap 18.4  inset 8.2  panel 182  font 11.0
  40 mm  gap 10.0  inset 6.3  panel 149  font 8.7

The ladder cap rose a point too, since width alone was no longer the
binding constraint. Mirroring the phone's `RepeaterIdChip` sizes was the
original reason for it, but a watch is read at arm's length in motion.

Worth flagging: the panel is now nearly as wide as it was before he asked
for it narrowed, though sitting much higher. The clearance arithmetic says
that is safe — at 19 pt up the curve the corner needs only ~4 pt of inset
— but the hardware confirmation was taken at the previous geometry, so
this specific combination is unverified on glass.
"On the map our location doesn't [stay] fixed in the center with the map
moving around, instead we move on the map and then recenter."

Two decisions collided. The phone withholds geo updates until the fix
moves 15 m, so nothing moves between snapshots and each one lands as a
single large step. And the camera animated over 0.25 s while the fix
annotation — anchored to a coordinate — moved the instant the snapshot
arrived. So the puck jumped ahead and the map slid after it, which is
precisely the sensation of moving across the map and being chased.

Worst where he noticed it: at walking pace that is one lurch every ~11 s.
At 30 mph the same threshold fires every ~1.2 s and reads as continuous.

Automatic follow updates and placement corrections now cut, so the camera
moves in the same frame as the fix and the puck stays where it is while
the world steps beneath it. An explicit recentre tap still animates: it is
rare, the wearer asked for it, and the motion shows what their tap did.
Nobody asked for a follow update, so nothing should appear to move except
the world.

Two alternatives were considered and deliberately not taken, both
recorded: lowering the 15 m threshold buys smaller steps with more radio
wakeups, and interpolating between fixes would pan continuously but
invents position data and reinstates the continuous animation `f15c93e`
removed. Either is available if stepping still distracts on a drive —
after tomorrow's battery numbers, not before.
"The countdown timer in the live [activity] is not positioned at the end
of the bar. It would be nice to move it a bit closer to the end of the
bar as it is in the watch app."

Measured from a render rather than guessed: the number's right edge sat
8.7 pt inside the track on the lock screen and 8.3 in the island. Because
the cap is rounded, the eye measures to the curve, which makes that gap
read as larger than it is — the number looked adrift in the dark part of
the track rather than anchored to its end.

The two ends were being inset equally, but they are not symmetric in
effect: the title begins against a straight fill edge while the countdown
ends against a curve. The inset is asymmetric now, and the gap measures
4.0 pt on the lock screen, 3.7 in the island and 4.0 on the small family.

The spacer between title and countdown is untouched — it is what
guarantees the title truncates before the two can collide, so buying room
from it would trade one defect for a worse one.
…entity

Three findings from the branch review, all traced before being believed.

**A synthetic timestamp defeated the dedupe and lied on screen.** Every
heard row carried `at: now` — the moment the payload was built — so once
Top Heard held anything, each rebuild produced a different fingerprint and
the 2 s throttle became the only brake: roughly 2,100 context updates
across a 71-minute session where near-zero were intended. Excluding
`updatedAtMs` from the fingerprint had achieved nothing. It was also
displayed: Node Detail's "Heard" time read as now, always. Rows now carry
when their set last changed, tracked separately for Top Heard and the RX
slot because multi-hop updates can move one without moving the other. The
manual-cooldown deadline serialises the timer's own `endTime` rather than
being reconstructed from two `DateTime.now()` reads whose jitter alone
changed the fingerprint.

**The cheap check ran after the expensive one.** `_flush` built the whole
geo payload — merging and sorting up to 2,000 ping candidates, resolving
colours and distances, evaluating the twelve-condition ping gate — then
serialised it, and only then compared the fingerprint and usually threw it
away. Five countdown timers tick at 500 ms into the scheduler, so a quiet
session did that twice a second. Urgency is now decided from a small
scalar projection first, and a flush inside the throttle window
reschedules without building anything.

Worse, none of it was gated on owning a watch. `isSupportedPlatform` only
asked whether this was iOS; native refused the payload at `isPaired` /
`isWatchAppInstalled`, but after Dart had built, encoded and crossed the
method channel. Someone with no Apple Watch paid all of that for a payload
that was discarded. Native now publishes its availability and the
scheduler does nothing without it — and because `sessionWatchStateDidChange`
republishes, a watch paired after launch starts working without a restart.

**Links compared incompatible identities, so none had ever drawn.**
`linkedRepeaterIds` and the heard IDs carry path hashes; `WatchRepeater.id`
carried the API database ID and `hexId` never reached the wire. The watch
compared the two exactly, so no link line has ever appeared and the
current-cycle ring almost never fired. Both identities now travel, and
matching resolves a path hash as a unique hex prefix — ambiguous prefixes
draw nothing, because a line to the wrong repeater asserts a relationship
that does not exist. The RX slot joins the highlight set, which was the
item deferred from the ping fix.

Nine tests cover the parts that were blind: timestamp stability across
rebuilds, throttling before the build, pairing transitions, prefix links,
ambiguity, and RX-only highlighting.
Five remaining review findings.

**Stop was silently dropped while a session was starting.** Admission
checked `_autoPingEnabled` but not `_autoPingStarting`, and during Start's
awaited session check the first is false while the second is true — so a
Stop arriving in that window was treated as "already stopped" and
discarded, after which the session came up anyway. Start in that window is
genuinely idempotent and is still accepted as a no-op; Stop is not, so it
now refuses with "Still starting — try Stop again." No deferred queue: a
refusal the wearer can act on beats hidden ordering they cannot see.

**An idle watch claimed to be preparing a session.** The shared phase
resolver maps "no session" onto Starting, which is right for the Live
Activity — whose builder only runs during a session — and wrong for the
watch, which is always present. A connected, GPS-locked, idle watch said
"Preparing session…" indefinitely, including right after Stop. The watch
projects that fallback to a new idle phase, "Ready / No session running";
the Live Activity still calls the shared resolver directly and is
unchanged.

**The outcome colour only ever read TX history**, so a Passive session
showed whatever TX last did, and a multi-hop-only TX reported success
while the map marker beside it drew RX purple — the same event described
two ways. It now follows the newest event across all four histories and
applies the map's multi-hop rule.

**Staleness never invalidated the view.** `isStale` compared against
`receivedAt` with nothing changing at the 90-second boundary, so on a
durable phase a dead link could look current indefinitely. The boundary is
an event now: one cancellable task per snapshot, not a poll — this is the
app whose battery we spent the day cutting.

**A failure cue replayed after a watch restart**, because the phone never
cleared it and the watch deduped IDs in process memory only. The phone
drops a cue once native accepts it, and the watch ignores anything undated
or older than 30 seconds. Either half alone leaves the hole open.
A queued stopSession delivered outside the 30 s admission window — the phone out
of range, the watch suspended mid-transfer — was refused with "Took too long to
reach iPhone", leaving the radio transmitting after the wearer had asked it to
stop.

That window exists so a late command cannot put a transmit on air from the wrong
place. Stopping takes the radio off air and can mis-attribute nothing, so it is
exempt for the same reason requestSnapshot already is.
Splits the old blanket assertion in two: Passive still ignores the transmit
policy blockers, and now waits out the manual-ping, RX-window and cooldown
timing guards it shares with every other mode.
Legacy sendMessage reply forwarded Dart null as NSNull. relayCommand passed the
reply dict through unmodified, and for every accepted command that dict is
{'reason': null}, which the standard codec decodes to NSNull — not a
property-list type, so WCSession's replyHandler failed on exactly the success
case of the compatibility path it exists to preserve. Nil-valued keys are now
stripped; an absent key reads the same as a null one on the watch.

A failed activation was permanent. Nothing retried it: Dart's canSync stays
false so send() — the only other activateIfNeeded caller — is never reached, and
the diagnostics screen polls status without re-activating. Activation failure
now backs off from 2 s to 60 s, and the status poll takes its own second chance.
activateIfNeeded also no longer fires while .inactive, which belongs to
sessionDidDeactivate.

Reachability flips wiped the dedupe caches. Every availabilityChanged push was
handled as refreshNativeState: true, clearing the payload fingerprint, movement
gate, throttle timestamps and map-geo lease — but native only drops
lastContextData in sessionWatchStateDidChange and clear, never on reachability,
which flips on every wrist raise and lower. Each glance therefore forced a full
updateApplicationContext resend and silently voided the lease. The push now says
whether native cleared its own cache, and only that invalidates Dart's.
ActivityKit only permits Activity.request from a foreground app, and a session
can start backgrounded — auto-ping is restored after a BLE auto-reconnect under
the background service. The visibility error was rethrown as a PlatformException,
which bypasses Dart's 30 s backoff because that only arms on a false result. So
every throttled sync retried and logged a failure for as long as the app stayed
backgrounded.

Returning false is also the honest answer: there is nowhere to put an activity
right now.
…n first

resume() computed needsPhone synchronously right after ingest(context:), but the
decoded context is applied through a queued Task { @mainactor } — and the 90 s
stale-boundary flip is queued the same way. The decision never saw the
just-ingested context, so the outcome depended on main-actor queue ordering:
either every glance past the boundary sent a forceRefresh full snapshot despite
a seconds-old retained context, or the request was skipped and the wearer sat on
stale UI. Both are what the method exists to prevent. This path now applies the
context on the calling actor; every other ingest keeps the background decode.

Separately, ingest(data:) fully decoded WatchSnapshot before checking
isSupportedVersion. A future v3 that removes or renames a required field — one
of the version constant's own documented bump triggers — fails the throwing
decode and returns before the check, so the "update the iPhone app" prompt
never fires and the watch just goes permanently stale. wireVersion is now read
through a minimal probe struct first.
pingLabel read Date() to test the cooldown deadline and again to build the
timer's range, so a deadline crossing between the two produced lowerBound >
upperBound — a runtime trap. MapPage's activeCountdownRange documents and avoids
the same hazard; this mirrors it.

DebugPage's session() has the pattern too, but it is DEBUG-only and left alone.
Key-set pins for WatchPing, WatchPosition and WatchHeardNode, matching the
existing WatchRepeater test. Every field in these is non-optional on the Swift
side, so renaming e.g. fixedAtMs does not degrade — it fails WatchSnapshot's
throwing decode and the watch stops receiving anything. Today that rename passes
the whole suite.

And a refused *timestamped* command now has its dedupe pinned. The invariant is
already stated in the bridge's own comment — redelivery after conditions change
must never turn yesterday's tap into a transmit — but only the legacy
untimestamped branch was covered, which is the branch no shipping watch uses.
DEVELOPMENT.md gains the [WATCH] and [LIVE ACTIVITY] rows in the Required Tags
table, the new Dart files in the Key File Reference, and an architecture section
for the companion surfaces: the five suppression gates in order and what each
one is for, cache invalidation, the map-geo lease, command admission, wire
versioning, and the geography caps. docs/LIVE_ACTIVITIES.md only ever covered
the Live Activity half, and these are the invariants a future contributor breaks
if nothing states them.

Also corrected: the small activity family is primarily the paired watch's Smart
Stack card rather than CarPlay, per the implementation's own comments; the
WakeLog devicectl command named dev.agessaman.meshmapper.watchkitapp where the
project builds net.meshmapper.app.watchkitapp; and the payload caps say they are
validated on receive, which no receive-side truncation does.
_buildWatchGeo took a single hex length from the first top row — or the RX
slot's when there were no top rows — and indexed the repeater catalogue at that
one width. The RX slot's path hash comes from a different ping than the top
rows', and the zone's hop-byte count is what sets the width, so whenever the two
disagreed the odd row out silently lost its name and its distance while every
other row resolved normally.

resolveUniqueHexPrefixes already indexes per distinct length with identical
uniqueness semantics, so this is a delegation rather than new logic. Removing
the length choice removes the branch that could get it wrong.
The phone drops a cue as soon as WatchConnectivity accepts the snapshot
carrying it, which happens while the watch is still suspended — so the retained
application context is the only copy that will ever exist. The watch then
discarded it outright past the 30 s freshness window: no haptic and no message.
A wearer who tapped Start, dropped their wrist and looked back a minute later
was never told why the session had not begun.

Freshness now decides how much to present rather than whether to present at
all. Inside 30 s the cue buzzes and shows its message as before; past it the
message still appears, because buzzing for something that old is wrong but
staying silent about it is worse.

The upper bound is staleAfter, the same 90 s boundary that greys the rest of the
screen. Past that the wearer is already being told the whole surface is old, and
a banner asserting a stale failure as current would be its own lie.
_availableWatchStartModes advertised Hybrid whenever the phone was connected and
TX was allowed, but ignored Offline Mode — where sessionStartAvailability
refuses every start with 'Offline Mode'. On a screen with room for two options,
one of them was permanently dead.

Moved beside its sibling resolvers as resolveAvailableWatchStartModes so the
policy is testable and so advertisement and admission cannot drift on this
again, which is the same reason the start gate itself is shared. Transient
guards deliberately stay out: a cooldown should not make an option vanish and
reappear.
… had

Every throttle assertion had to wait out the real 2 s interval, so the
refresh-dedupe group alone spent ~35 s of wall clock racing a live timer — a
race this suite has already lost once. The ten-minute map-geo lease and the
disconnect clear path could not be covered at all.

The debounce, the non-urgent interval and the lease window are now constructor
parameters defaulting to the wire's real values. The command-age window and
clock tolerance deliberately stay constants: they are compared against
timestamps a test already chooses freely, so shortening them would buy nothing
and would let a test pass against a window the wire does not use.

Full suite: 39 s to 8 s, and five tests longer. Five consecutive runs pass, as
do three under eight competing busy loops — the margins are no longer luck.

Newly covered, all previously unreachable:

- an unrenewed suppression lease expires back to full geography, and a renewal
  resets it
- a null snapshot clears native, does not clear twice, and does not dedupe the
  next real snapshot against state the watch no longer holds
- a burst of eight notifications coalesces into one build, which is the first
  of the five suppression gates and the only one nothing tested
The two devices do not share a clock and nothing makes them agree. Every
phone-stamped time the watch read, and every watch-stamped time the phone read
back, was compared across that gap with five seconds of slack — so a larger
disagreement did not degrade. It refused every timestamped command and marked
every payload stale, for as long as the skew lasted.

A live sendMessage crosses in milliseconds, so the phone's updatedAt on one of
those is effectively a reading of the phone's clock taken now. The watch learns
the offset from that and corrects by it: snapshot age, cue age, and — sent
alongside each command as the additive optional clockOffsetMs — the phone's
transmit-age window.

Learned only from live deliveries. An application context can sit retained for
hours, so its timestamp says nothing about the current offset.

Not folded into issuedAtMs, deliberately. That value doubles as the ordering key
for map-geo suppression claims, and rewriting it into phone time would make the
key jump backwards the first time an offset was learned — turning a monotonic
sequence into one where a newer claim can look older than the watermark and be
ignored for the rest of the session.

Absent offset means zero, so an older watch build and a watch that has not yet
seen a live delivery behave exactly as before. The five seconds now cover the
residual — transit and measurement error — rather than the skew itself, and a
genuinely stale command is still refused once the clocks agree.
Two things the review flagged, and they turn out to be the same thing.

The phone kept two didReceiveMessage overloads for commands sent by watch builds
predating the queued transport. No such build has ever shipped — the companion
app is landing for the first time with the queued path already in place, and
WatchSessionClient.send calls transferUserInfo exclusively. So they were a
second entry point into transmit admission serving nobody, and the part of the
system worth being strict about is exactly the one that reaches the radio.

The queued path is deliberate, not incidental: sendMessage can execute a command
and still fail its reply as undeliverable, leaving the wrist unable to tell a
refusal from a lost ack. Keeping a shim for the transport that has that flaw is
the wrong direction.

That removal also takes away the only place the redelivery ack was observable,
which is where the second half comes in. The bridge answered any redelivery with
accepted: true regardless of what it had recorded, so the redelivery of a
refused command described a transmit that never happened. Outcomes are now
recorded per ID and echoed, bounded exactly as the ID set was. Fixed rather than
left to rot with the path removed: a future reply channel would otherwise
inherit a wrong answer.

The NSNull sanitising added earlier in this branch goes with the reply path it
existed for — the remaining queued path discards the reply, so nothing can hand
NSNull to WCSession any more.
An iPhone below iOS 16.2 answered sync the same way as one whose owner had
switched Live Activities off, so it earned the same 30 s retry — a timer, a
build and a channel round trip every half minute for the whole session, on a
device that was never going to display anything. The OS version does not change
while the app is running; authorization does, which is why that one keeps its
backoff.

Native now answers "unsupported" for the version guard, and Dart stops
scheduling for the rest of the process. A MissingPluginException is the same
permanent condition — an iOS project generated before this feature existed has
no such channel and will not grow one — so it stops too, where it used to be
swallowed and retried.
The entry required paired to have been observed true at least once, which got
the purpose backwards: the screen explains why the phone is or is not talking to
a watch, so the case it most needs to be reachable in is the one where nothing
works. A session whose activation failed reports paired: false for a phone that
may well have a watch on the wrist, and the entry then stayed invisible for
exactly the wearer who needed it.

It now also appears when WatchConnectivity is supported but the session never
came up. That is not a claim a watch exists — only an admission that we cannot
say it does not. A healthy session reporting no watch still hides it, which is
the common case on an iPhone with no Apple Watch.

Moved beside the other resolvers so the rule is testable, and DEVELOPMENT.md
picks up the invariants this branch changed: the clock-offset correction, the
single command transport, the redelivery echo, per-length heard-row resolution,
and the three-way Live Activity host answer.
The heard page already renders names and the wire already carries them, so the
"Name unresolved — short path hash" rows were never a bandwidth problem. They
were a resolution problem: the phone resolved names from the truncated path hash
even when it was holding a far better identity for the same node.

A discovery response carries the responder's full 64-character public key.
_updateTopRepeaters mapped it to (repeaterId, snr) and dropped the pubkey on the
floor — the field whose own comment says it exists for exact repeater matching —
then resolved from two hex characters. A trace carries a 4-byte target, which
was deliberately shortened to three bytes to fit the overlay and then resolved
from the shortened form, throwing away a byte of certainty for no reason:
truncating is a presentation decision, not an identity one.

With one hop byte that hash is 2 characters over 256 values, so in a dense zone
most rows collided and resolveUniqueHexPrefixes correctly refused to guess. It
was refusing questions it did not have to be asked.

Identities now travel beside the overlay rows and are used first, falling back
to the existing prefix rules for the rows that never had anything better. TX
echoes and passive RX are unchanged — the packet genuinely carries only the path
byte, so their ambiguity is real and the refusal stays.

Zero additional bytes on the wire: name already existed on WatchHeardNode, and
all of this happens before the payload is built. The Live Activity and the
watch's status panel resolve through the same map, so they gain the same names.

Two invariants held deliberately. The map is replaced per ping and never merged,
because a hash that meant one repeater in a discovery response says nothing
about who a later TX echo under it was. And uniqueness is still required at
every step — a longer identity makes a collision vanishingly unlikely, not
impossible.
Substituting the discovery pubkey for the display hash in
_resolveRepeaterDisplayName broke the lookup that was actually doing the work.

The catalogue is matched three ways: short numeric ID, the 8-character hexId,
and displayHexId — the hex truncated to the zone's hop width, which is the exact
form an overlay row is labelled with. That last one is what resolved a
2-character path hash, and a 64-character public key cannot match it. So the
exact stage started coming up empty and every lookup fell through to prefix
matching.

Where it met the second half: Repeater.fromJson defaults hex_id to the empty
string because the API often omits it, and "starts with empty" is true of every
id. A single such entry in the catalogue made every prefix lookup ambiguous, so
the resolver refused, and repeaters that had been named for months went unnamed
on the Live Activity, the watch's status panel, and the trace target label.

Two fixes. The resolver now tries the fuller identity and falls back to the
display hash, so a better identity can only ever add a name — which is what I
should have written the first time; resolveOverlayRepeaters had exactly that
fallback and I did not carry it across. And a repeater with no hexId is no
longer a prefix candidate at all, which was a latent trap the old code could
also hit whenever its exact stage missed.

Extracted as WatchGeoBuilder.resolveRepeater so it has tests. The watch's heard
page deliberately keeps its stricter hexId-only rules and does not gain
short-numeric-ID matching, because a path hash and a database ID are different
identity domains that can collide by coincidence.
Both new embed phases sat ahead of the compile phases in Runner's buildPhases.
It built, because embedding depends on target dependencies rather than phase
order, but Xcode reorders it on the next unrelated edit — which lands the churn
in someone else's diff. This PR introduced the phases, so this is where the
ordering belongs.

Also formats one line of watch_bridge_service.dart. That file was format-clean
at the branch point and I made it dirty adding the clock-offset comment, and it
is a file this branch adds, so it has to land clean.
@agessaman

Copy link
Copy Markdown
Contributor Author

Okay, I think all of the issues above are handled and a couple more on top. There are some things that came up that I didn't touch.

Residual issues — all pre-existing, none from this branch

  • The format gate will still go red. 31 files on dev are dirty; every file this PR touches is clean. Separate cleanup, as the lead said.
  • Duplicate @override on notifyListeners() in app_state_provider.dart:9042. Present on dev, analyzer doesn't flag it.
  • Committed Podfile.lock is out of sync with what pod install produces. A clean checkout fails the Runner build until it's regenerated.
  • API repeater records often lack hex_id. This is the real reason some discovery-heard nodes stay unnamed in both the log screen and the watch heard page — neither can match a repeater with an empty hex. Worth deciding whether that's a server gap or something the client should derive.
  • repeater.id vs path-hash conflation in _resolveRepeaterDisplayName: a database id of 02 can coincidentally match a path hash of 02. Pre-existing; I deliberately did not propagate it into the heard page.

What's still unverified

  • The clock-skew fix has no hardware verification. It needs two devices whose clocks genuinely disagree, which I can't manufacture. Unit-tested, and the no-offset fallback is byte-identical to the old behavior.

The phone treats flood traffic as an existence policy. Send Ping and the
Active/Hybrid button are built inside `if (!txNotAllowed && floodTrafficVisible)`
in all three ping_controls layouts, so with flood off those controls do not
exist — and `floodTrafficEnabled` folds in the regional `flood_disabled` veto a
zone admin sets, which the wearer cannot override. Nothing else enforces it:
sendPing and toggleAutoPing never look.

The watch consulted none of that. resolveAvailableWatchStartModes advertised
Hybrid on isConnected/txAllowed/offlineMode alone, and _manualPingAvailability
copied only the inner half of the Send Ping gate, so _handleWatchCommand
admitted both a Hybrid start and a manual ping. The preference defaults off, so
this was the common configuration rather than an edge — and in the veto case the
wrist would originate traffic a zone admin had forbidden.

Same offer/admission drift as the Offline-Mode Hybrid bug, with an on-air
consequence. Fixed in the same shape: the advertisement withdraws Hybrid, and
resolveSessionStartAvailability refuses transmit starts with 'Flood Traffic Off'
alongside the other transmit-only policy checks, so Passive — which is not flood
traffic, and whose phone button sits outside the gate — is untouched.

manualPingApplicable gains it too, and that one is not incidental. It decides
Ping-versus-Stop ownership of the map toolbar's single corner, and it already
carries txAllowed because it follows stable configuration facts rather than
transient timing. Without flood in it, gating canManualPing alone would hand
that corner to a Ping that can never fire, on the one surface that shows no
reason — trading a safety bug for a dead Stop.

One reason string covers both the preference and the regional veto, because the
phone's own gate is the single effective value that already combines them and
the wearer's next step is the same either way: look at the phone, where Settings
distinguishes them.

The drift test now sweeps offline and flood together, so the advertisement and
the admission cannot diverge again on either.
A wrist Start or Ping that is admitted and then fails puts a one-shot cue on the
next snapshot. The phone dropped that cue as soon as the native sync returned
true — but true means updateApplicationContext accepted the blob, not that the
watch ingested it, and the wearer's wrist is usually down at exactly that
moment. Because the cue ID sits in the urgency key, the very next flush was
urgent and overwrote the retained application context with a cue-less payload,
often within a second. The watch woke to idle UI and no account of the failure:
the failure path had quietly deleted its own only evidence.

WatchSessionClient already has the path for this. Its `.read` presentation shows
the message without a haptic for cues too old to buzz for, up to the same 90 s
boundary that greys the rest of the screen, and its own comment names this race.
It could never run, because resume ingests whatever context is retained — by
then the second snapshot.

So bound the cue by age instead of by delivery. WatchWire.cueReadableFor mirrors
WatchSessionClient.staleAfter, WatchHapticCue.isPresentableAt applies it, and
_presentableWatchCue feeds both the snapshot builder and the cheap urgency-key
preflight — those two must agree about the cue or the preflight lies about
urgency. Re-attaching costs nothing and cannot double-buzz live redelivery: the
watch keys haptics on presentedCueIDs and drops the cue itself past the
boundary rather than asserting a dead failure as current.

Removes the onSnapshotDelivered plumbing rather than leaving it unused. A
callback named "delivered" whose actual meaning is "WatchConnectivity queued the
blob" is the trap that produced this, and leaving it in place invites the next
use. Its test now pins that the accepted send is recorded as an accepted send.
Two corrections to the last two commits.

`apply` notices a suppressed context arriving after the map came back and asks
for a full replacement, but asked with `sendMapGeoPreference(true, force: true)`.
`force` only defeats this client's own "already told the phone that" check. The
phone still holds its payload fingerprint, and by the time the out-of-order
context lands it has usually already sent full geography and cached it — so the
request dedupes into silence and the wrist keeps rendering empty pings and
repeaters. `refresh: true` is the flag that sets `forceRefresh` and makes the
phone answer an unchanged payload; `requestFullSnapshot` was the only caller
passing it.

Severity is bounded but the shape is wrong: during a session, geo changes at
roughly 2 Hz and the fingerprint breaks on its own within a second. Parked and
idle it does not, which is when a blank map is least explicable.

Also corrects a claim I introduced last commit. "Re-attaching cannot
double-buzz" is true of WatchConnectivity redelivery and false of a watch
process that relaunches: launch ingests the retained context against an empty
`presentedCueIDs`, so a cue still inside the 30 s haptic window fires again.
Holding the cue for 90 s instead of about a second widened that window, and the
trade is still obviously right — one duplicate buzz beats the silence it
replaced — but the document should not claim the case away. Closing it properly
means persisting presented IDs across launches, which is a separate change.

Not covered by CI: the watch target has no Swift tests and the workflow runs
`flutter test` on Ubuntu. The Swift edit is syntax-checked only.
An urgent update goes out twice — `sendMessage` and `updateApplicationContext`
— and nothing orders the two. `sendMessage` does not populate
`receivedApplicationContext`, so the retained context keeps whatever last
arrived down the slow path, which can be a payload this client already
superseded live. `apply` was last-writer-wins, and `resume` ingests that
retained context on every wrist raise.

That combination strands a wearer with nothing asking for better. The phone
sends P2 both ways; the watch applies P2 from the message; the wrist drops and
rises before context P2 lands; `resume` ingests the retained context and applies
P1 over it. P1 is under 90 s old, so `resume` sees no reason to spend the radio,
and the phone's caches already equal P2, so nothing further is sent. The wearer
reads P1 until something unrelated moves the urgency key.

So `apply` now refuses a payload older than what is rendered. Both stamps come
from the one phone clock and compare raw — `inLocalTime` is for phone-instant
against watch-now, which this is not. Equal stamps are accepted; a forced
refresh always restamps `updatedAt`, so it can never look like a replay of
itself.

The refusal lifts once the held snapshot is stale. That is the part worth
keeping: a bare timestamp comparison turns a phone clock stepping backwards into
a watch that refuses everything for the life of the process, where this bounds
it to the 90 s boundary and then recovers on its own. A stale surface is already
worse than a possibly-reordered one.

No wire `seq`. It would need a phone-process identity beside it — a counter
restarting at zero is indistinguishable from an ancient one — to buy behaviour
this already has, and that is a version conversation for no gain.

Falls out of the same guard: a live message that lost the ordering check no
longer teaches `clockOffset`. Its transit time is the measurement, and one that
arrives out of order has already broken the assumption the measurement rests on.

Not covered by CI: the watch target has no Swift tests and the workflow runs
`flutter test` on Ubuntu. Syntax-checked only; this wants a wrist.
Stop is exempt from the transmit-age window, and should be: a late stop takes
the radio off air, so refusing one leaves a session transmitting after the
wearer asked it to end. But the exemption quietly assumed sessions were
interchangeable, and the command carried nothing to say which one it meant.

So a Stop queued while the phone was out of range — or while the watch was
suspended mid transfer — could arrive after that session had ended and another
begun, and stop the wrong one. Silently: the wrist gets its acceptance, the
phone stops a session nobody asked it to, and the wearer finds out by noticing
that recording has halted. Of everything the wrist can get wrong, this is the
one that costs data rather than a confusing screen.

The command now carries the session id from the snapshot the wearer was looking
at when they tapped, and the phone refuses a stop whose target it has moved on
from. Attached inside `send` rather than by each call site, so no Stop button
can forget it.

Checked after the active-session gate, not before. With nothing running, a stop
stays the harmless no-op it has always been rather than becoming a refusal for
a session that is already over — the wearer asked for stopped, and stopped is
what they have.

Additive and optional on the wire, so no version bump: absent means an older
watch build, which is admitted exactly as it was before the field existed.
Refusing those instead would strand a wearer whose Stop button the phone had
silently stopped honouring, which is the failure this is meant to prevent.

Makes DEVELOPMENT.md's "lateness can only make refusing it worse" true. It was
not, and this is why.

The admission rule lives in the shared resolver and the wire key is asserted at
the bridge, both mutation-checked. The watch half is syntax-checked only.
A queued command can wake a phone app that was not running, and the native
relay invokes Dart the moment WatchConnectivity delivers it — which can be well
before attachCommandHandler runs, since that happens partway through the
provider's asynchronous initialization. transferUserInfo hands each command
over exactly once, so anything dropped in that window is simply gone: the wrist
shows its spinner, times out after ten seconds, and never says why.

Flutter already buffers platform messages sent before a handler exists, so this
was never total loss — but the default depth is one, so a wearer who tapped
twice lost the first tap. Measured rather than taken from the documentation:
the new test pushes three commands at the channel buffer before attaching a
handler and, without the reservation, exactly one arrives.

So reserve eight, at the top of main() before anything slow. That is well past
any plausible burst — there is one Start button and one Ping button — while
staying bounded, because these are intents that still face the age window and
the ID cache on arrival, not state to be replayed.

Uses the platform's own mechanism rather than a native queue and a ready
handshake. It narrows the window rather than closing it: a burst landing before
Dart's entrypoint executes at all is still governed by the default depth, since
resizing upward keeps what is already queued but cannot retroactively make room.
Closing that remainder means buffering natively, which is a lot of moving parts
for a window smaller than the one this covers.
The companion app shipped with no tests at all, and four commits on this branch
went in syntax-checked only. The cause was structural rather than neglect:
`WatchSessionClient` is `@Observable`, `@MainActor`, and reaches
`WCSession.default` through a computed property with no injection point, so
nothing inside it could be exercised without a paired watch. The rest of the
branch pushed testable logic into Dart, which worked — the wire contract, the
geo builder and the start gates all have real coverage — but that strategy
covers nothing only expressible in Swift, which is exactly where the last
several bugs were.

So the decisions move to `WatchWireRules` in the shared, Foundation-only
payload file: snapshot ordering, cue presentation, and the staleness origin.
The client keeps its observable state and its timers and now delegates. That
file was already compiled into both app targets, so this needed no project
change at all — worth having, given this branch has already had to correct
build-phase ordering once.

Tested by a SwiftPM package rather than an Xcode unit-test bundle. A watchOS
test target wants a host app, a simulator and a signing identity to test code
that depends on none of them; `swift test` runs these in milliseconds on any
toolchain. The package compiles the shipping source through a symlink, so there
is no copy to drift.

Sixteen tests, each mutation-checked: removing the ordering guard, removing its
stale escape hatch, collapsing the cue read window into the haptic window,
ignoring the clock offset, and dropping the arrival clamp are all caught.

One of them caught me rather than the code. I expected `reception` to age a
payload from `producedAt`; it ages from `producedAt` plus `clockTolerance`,
clamped to arrival. The slack is spent making a payload look *younger* so
ordinary skew cannot age a live one early — it is not a symmetric error bar,
and a snapshot reads up to five seconds fresher than it is. The test now says
so, because I had to learn it the slow way.

CI gains a macOS job running those tests and type-checking every watch source
against the watchOS SDK. The second half closes a gap worth naming on its own:
a Swift break in the companion app now fails a PR instead of Adam's next device
build. It type-checks rather than builds, so it still knows nothing about target
membership, embed phases, entitlements or signing — a file added to the folder
and never added to the target passes here and fails in Xcode.

Still uncovered, and documented as such: WatchConnectivity delivery, SwiftUI,
MapKit. The two-path delivery race in particular is reasoning plus a unit test
of the rule, not evidence from a wrist.

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

Happy with where this has gotten to, going to proceed with merging into dev

MrAlders0n added a commit that referenced this pull request Aug 20, 2026
CI's format gate only runs on pull requests, so unformatted code
accumulated on dev through direct pushes and failed PR #29's check.
Mechanical rewraps only, plus braces on four ifs the formatter split
onto two lines (keeps curly_braces_in_flow_control_structures quiet).
@MrAlders0n
MrAlders0n merged commit 88b0770 into MeshMapper:dev Aug 20, 2026
3 of 6 checks passed
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.

3 participants