Skip to content

Add an Android Auto Surface - #31

Open
NicholasFeldman wants to merge 373 commits into
MeshMapper:devfrom
NicholasFeldman:feat/android-auto
Open

NicholasFeldman wants to merge 373 commits into
MeshMapper:devfrom
NicholasFeldman:feat/android-auto

Conversation

@NicholasFeldman

Copy link
Copy Markdown

This PR adds an android auto surface, bringing in flutter_carplay as a dependency.

Testing must be done with Desktop Head Unit (https://developer.android.com/training/cars/testing/dhu), and can not be used against a physical head unit.

This was created as a POI App (https://developer.android.com/training/cars).
See here for distribution requirements (developer.android.com/training/cars/distribute).

Claude Code was used for the integration of flutter_carplay, some dart code, test generation, and self review.

MrAlders0n added 30 commits May 20, 2026 23:22
… actually renders on shorter lines instead of being hidden or unreadable
…ead of showing as anonymous RX observations. Log tab, noise floor graph, and map popup show "Direct Repeats" and "Multi-hop Repeats" sections. TX markers are green (direct echo), RX-colored (multi-hop only), or red (no response).
…nd every ping is tagged with it. You can now filter region coverage by radio preset.
- TX pings now send a short opaque tag (e.g. "MM:zpCFQwc") on the #wardriving channel instead of your GPS coordinates. Your location still reaches the server over the API, it's just no longer readable off the air by anyone with the channel key.

- New "Broadcast My Coordinates" toggle (Settings -> Ping Settings, off by default) lets you opt back into putting your real coordinates on the air, now sent as a compact "MM:lat,lon".

- Very long sessions now end cleanly: when the per-session ping limit is reached the app uploads any pending pings, then disconnects with "Reached session limit, please reconnect."
…oJson tile(hack cause we cant invalidate a single tile with LibreMaps)
…heat fix)

Root cause of phones overheating during wardriving: the MapLibre MapWidget
(the most expensive subtree) did context.watch<AppStateProvider>() and so
rebuilt on EVERY one of the provider's ~166 notifyListeners() calls — including
high-frequency UI-only notifies (noise floor every 5s, battery, live stats) and
the dense-mesh passive-RX pin storm (10-20x/sec). This kept the CPU/GPU pinned.
Present in the weeks-old build too, which matched the reports.

Fix (Tier B):
- AppStateProvider gains `mapRevision` plus `_notifyMapNow()` (immediate) and
  `_notifyMapThrottled()` (250ms leading+trailing coalescing). Only map-relevant
  mutations (TX/RX/disc/trace markers, echoes, GPS position, repeater load,
  history view, marker/log clears, marker-style prefs) bump mapRevision; the
  RX-pin/echo storm goes through the throttle (~4/sec cap). UI-only notifies
  (noise floor, battery, stats) leave mapRevision untouched.
- MapWidget is wrapped in a Selector keyed on (mapRevision, focus, history,
  padding, controls) in home_screen.dart, so it is cached across all UI-only
  notifies, and its top-level subscription switches from context.watch to read.

Result: the map rebuilds only when map data/layout actually changes, at a
bounded rate, instead of continuously.

Investigated and found NOT to be causes (no change):
- 500ms countdown timer: already isolated into its own ChangeNotifier.
- graph_screen 2s live timer: it's a pushed MaterialPageRoute (disposed on pop),
  not an offscreen IndexedStack child — no continuous offscreen cost.
- _saveLastPosition: already throttled to 30s; checkDistanceTriggers gates at 25m.
- _computeMarkerDataVersion heard/discovered sums: they drive marker color
  transitions, so they must stay; Tier B makes the O(N) loop run rarely.

flutter analyze: clean. flutter test: 39/39 pass.
Add the "Map Rebuild Isolation" section to DEVELOPMENT.md and CLAUDE.md, and a
new Critical Rule 9 in CLAUDE.md: map-rendered state must bump mapRevision via
_notifyMapNow()/_notifyMapThrottled() (not plain notifyListeners()) so the map,
now isolated behind a Selector + context.read, still updates.
…e pump)

The Active/Hybrid/Passive mode buttons ran a repeating 1200ms pulse
AnimationController (_pulseController.repeat) the entire time a mode was
running. A repeating AnimationController keeps Flutter's vsync ticker alive, so
the GPU/compositor never idled — on a 120Hz device the app rendered ~120fps for
the whole wardriving session for a purely cosmetic breathing glow, even parked.

Profile-mode measurement (SchedulerBinding.addTimingsCallback FPS logger):
during active Hybrid mode the app sat at ~113-129 fps continuously; with the
pulse removed it drops to ~25-28 fps (renders only when something actually
changes). The button still reads as active via color, the indicator dot, the
"Active" text, and the live countdown — only the animated breathing is gone.

