Add Live Activity and watchOS support for mapping sessions - #29
Merged
Conversation
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.
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
What's still unverified
|
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
approved these changes
Aug 20, 2026
MrAlders0n
left a comment
Contributor
There was a problem hiding this comment.
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR adds two Apple companion surfaces to MeshMapper:
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:
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:
updatedAtmetadata does not itself defeat deduplicationExplicit 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
updatedAttimestamp 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
onAppearalone is insufficient for normal wrist-raise behavior.On resume the watch:
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
Testing
The branch adds coverage for:
CI now runs: