Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
104 commits
Select commit Hold shift + click to select a range
1059911
feat(ios): add Live Activity support for mapping sessions
Bjorkan Jul 14, 2026
2708c82
Fix unnecessary string interpolation braces in Live Activity title
agessaman Aug 12, 2026
2547c52
Add MeshMapperWatch watchOS app target
agessaman Aug 12, 2026
a9fff83
Add watch transport: snapshots down, commands up
agessaman Aug 13, 2026
b2bedbe
Add watch map page with pings, repeaters, and follow mode
agessaman Aug 13, 2026
51332e2
Add heard-node list in both placements, plus stale badge
agessaman Aug 13, 2026
73c6c03
Mirror the app's Top Heard overlay on the watch map
agessaman Aug 13, 2026
cef0e6b
Fit Top Heard on the smallest watch and drop the countdown into the c…
agessaman Aug 13, 2026
ce6af9a
Move the map overlay to a full-width bottom panel with a countdown bar
agessaman Aug 13, 2026
6866035
Overlay the countdown label on the bar and offset the camera for the …
agessaman Aug 13, 2026
5ed21ab
Size the countdown bar to the watch, and place the fix via the map proxy
agessaman Aug 13, 2026
25af775
Put both bar labels back on the track and fit the panel to the displa…
agessaman Aug 13, 2026
2fa0aef
Raise the panel 4 pt without spending the curvature clearance on width
agessaman Aug 13, 2026
f0961c1
Scale the panel gaps with the corner radius, and stop asserting lapse…
agessaman Aug 13, 2026
bbd01ca
Add wrist controls, and make every unavailable control say why
agessaman Aug 13, 2026
0385297
Open the map at 500 m, and remember the wearer's zoom
agessaman Aug 13, 2026
76d8a3b
Cut to the fix on launch, mirror the app's ping markers and ping gate…
agessaman Aug 13, 2026
ea78b4f
Single-source the manual-ping gate, and stop labels and errors misrep…
agessaman Aug 13, 2026
b82b409
Acknowledge admission, not completion, and give async failures a channel
agessaman Aug 13, 2026
b460b98
Send wrist commands over a queued transport, and expire them
agessaman Aug 13, 2026
e998227
Start the mode the region allows, and name it on the button
agessaman Aug 13, 2026
2768c79
Rebuild the Live Activity on the map overlay's vocabulary
agessaman Aug 13, 2026
224772d
Stop telegraphing a routine result: de-tint the bar and keyline
agessaman Aug 13, 2026
a6d7e2d
Give the watch map its discovery and trace pings
agessaman Aug 13, 2026
6a8ba00
Stop drawing a map at a wrist nobody is looking at
agessaman Aug 13, 2026
ad826e7
Key the drain to its deadline, not its caption; widen the panel to be…
agessaman Aug 13, 2026
ac862b7
Keep the fix still and move the world under it
agessaman Aug 13, 2026
e8027d6
Anchor the Live Activity countdown to the end of its bar
agessaman Aug 13, 2026
bce27b1
Stop rebuilding the watch payload twice a second, and fix repeater id…
agessaman Aug 13, 2026
6e1112b
Tell the truth about starting, idling, and going stale
agessaman Aug 13, 2026
10f2e12
Retry Live Activities within a session after they become available
agessaman Aug 13, 2026
aef4030
Offer the readout as a main page, not only when the wrist drops
agessaman Aug 13, 2026
001812a
Say the smallest true thing the dimmed countdown can
agessaman Aug 13, 2026
20bc491
Put the map/readout switch in the system's corner, not ours
agessaman Aug 13, 2026
5ad2c89
Hold the readout still when the wrist drops
agessaman Aug 13, 2026
00759dc
Take back the height Always-On returns, not just the position
agessaman Aug 13, 2026
48aa208
Start the map at 250 m, and stop MapKit overwriting it
agessaman Aug 13, 2026
590e5b7
Put session control in the watch's second corner
agessaman Aug 13, 2026
e1adacb
Make the controls page look like the rest of MeshMapper
agessaman Aug 14, 2026
3b9b895
Let the map page be a page: swipe navigates, crown zooms
agessaman Aug 14, 2026
4a461da
Stop the wrist from starting a session the phone would refuse
agessaman Aug 14, 2026
df145cf
Stop shipping map geography to a watch that isn't showing a map
agessaman Aug 14, 2026
c2b9735
Let MapKit know what covers the map instead of shifting the camera
agessaman Aug 14, 2026
13346e9
Flag the vestigial MapReader for a decision before the PR
agessaman Aug 14, 2026
336d047
Stop renaming every map marker when one new ping arrives
agessaman Aug 14, 2026
df0dcbd
Add a wrist switch for measuring the countdown drain
agessaman Aug 14, 2026
a1dbb2f
Stop a swipe off the map stranding a sheet over the next page
agessaman Aug 14, 2026
8dd93e2
Measure the panel's height, not where it is
agessaman Aug 14, 2026
8430f96
Stop the map camera resting at a continental span
agessaman Aug 14, 2026
591b9ed
Show why the phone is or is not talking to the watch
agessaman Aug 14, 2026
ebc09fb
Stop the puck lagging the pings beside it
agessaman Aug 14, 2026
71dd8dd
Keep the map on screen when the display dims
agessaman Aug 14, 2026
1d7bbe0
Stop the movement-gate tests racing the throttle
agessaman Aug 14, 2026
0b38315
Let the watch app install on watchOS 11
agessaman Aug 15, 2026
0e3916d
Stop a fast watch clock widening the transmit window
agessaman Aug 15, 2026
24370e4
Stop retained state reading as fresh on watch launch
agessaman Aug 15, 2026
41949ac
Clear out two pieces of development residue
agessaman Aug 15, 2026
87f6f1e
Run the tests in CI
agessaman Aug 15, 2026
5a2be4d
Answer a requested refresh even when nothing changed
agessaman Aug 15, 2026
2bfe0e2
Stop map-geo lease renewals forcing a snapshot
agessaman Aug 15, 2026
d9cf509
Reconcile on resume, not only on first appearance
agessaman Aug 15, 2026
6bce3ae
Stop both countdown bars animating against the system
agessaman Aug 16, 2026
50a157f
Stop the status panel dragging the camera when it resizes
agessaman Aug 16, 2026
675fa11
Let a Smart Stack tap reach the watch app
agessaman Aug 16, 2026
afe34cf
Let the wearer's text size reach the reason a control is dead
agessaman Aug 16, 2026
ac7f64f
Dim the held map, and hold it for twelve seconds instead of twenty
agessaman Aug 16, 2026
f4ca85f
Keep the delivery thread off rendered state, and let a failure be felt
agessaman Aug 16, 2026
6925b34
Walk the sample fix so a follow animation can be reviewed
agessaman Aug 16, 2026
d8f3bbe
Slide the map to a new fix instead of cutting to it
agessaman Aug 16, 2026
161a96c
Darken the held map to what a powerlog says it costs
agessaman Aug 16, 2026
8e8f421
Stop a millisecond of deadline drift bypassing the update throttle
agessaman Aug 16, 2026
31a95c9
Give the Smart Stack card the map panel's rows and none of its bar
agessaman Aug 18, 2026
f1d5fdf
Give the Smart Stack card one header line and bigger rows
agessaman Aug 19, 2026
82c2110
Give the watch target the version numbers it ships with
agessaman Aug 19, 2026
e7c2ce5
Format two of the three files the format gate flags
agessaman Aug 19, 2026
33bfc14
Stop the wrist starting Passive inside a manual ping's window
agessaman Aug 19, 2026
eb9ad93
Keep an early teardown from skipping the rest of dispose
agessaman Aug 19, 2026
7e59e11
Decide the Live Activity throttle before paying to build a snapshot
agessaman Aug 19, 2026
2b9b9bb
Let a late stop off the transmit-age window
agessaman Aug 19, 2026
ffb0bee
Pin the Passive start guards in the wire contract tests
agessaman Aug 19, 2026
8f66802
Fix three ways the native watch bridge stayed stuck or spent the radio
agessaman Aug 19, 2026
fe2c1e6
Stop a backgrounded session logging a failed Live Activity every 30 s
agessaman Aug 19, 2026
b299d61
Let resume see the context it just ingested, and read the wire versio…
agessaman Aug 19, 2026
28ba3d1
Take one reading of now for the ping cooldown countdown
agessaman Aug 19, 2026
26c2f8f
Close the two wire-contract gaps redelivery and renames slip through
agessaman Aug 19, 2026
2808dde
Write down the watch bridge's invariants, and correct three stale claims
agessaman Aug 19, 2026
7660f0e
Resolve heard-row names at every hash width, not just the first row's
agessaman Aug 19, 2026
0f35798
Let a wearer read a failure cue they were too slow to feel
agessaman Aug 19, 2026
a3eb306
Stop offering the wrist a Hybrid start that Offline Mode always refuses
agessaman Aug 19, 2026
44ee7fb
Give WatchBridgeService the interval seam LiveActivityService already…
agessaman Aug 19, 2026
bb35480
Let an unshared clock degrade instead of disabling the wrist
agessaman Aug 19, 2026
34a2f1d
Retire the legacy command transport, and make the ack tell the truth
agessaman Aug 19, 2026
a541b13
Stop asking a host that can never show a Live Activity
agessaman Aug 19, 2026
cc0a4c0
Show the watch diagnostics when nothing is working, not only when it is
agessaman Aug 19, 2026
c7e3af4
Name heard rows from the identity the ping carried, not the hash
agessaman Aug 19, 2026
6cdcd87
Restore the names my identity change took away
agessaman Aug 19, 2026
d1267e4
Put the embed phases where Xcode will leave them
agessaman Aug 19, 2026
d380620
Stop the wrist offering flood traffic the phone withholds
agessaman Aug 19, 2026
5ec38ef
Let a failure cue outlive the snapshot that carried it
agessaman Aug 19, 2026
11a7c19
Make map-geo recovery actually demand the geography back
agessaman Aug 19, 2026
0db8805
Stop a slow application context overwriting a newer live one
agessaman Aug 19, 2026
70b11cf
Name the session a queued Stop meant
agessaman Aug 19, 2026
4dd2426
Keep a wrist command that arrives before Dart is listening
agessaman Aug 19, 2026
5064e24
Put the watch's decisions somewhere a test can reach them
agessaman Aug 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,35 @@ jobs:
- run: dart run build_runner build --delete-conflicting-outputs
- run: dart format --output=none --set-exit-if-changed .
- run: flutter analyze
#- run: flutter test
# no tests yet, fails without ./test directory
- run: flutter test

watch-logic:
name: Watch Wire Rules
# The watch app's decision logic — snapshot ordering, cue presentation,
# staleness — decides what the wearer sees and was previously unreachable
# from CI: the analyze job runs on Linux and `flutter test` compiles no
# Swift. These are plain SwiftPM tests over Foundation-only sources, so
# they need no Xcode project, simulator or signing identity, and run in
# seconds. This does not cover WatchConnectivity, SwiftUI or MapKit; those
# still want a wrist.
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

- name: Unit-test the wire rules
run: swift test
working-directory: ios/WatchLogicTests

# Compiles every watch source against the real watchOS SDK without an
# Xcode project, a simulator or a signing identity, so a Swift break in
# the companion app fails a PR instead of Adam's next device build.
#
# It type-checks sources; it does not link, and it knows nothing about
# target membership. A file added to the folder but never added to the
# target still passes here and still fails to build in Xcode, as do
# embed-phase, entitlement and signing mistakes.
- name: Type-check the watch target
run: >
xcrun --sdk watchos swiftc -typecheck
-target arm64_32-apple-watchos11.0
ios/MeshMapperWatch/*.swift ios/Shared/MeshMapperWatchPayload.swift
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,6 @@ drive_route_map*.html
# Claude Code
.claude/
CLAUDE.md

# SwiftPM build products for the watch wire-rule tests
ios/WatchLogicTests/.build/
211 changes: 211 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,33 @@ flutter test

# Run a single test file
flutter test test/services/gps_service_test.dart

# Watch wire rules — snapshot ordering, cue presentation, staleness.
# Plain SwiftPM over Foundation-only sources: no Xcode project, no simulator,
# no signing. macOS only.
(cd ios/WatchLogicTests && swift test)

# Type-check every watch source against the watchOS SDK, without building
xcrun --sdk watchos swiftc -typecheck -target arm64_32-apple-watchos11.0 \
ios/MeshMapperWatch/*.swift ios/Shared/MeshMapperWatchPayload.swift
```

**What the watch is and is not covered by.** `WatchWireRules` in
`ios/Shared/MeshMapperWatchPayload.swift` holds the decisions that pick what the
wearer sees, deliberately kept Foundation-only and free of `WCSession` so they
can be tested at all — `WatchSessionClient` is `@Observable`, `@MainActor`, and
reaches `WCSession.default` through a computed property with no injection point.
Logic that belongs to the wire goes there; the client keeps observable state and
timers. `ios/WatchLogicTests` compiles the shipping file through a symlink, so
there is no copy to drift.

That covers the rules and, via the type-check, a Swift compile break. It does
**not** cover WatchConnectivity delivery, SwiftUI, MapKit, or anything about
target membership: a file added to `ios/MeshMapperWatch/` but never added to the
target type-checks here and still fails to build in Xcode, as do embed-phase,
entitlement and signing mistakes. Those still need a real build, and the
delivery races still need a wrist.

### Building for Release
```bash
# Use Build.sh — prompts for API key and signing passwords
Expand Down Expand Up @@ -403,6 +428,183 @@ fan-out logic (`MeshMapper_Server/dev/index.php`):
`repeaterCoverageCells`, `RepeaterStats.fromCoverageWithPoints`),
`lib/utils/coverage_tile_palette.dart` (`colorsForStatus`).

### Apple Companion Surfaces (Watch + Live Activity)

Both surfaces are **projections of phone-owned state**. The phone keeps the
MeshCore connection, the GPS fix, the session lifecycle, the transmit policy and
command admission; the wrist and the Live Activity render that state and send
intent back. Nothing on either surface may decide that a transmit is legal.

`docs/LIVE_ACTIVITIES.md` covers the ActivityKit half. The invariants below
belong to the watch bridge, and breaking one of them costs battery on two
devices or puts a packet on air from the wrong place.

**Delivery and suppression** (`lib/services/watch/watch_bridge_service.dart`)

Every gate runs before the next, and each exists for a different failure:

1. **Debounce** — 200 ms. Coalesces a burst of `notifyListeners()`.
2. **Urgency preflight** — the flush decides whether it may wait *before*
building anything. `WatchSnapshot.buildUrgencyKey` is a small scalar
projection (session, mode, phase, connection, control enablement, cue ID,
map-geo inclusion); if it hasn't moved, the 2 s floor applies and no
geography is constructed, sorted or encoded. `LiveActivityService` mirrors
this with `LiveActivitySnapshot.buildPreflightUrgencyKey` and a 15 s floor.
**Sustained per-tick work is the thing to avoid here** — the countdown
timers drive a flush at ~2 Hz for a whole session, and this app has a
wardriving overheat history.
3. **Payload fingerprint** — JSON of the payload minus `updatedAtMs`. Timestamp
metadata must never defeat dedupe; the watch renders countdowns from the
absolute `phaseEndsAt` deadline instead. An explicit `forceRefresh` is the
one thing that may send an identical payload again.
4. **Movement gate** — `WatchWire.minMoveMeters` (15 m). Expressed as "nothing
but the fix changed, and the fix didn't move far enough", measured against
the fix the watch last *received*. A refused or dropped send must not
consume the wearer's next 15 m.
5. **Send throttle** — the same 2 s floor, applied to delivery. A forced
refresh outlives a deferral rather than being dropped.

Urgent updates use `sendMessage` and *always* fall through to
`updateApplicationContext`, so a missed message can't strand the watch.

**The two paths are not ordered against each other, and the watch enforces
that.** `sendMessage` does not populate `receivedApplicationContext`, so the
retained context can hold a payload the watch already superseded live — and
`resume` ingests that context on every wrist raise. `WatchSessionClient.apply`
therefore refuses anything whose `updatedAt` predates what is already rendered.
Both stamps come from the one phone clock, so they compare raw. The refusal is
lifted once the held snapshot is stale, which bounds a backwards clock step to
90 seconds of refusal instead of the life of the process. There is deliberately
no wire `seq`: a counter restarting at zero is indistinguishable from an ancient
one without a process identity beside it, which is a version conversation for
behaviour this already has.

**Cache invalidation.** Dart's dedupe caches mirror native's `lastContextData`.
Native clears that only in `sessionWatchStateDidChange` and on `clear`, and says
so with `nativeCacheCleared` on the `availabilityChanged` push. Reachability
flips on every wrist raise and lower — treating those as invalidation forces a
full context resend per glance and voids the map-geo lease.

**Map-geo lease.** While the map isn't visible the watch asks the phone to omit
geography. The phone treats that as a *lease*, not a latch: suppression expires
after `_mapGeoClaimFreshFor` (10 min) back to full geography, and the watch
renews it every 5 minutes. A lost command therefore fails safe — toward sending
too much rather than a permanently blank map. Renewals are deduplicatable;
only a stated `forceRefresh` defeats the payload fingerprint.

**Command admission.** Wrist commands are intent, revalidated by the phone.
`transferUserInfo` is the *only* transport — there is deliberately no
`sendMessage` path into admission, because that can execute a command and still
fail its reply as undeliverable, leaving the wrist unable to tell a refusal from
a lost ack.

- IDs make WatchConnectivity's redelivery idempotent. A *queued* command refused
once stays remembered — redelivery after conditions change must never turn
yesterday's tap into a transmit. Only an untimestamped command forgets, since
it cannot be aged and its sender may legitimately retry.
- A redelivery is answered with the **outcome recorded the first time**, not a
blanket acceptance. Replying "accepted" to the redelivery of something the
bridge refused describes a transmit that never happened.
- Timestamped commands must land inside `_maximumCommandAge` (30 s), with
`_clockTolerance` (5 s) of slack in both directions.
- **The two devices do not share a clock, and that is a normal condition.**
`issuedAtMs` is stamped in the watch's clock and the command carries
`clockOffsetMs` beside it, so the phone measures a real elapsed age rather
than an age plus the skew. The watch learns that offset only from a live
`sendMessage`, whose transit is milliseconds; an application context may have
sat retained for hours and says nothing about the current offset. Absent
offset means zero, which is the old behaviour exactly. `_clockTolerance` now
covers the residual — transit and measurement error — not the skew itself.
The offset is *not* folded into `issuedAtMs`, because that value doubles as
the ordering key for map-geo suppression claims and rewriting it would make
the key jump backwards the first time an offset is learned.
- `requestSnapshot` and `stopSession` are exempt from the age window: one
transmits nothing, and the other takes the radio *off* air, so lateness can
only make refusing it worse. **A stop therefore names its session**, from the
snapshot the wearer was looking at when they tapped, and is refused if the
phone has since moved on. Without that the exemption assumed one session was
as good as another, and a stop queued against A could silently end B. The
field is optional: absent means an older watch build and is admitted as
before, because refusing those would strand a wearer whose Stop button the
phone had quietly stopped honouring.
- `resolveSessionStartAvailability` is the single start gate for both the
offered button and the admitted command. **Passive counts as transmitting** —
it sends a discovery request on start and every 30 s — so the manual-ping,
RX-window and cooldown guards apply to every mode. Only offline mode,
passive-only zones, flood traffic being off, and TX validation are
transmit-only.
- **Flood traffic is an existence policy, not a preference.** The phone builds
Send Ping and the Active/Hybrid button inside
`if (!txNotAllowed && floodTrafficVisible)`, so with flood off those controls
do not exist — and `floodTrafficEnabled` folds in the regional
`flood_disabled` veto a zone admin sets. It gates the wrist on both sides:
`resolveAvailableWatchStartModes` withdraws Hybrid, and
`resolveSessionStartAvailability` plus `_manualPingAvailability` refuse with
'Flood Traffic Off'. The preference **defaults off**, so a wrist that skips
this admits the common configuration rather than an edge one.

**Failure cues.** A one-shot cue rides *every* snapshot until it is older than
`WatchWire.cueReadableFor` (90 s), which mirrors `WatchSessionClient.staleAfter`.
It is deliberately **not** dropped when native accepts a payload carrying it:
that reply means `updateApplicationContext` took the blob, not that the watch
ingested it, and the wearer's wrist is usually down at that moment. Because the
cue ID is in the urgency key, dropping it there made the very next flush urgent
and overwrote the retained context with a cue-less payload — so a suspended
watch woke to idle UI and no account of the failure. Re-attaching is free: the
watch keys haptics on `presentedCueIDs` and drops the cue itself past the
boundary rather than asserting a dead failure as current.

`presentedCueIDs` is process-local, so that de-duplication covers WatchConnectivity
redelivery but **not** a watch process that dies and relaunches. Launch ingests
the retained context, and a cue still inside `cueFreshFor` (30 s) buzzes again
against an empty set. Widening the attachment window from about a second to 90 s
widened that case with it — deliberately. One duplicate haptic after a relaunch
is a far smaller failure than the silence it replaced, and closing it properly
means persisting presented IDs across launches for a payload the watch is
already re-reading on purpose.

**Wire versioning** (`WatchWire.version`, mirrored in
`ios/Shared/MeshMapperWatchPayload.swift`)

Bump only when a field **changes meaning or is removed**. Additive optional
fields must not bump it: a bump strands compatible pairs, and older peers are
required to default absent fields safely. The watch reads `wireVersion` with a
minimal probe struct *before* attempting the full decode, so a payload that a
future breaking change makes undecodable still reaches the "update the iPhone
app" prompt instead of going silently stale.

**Geography caps** — `maxPings` 60, `maxRepeaters` 20, `maxHeard` 4. Applied by
`WatchGeoBuilder` on the sending side, after merging every source and sorting by
recency, so a busy TX history cannot erase discovery or trace markers.

**Heard-row names** resolve from the fullest identity each row arrived with,
via `WatchGeoBuilder.resolveOverlayRepeaters`. A path hash is 1–3 bytes and in a
busy zone routinely matches several repeaters, where naming one would be a coin
flip — but the phone often knows exactly who answered: a discovery response
carries the responder's full 64-character public key, and a trace carries its
4-byte target. Those identities travel beside the overlay rows in
`_overlayIdentityById`, **replaced wholesale 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 the same hash was.

TX echoes and passive RX carry only the path byte, so they fall back to prefix
matching and keep refusing to guess. That fallback indexes per *distinct*
prefix length: the RX slot's hash can be a different width than the top rows',
so a single-length index silently drops the odd row's name and distance.

Uniqueness is required at every step. A longer identity makes a collision
vanishingly unlikely, not impossible, and a confidently wrong name stays worse
than none. **Never resolve names on the watch** — it holds only the nearest 20
repeaters, so it would name rows the phone refused as ambiguous across the full
catalogue.

**Live Activity host support.** ActivityKit answers `sync` with `true`, `false`,
or `"unsupported"`. `false` means *not right now* — authorization is off, or
`Activity.request` was refused because the app is backgrounded — and earns the
30 s backoff. `"unsupported"` (and a `MissingPluginException`) means this host
can never show one, and Dart stops asking for the rest of the process rather
than running a guaranteed-fail retry loop all session.

### BLE Service UUIDs (MeshCore Companion Protocol)
- Service: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E`
- RX Characteristic: `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` (write to device)
Expand Down Expand Up @@ -546,6 +748,8 @@ debugError('[API] Failed to post batch: $error');
| `[OFFLINE]` | Offline mode operations |
| `[SCAN]` | BLE device scanning |
| `[WAKELOCK]` | Wake lock acquisition/release |
| `[WATCH]` | WatchConnectivity bridge: availability, snapshot delivery, wrist commands |
| `[LIVE ACTIVITY]` | ActivityKit bridge: sync, end, and authorization failures |

Never log without a tag.

Expand Down Expand Up @@ -671,6 +875,13 @@ All API endpoints may return maintenance mode:
- `lib/services/debug_submit_service.dart` - Bug report submission (4-step workflow)
- `lib/services/gps_simulator_service.dart` - GPS simulation for testing
- `lib/services/wakelock_service.dart` - Screen wake lock during auto-ping
- `lib/services/watch/watch_bridge_service.dart` - WatchConnectivity transport: throttle, dedupe, movement gate, map-geo lease, command admission
- `lib/services/watch/watch_models.dart` - Watch wire contract and shared start-admission resolver
- `lib/services/watch/watch_geo_builder.dart` - Ping/repeater/heard geography for the wrist, with wire caps
- `lib/services/watch/watch_color.dart` - Wire colour projection shared with the phone map
- `lib/services/live_activity/live_activity_service.dart` - ActivityKit bridge: preflight urgency, throttle, dedupe, unavailable backoff
- `lib/services/live_activity/live_activity_models.dart` - Live Activity snapshot model and urgency keys
- `lib/screens/watch_diagnostics_screen.dart` - Watch transport diagnostics (Settings)
- `lib/services/meshcore/packet_validator.dart` - Packet validation and carpeater filtering
- `lib/models/noise_floor_session.dart` - Noise floor session data models
- `lib/widgets/noise_floor_chart.dart` - Noise floor graph visualization
Expand Down
29 changes: 29 additions & 0 deletions docs/LIVE_ACTIVITIES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# iOS Live Activities

MeshMapper starts one read-only Live Activity while an automatic wardriving session or manual ping cycle is active. It mirrors the same state used by the in-app controls.

## Displayed information

- Current mode and phase (`Sending`, `Listening`, `Next ping`, `Cooldown`, GPS/zone/reconnect states)
- System-rendered countdowns based on absolute timer deadlines
- The strongest repeaters from the current or latest completed cycle, sorted by SNR
- TX/RX counters, upload queue, zone, connection state, and stale-update warnings
- Lock Screen, Dynamic Island, and an iOS 18 small-family layout — primarily the paired watch's Smart Stack card, and CarPlay on supported systems

## Architecture

- Dart builds a compact `LiveActivitySnapshot` from `AppStateProvider`.
- `LiveActivityService` deduplicates and throttles noncritical updates before sending them over `meshmapper/live_activity`.
- `LiveActivityManager` creates, updates, deduplicates, and ends the native ActivityKit activity.
- `MeshMapperLiveActivityExtension` renders the snapshot using SwiftUI and WidgetKit.

No push server or App Group is required. The Live Activity does not replace the existing BLE/location background execution; it only presents its state.

## Platform requirements

- Live Activity: iOS 16.2 or later
- Small supplemental activity family (watch Smart Stack): iOS 18 or later
- CarPlay presentation of Live Activities: iOS 26 or later
- A physical iPhone is recommended for final background, Dynamic Island, and CarPlay validation; the Smart Stack card needs a paired watch

The main Runner target keeps its existing deployment target. Unsupported devices skip ActivityKit creation.
1 change: 1 addition & 0 deletions ios/Flutter/LiveActivity.xcconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#include "Generated.xcconfig"
1 change: 1 addition & 0 deletions ios/Flutter/Watch.xcconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#include "Generated.xcconfig"
29 changes: 29 additions & 0 deletions ios/MeshMapperLiveActivity/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>MeshMapper</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
Loading
Loading