Removes the AnimationController / AnimatedBuilder from _ActionButton,
_LandscapeIconButton, and _CompactActionButton; the active-state background
opacity is now a static value.

flutter analyze: clean. flutter test: 39/39 pass.
The MapWidget's Selector was being silently defeated on every HomeScreen
rebuild. HomeScreen.build() uses context.watch, so it rebuilds on every
notifyListeners() — including the ~2Hz GPS notify. Each rebuild constructed a
fresh inline Selector instance, and provider's Selector invalidates its cache
whenever `oldWidget != widget` (selector.dart:77), so the cached MapWidget was
rebuilt BEFORE the value comparison ran — relayouting the iOS platform view
(~24ms) on every GPS position update.

Measured on-device (iPhone, profile mode): GPS-driven map builds dropped from
2-3x/sec @ ~24ms to 0/sec; per-frame build time fell ~24ms -> ~1ms while the
camera still follows in real time.

Two changes:
- Decouple GPS from mapRevision: the GPS listener now calls plain
  notifyListeners(); camera-follow, derived heading and the GPS puck are driven
  from a direct provider listener (_onPositionNotify -> _handleGpsPosition) via
  the native controller (animateCamera / updateSymbol), preserving the
  real-time nav feel with no widget rebuild.
- Memoize the map Selector (home_screen _buildMapSelector) so its widget
  identity is stable across HomeScreen's per-notify rebuilds, letting the
  Selector's value comparison actually gate the map.
…ution for cell MAX-DIST

Brings the web's coverage "Cell Click" (GRID SUMMARY: per-status counts, AVG
SNR/NOISE, MAX DIST, proportional bar graph) and "Repeater Click" (online
status, fingerprint, hop bytes, schedule + clock-skew warning, first heard, max
range, BIDIR/TX/RX/DISC/DEAD totals) into the app, with client-side aggregation
mirroring the web. Coverage is fetched lazily via the key-gated app_coverage.php
proxy.

Cell MAX-DIST (GridSummary) resolves tokens via _repForToken (full-hex /
narrowCandidates) instead of a bare id lookup, so it stays correct as the server
normalizes coverage path tokens to a wider-than-id canonical hex.

- lib/utils/coverage_summary.dart: GridSummary / RepeaterStats / RepeaterLookup
  / GridCell; _repForToken full-hex resolver.
- lib/utils/repeater_format.dart, distance_formatter.dart: date / clock-skew /
  coverage-distance formatting.
- lib/widgets/cell_summary_sheet.dart, map_widget.dart: cell-tap + repeater
  detail sheets.
- lib/services/api_service.dart, providers/app_state_provider.dart: coverage
  fetchers. lib/models/repeater.dart: time_offset.
- tests in test/utils/.
The multi-byte path warning told users the radio would stay in multi-byte mode until they change it. That's wrong: _restorePathHashMode() restores the original setting on a clean disconnect (unless the user changed it themselves). Reword to say the app restores it on a normal disconnect and warn that an unclean drop needs a manual revert in Settings.
Tapping a coverage cell in Detailed mode now (a) fetches and keeps the pings whose 3x3 blob colours the tapped cell, matching the web's lazyShowPingsAt (GridCell.filterWithinBlob / blobFetchRadiusMeters, used by _showCellSummary), and (b) draws an outline-only 3x3 highlight block centred on the tapped tile (GridCell.blockRing + a self-healing line-layer overlay, cleared when the summary sheet closes). Simplified mode collapses to the single tapped cell. Adds GridCell unit tests.
With a session and 'Broadcast My Coordinates' on, the on-air body is now MM:<tag>:lat,lon (keyed tag plus plaintext coords) instead of coords-only. The bare wire tag still goes to the API (txWireTag), so /wardrive validation and tx_pings are unchanged, and the 11-bit session-limit counter guard now applies to both privacy and broadcast modes. No session yet still falls back to plaintext MM:lat,lon.
Mobile offline-session download was a 'coming soon' stub (web already worked). Add AppStateProvider.shareOfflineSession: write the session's pretty JSON to a temp file and open the OS share sheet (Save to Files, Drive, email, ...) via share_plus, mirroring shareDebugLog. settings_screen's download handler now calls it on mobile; the web blob-download path is unchanged. Downloads work before upload and after a failed upload (those sessions stay listed).
setMapStyle and setColorVisionType called plain notifyListeners(), which does not bump mapRevision — so the MapWidget (isolated behind the mapRevision Selector, Critical Rule 9) never rebuilt and the new style/palette never reached the native map. Switch both to _notifyMapNow() so the change actually applies.
…h event)

The map re-pushed every accumulated TX/RX/DISC/trace marker to the native
layer on every ping event — hundreds of awaited platform round-trips per
event late in a session — so marker/ping display lag grew with session
length and the coverage tile often repainted before its ping marker showed.
Markers now skip the native update when their icon/size is unchanged, bounding
per-event work to actually-changed pins. Adds a [MAP] Coverage sync diag line.
…e server to accept the upload session before sending, and never discards pings it couldn't upload (they're kept and retried instead of being lost as a "partial upload").
…at record time for parity with live uploads — model was previously sent as the generic 'Offline Upload' and power/version were derived at upload time instead of when the pings were recorded. Snapshotted fields are sent on upload auth with fallbacks for legacy sessions.
…coordinates

All 10 TestFlight crash reports share one root cause: an invalid GPS or stored
coordinate (NaN / infinite / out-of-range) reached MapLibre's camera, whose
native LatLng constructor throws std::domain_error — uncaught across the
C++/Obj-C boundary, so it aborts the app (SIGABRT). iOS can briefly report an
invalid CLLocation right after the app resumes from background (the "brought it
back up and it crashed" reports), and a corrupted/stale lastKnownPosition from
Hive explains the "instacrash on launch" reports. The prior _cameraAnimationReady
one-frame delay only addressed GL-surface timing, not the invalid input.

Two layers of defense:

- Layer A (crash-stopper): validate every coordinate before it reaches the map
  camera. New shared isValidLatLng() guards the animate helpers, the three center
  builders feeding initialCameraPosition + style-load zoom, the focus/history
  fit-bounds (invalid points filtered), and the cluster zoom-ins in map_widget,
  plus the offline_maps_screen center.

- Layer B (stop the bad data at the source): drop invalid fixes at the GPS stream,
  simulator, getCurrentPosition and getFreshPosition; ignore invalid stored
  last-known-position on load and never persist invalid coords. This also keeps
  invalid coordinates out of the API upload payloads.

Adds lib/utils/geo_validation.dart + unit test. flutter analyze clean; full
suite green (72 tests).
…kdrop (web parity)

Replaces the cyan outline box around a tapped cell's 3x3 block with the web
front end's spot-click look (highlightSpotCoverage): the block fills as a grid
of cells in one dominant colour and the coverage backdrop dims to 0.15 so the
footprint pops, restored when the summary sheet closes (honouring ping-focus).

The dominant colour is the highest-priority status (green > cyan > orange >
purple > grey > red) among only the pings whose blob covers the tapped cell, so
a red-dominant block repaints green-looking neighbours that were coloured by a
ping outside the blob (the intentional smear). Applies in both grid modes (3x3
Detailed, single cell Simplified). Highlight + dim appear with the fetched
pings, driven off the same fetch the summary sheet already uses (no extra
network call).
…ze button

Both tap popups now match the ping-focus sheets: a transparent modal barrier so
the map and markers stay fully bright (the tile footprint's 3x3 highlight + 0.15
coverage dim still pop), plus a minimize button that collapses the sheet to a
tappable bottom pill, leaving the map interactive.

Adds a generic _MinimizedInfoPopup + pill (kept separate from focus state),
extracts _presentCellSummarySheet for reuse on reshow, and threads an optional
onMinimize into CellSummarySheet. Opening a new popup supersedes a minimized
one (a minimized cell's footprint is cleaned up when superseded by a repeater;
cell-to-cell keeps the old footprint until the new data lands, so no flash).
The minimize pill sat underneath the ping control panel. Mirror focus mode's
mechanism: add an AppStateProvider.infoPopupMinimized flag and OR it into the
same control-panel visibility conditions + map bottom-padding calc in
home_screen that isFocusModeActive/viewingHistorySession already use. The pill
set/clear is centralized in _setMinimizedInfoPopup/_clearMinimizedInfoPopup so
the flag stays in lockstep across every show/reshow/close/supersede path.
MrAlders0n and others added 28 commits August 25, 2026 16:10
App Shortcuts are indexed from the app, so an AppShortcutsProvider that
lives in the App Intents extension is never registered: its phrases
resolved to nothing when spoken, with no error to explain it. Status,
Recent Repeaters and Find Repeater were all declared that way.

Merge the two providers into one MeshMapperAppShortcuts in Runner and
give the read intents and their entities dual target membership, which is
Apple's arrangement for an intent that backs an App Shortcut and must
also run in an extension. Reads still execute in the extension and never
launch Flutter.

Withdraw the Find Repeater phrase for now. Spoken lookup still failed on
device, and a registered phrase that fails is worse than one that was
never offered. The intent, entity and query stay built and usable from
the Shortcuts app, with a TODO naming the two suspects worth checking.
Implement iOS App Intents and enhance Siri shortcuts functionality

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants