Skip to content

Release: dev → main up to 222a300 (iOS donation-link App Store fix)#27

Merged
MrAlders0n merged 104 commits into
mainfrom
release/222a300
Jun 30, 2026
Merged

Release: dev → main up to 222a300 (iOS donation-link App Store fix)#27
MrAlders0n merged 104 commits into
mainfrom
release/222a300

Conversation

@MrAlders0n

Copy link
Copy Markdown
Contributor

Merges dev into main up to commit 222a300 ("Hide donation link on iOS to satisfy App Store guideline 3.1.1").

Range: main (582f175) → release/222a300104 commits. main is the merge-base, so this is a clean fast-forwardable integration with no divergence.

Deliberately excluded (the two dev commits newer than 222a300, held back for a later cut):

  • eb74c0e Fix landscape nav/connect layout, 3-byte ID clipping, hybrid bubble, boot notification
  • 5e36b0f Fix repeater search relevance and surface RX-no-GPS silence

Highlights

Location privacy / TX wire-tag

  • Your location is no longer broadcast over the air — TX pings send a short keyed token; coordinates travel only via the API.
  • Keyed Feistel wire-tag codec (byte-identical to the server's PHP encoder), with session date encoded to fix cross-day collisions (the "DEAD tile" bug).
  • Optional "Broadcast My Coordinates" mode sends tag + plaintext coords on-air; ping counter resumes from /auth on session reuse.

New transports

  • TCP and USB Serial (USB OTG / Web Serial) companion connections alongside BLE.
  • TCP health check after app resume; Connect screen remembers the last-used transport.

Maps & coverage

  • Vendored, patched maplibre_gl 0.25.0 with a native camera-viewport crash guard (SIGABRT on degenerate viewport).
  • Vector-tile coverage overlay with client-side palettes (incl. colour-vision modes) and Grid Mode (Simplified/Detailed).
  • Coverage tap-to-inspect: connection lines + per-repeater coverage cells, post-wardrive live tile refresh.
  • Offline map download, cancellation/queueing, cache management, and storage safeguards.

Performance / wardrive overheat

  • Map no longer rebuilds on every notifyListeners() or relayouts on every GPS tick (mapRevision isolation architecture).
  • Static mode-button glow, incremental coverage-marker sync, fewer countdown-timer full-tree rebuilds.

iOS / Android stability

  • Fixed iOS launch & resume-from-background crashes (non-finite zoom / NaN projection → std::domain_error).
  • Hardened Hive preferences against being wiped by transient open failures.
  • Hide donation link on iOS to satisfy App Store guideline 3.1.1 (the commit this PR is cut at).

Offline sessions

  • Capture device model, power level, and app version at record time; per-region placement summary after upload.
  • Fixed sessions getting stuck at "partial upload" and never discarding un-uploaded pings.

Other notable

  • Radio preset reported to the server and tagged on every ping (filter coverage by preset).
  • Flood Traffic toggle with regional-admin enforcement; stale-repeater filtering.
  • Repeater marker clustering + spiderfy; RX hop-path display; Session History map view.

Verification

Run flutter analyze and the test suite on release/222a300 before merge (see test/services/wire_tag_codec_test.dart for the wire-tag canonical vectors).

MrAlders0n and others added 30 commits April 11, 2026 14:48
…rent overlay refreshes and syncs, improving performance and stability during rapid state changes.
format using `dart format .`

manual add missing curly braces in control flow statements with dartls

adds check in ci to confirm formatting and remove tests and there are none

# Conflicts:
#	lib/models/user_preferences.dart
#	lib/widgets/map_widget.dart
comment out unused _blankStyleJson URL
…safeguards

- Add "Cancel" button to in-progress download card; discards partial tiles
  and frees the slot for queued downloads
- Queue multiple offline map downloads; starting a second download while
  one is running enqueues it, and the next begins automatically when the
  active download finishes or is cancelled. Progress card shows pending count
- Check device free space before starting; reject downloads up front if
  less than 1.5x the estimated region size is available
- Label storage usage on Offline Maps screen as an estimate (derived from
  tile-count heuristics) rather than presenting it as an exact figure
- Surface real error causes from the native layer (network, style, IO)
  instead of the generic "Download error occurred" message
- Cap offline zoom slider at z15 (was z18) and set default max zoom to z14;
  OpenFreeMap vector tiles max out at z14, so higher zooms produced duplicate
  overzoom tiles and could accidentally explode storage usage
- Reject offline regions that cross the antimeridian with a clear message
  instead of silently producing a 357-degree-wide region
- Fix race where the offline download dialog could close before validation
  errors were shown to the user
- Bump version to 1.3.0 in .build_version.
- Modify Build.sh to include export-options-plist for iOS IPA builds.
- Enhance MainActivity.kt to support fetching per-region downloaded byte counts.
- Update AppDelegate.swift to handle region sizes and improve cache management.
- Refactor offline_maps_screen.dart to refresh region sizes and update UI accordingly.
- Adjust settings_screen.dart to reflect changes in terminology from "regions" to "areas".
- Improve offline_map_service.dart to track actual bytes used by downloaded areas.
- Add tile_cache_service.dart method to retrieve per-region sizes from the native SDK.
- Modify map_widget.dart to manage coverage overlay based on tile availability.
- Create ExportOptions.plist for iOS export settings.
…F). When OFF, Send Ping, Active, and Hybrid controls are hidden across all three ping-control variants (standard, compact, landscape). Passive and Trace stay visible.

- MeshMapper now parses `flood_disabled` from the auth response and auto-syncs the toggle on connect and zone change. If the region permits flooding, the toggle flips ON. If the region forbids it, the toggle is forced OFF, greyed out, with an amber "Set by Regional Admin" subtitle.
- One-shot popup when a user-enabled flood toggle gets overridden by regional policy on auth or zone change, explaining that Active and Hybrid modes have been disabled for this session.
… startup theme loader was treating a non-fatal open timeout as corruption and deleting the

 entire user_preferences box, taking every saved setting with it. The early-startup deletion is gone — real corruption is still caught later by
 AppStateProvider._attemptHiveRecovery with a user-visible error.
 - Force a box.flush() after every preference write (general preferences, device antenna preferences, device power overrides, audio settings) so settings are
 persisted to disk immediately instead of relying on Hive's lazy flush — prevents loss on crash or abrupt termination.
…s later by a MapLibre camera animation error. Camera animations now suspend while the app is in the background.
…e (or near-same) GPS coordinates now collapse into a cluster bubble with a count instead of rendering as an overlapping pile with illegible labels. Clustering runs at every zoom level (was previously dropped past zoom 14), so a stack stays as a single tappable target even fully zoomed in.

- **Spiderfy on Tap:** Tapping a cluster at max zoom fans the members out in a ring (or spiral past 20 markers) connected by thin leader lines, each independently tappable and opening the repeater detail sheet. Below max zoom, taps zoom in further first so you can separate the stack visually.
- Spider auto-collapses when you tap an empty area, tap a different cluster, or zoom past a 0.25-level delta. Panning keeps the spread open since leader lines live in geographic coordinates and follow the map naturally. On iOS, spread markers covered by the GPS dot are now tappable as well.
…reen entirely in heading mode) when the bottom controls panel is expanded with auto-follow enabled. The centring offset was using a 256px tile assumption while MapLibre projects onto a 512px tile grid, so every padding-aware shift was 2x too large.
…the packet travelled (origin → ... → us) as a tappable chain of repeater chips, with the receiving repeater tagged "(heard)". Each hop chip opens the standard repeater info popup so you can resolve names and distances along the path without leaving the sheet. The same chain renders on every RX entry in the Log tab. If your own carpeater was on the tail, it's stripped from the chain just like it's stripped from the displayed last-hop ID. CSV export gains a `path_hops` column on RX rows. Path data is transient and lost on app restart, matching how TX heard-repeater data already works.
…apping a bubble at max zoom. The fan now matches the count shown on the bubble exactly.
…ys are now hidden from the map. They don't render as markers, don't contribute to cluster bubble counts, and don't get pulled into spider expansions. The repeater picker (trace mode) and Log tab still list every repeater the server returns, so nothing is lost from the data view, it's a map-only declutter.
- Auto-follow zoom is now one-shot: zooms to street level once, then only pans. Pinch-zooming while following no longer fights you.
- Reduced per-GPS-tick map work for smoother performance

Bug Fixes
- Coverage overlay no longer flashes on tile refresh
- Repeaters without GPS now show a location-off icon instead of dragging focus mode to (0, 0)
- Fixed map labels flickering at max zoom during follow mode
…is no longer hardcoded. The app now reads `stale_repeater_hours` from the zone status API response so that it matches the web client.
…X window, discovery window) no longer trigger a full widget-tree rebuild on every 500ms tick. They now self-notify via `ChangeNotifier` so only the ping control buttons redraw. During auto-ping this eliminates 4-6 unnecessary rebuilds per second across 11+ widgets. Noise floor and battery streams also skip redundant notifications when values haven't changed, removing ~840 unnecessary full-tree rebuilds per hour during stable conditions.
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.
…ered bottom bar

- audio_service: route TX/RX/alert blips to the Android media stream (usage: media) so they track the media volume slider instead of the ringer/notification stream (#88).

- home_screen: add a minimize button to the landscape control panel; the collapsed state shows a compact bar centered along the bottom of the map (#329).
…ormat that hid pings from coverage

In broadcast-coords mode the app sent on-air "MM:<tag>:<lat>,<lon>" while still posting the bare wire_tag to /wardrive. The server's observer truncates the on-air body to 16 chars and exact-matches it as the wire tag, so it never joined the app's coverage row — the TX stayed at DEAD and never reached the coverage view (visible only in the analyzer). RX and privacy mode were unaffected.

Broadcast-coords now uses the plain coords path: "MM:<lat>,<lon>" on air, no wire_tag/ping_counter to the API (coords still travel via API lat/lon), matching the server's documented contract. No ping counter is consumed in this mode.
…on-air format"

Keep the combined on-air format MM:<tag>:<lat>,<lon> for broadcast-coords mode — the keyed region-tag is intentional so cross-region spillover can still be attributed even when a user broadcasts coordinates. The coverage-join breakage is being fixed server-side (api.php normalizes the on-air body back to the bare tag before the join + spillover decode) instead of removing the tag client-side.

This reverts commit be1bfea.
Tapping coverage data now draws connection lines from the points the tap flow
already fetches (no new API calls), matching the web client's matching/fan-out:

- Tap a coverage tile: fan out blue dashed lines from the cell centre to every
  unique repeater that heard its pings (with distance labels), hiding the rest.
- Tap a repeater: draw its coverage cells (status/tile colours) + status-coloured
  lines to each cell centre, dim the base tiles, and hide the other repeaters.
  Cap 5000 cells (farthest-first) so the full footprint draws.
- Repeater pill: status shown as a coloured tower icon (green online / grey
  offline) so the stats row fits on one line.

New helpers in coverage_summary.dart (fromCoverageWithPoints, repeaterCoverageCells,
heardEndpointsForCell) reuse the existing web-parity matching logic.
- Fix dead taps at z>=15 on freshly-pinged cells: _handleFeatureTap now treats
  the session patch layer (and the footprint highlight / repeater coverage cells)
  as a coverage-cell tap, not just the base overlay. Fallback hit-test queries
  the patch layer too, with a small rect so tiny cells stay tappable.
- Ignore cell taps below zoom 12 (cells are sub-pixel when zoomed out, so taps
  are almost always accidental).
- Repeater pill: online/offline shown as a coloured tower icon in the title so
  the stats row fits one line.
- Repeater coverage cap raised 250 -> 5000 so the full footprint draws (web parity).
- Shared focus-camera lifecycle (_enterFocusCamera/_exitFocusCamera/
  _fitCameraToPoints/_exitFocusCameraIfDone) so ping focus, tile (Feature A)
  and repeater (Feature B) coverage views share one save/restore of the
  user's camera, follow and rotation.
- Spider spread-marker tap now collapses the spider and focuses that repeater
  (focus mode + its coverage cells), matching the GPS fall-through path
  (was: minimized pill only, no coverage).
- Detailed Grid Mode: tapping a stacked pile spiders out at any zoom
  (un-clustered, no cluster-bubble zoom-in path); spider markers render baked
  chips so the hex label shows instead of an empty box.
The server now routes each offline ping to the region it occurred in and returns
placement_counts + too_far_region per batch. Parse and accumulate these across
all batches of an upload, store them on the uploaded OfflineSession, and show a
summary ("Uploaded - DSA 88 - EMA 157 - too far 3") that is tappable for a
per-region breakdown dialog.

Add a defensive guard in WireTagCodec.encode() that rejects offline-/non-region
session ids (offline is passive-only and is never wire-tag encoded; this also
prevents a RangeError on a malformed id). Session id stays opaque otherwise, so
the upload loop is unchanged and older behaviour is preserved.
…pLibre a non-finite zoom

The camera fit-to-bounds paths could hand MapLibre a zero-area box (coincident
points) or edge padding larger than the visible map (the tall coverage/repeater
bottom sheet), both of which make its projection compute a non-finite zoom and
abort the app via an uncaught native LatLng throw (SIGABRT) — the same crash
signature as an invalid coordinate.

All newLatLngBounds fits now route through a guarded _animateFitBounds helper
that centers degenerate boxes (isDegenerateBounds) and clamps padding to the
live map size (clampFitPadding). The earlier lat/lon-only guard (813273d) never
covered zoom/bounds/padding, so the same crash survived in the coverage
focus-camera feature. Adds unit tests for the new helpers.
On a fresh /auth, continue the wire-tag ping counter from the server's resume_counter (sent when the session was reused after a force-close + reconnect) instead of resetting to 0, which re-minted duplicate wire-tags the subscriber + coverage dedup silently dropped. Absent/0 -> reset as before.
MapLibre's transform unprojects against the live viewport. On a launch
where the GL surface is degenerate/zero-sized (tiles never render a real
frame), the first animated flyTo/setCamera makes unproject return NaN and
mbgl::LatLng throws an uncaught C++ std::domain_error -> SIGABRT. That
throw crosses the native->Dart boundary and cannot be caught from Dart, so
the fix has to live inside the plugin.

- Vendor maplibre_gl 0.25.0 under third_party/maplibre_gl, consumed via
  pubspec.yaml dependency_overrides (no longer from pub.dev).
- Native MESHMAPPER GUARD in the camera handlers of MapLibreMapController
  (.swift / .java): bail out (still completing the method-channel result)
  when the map view has no usable size.
- Dart defense-in-depth: _mapHasRenderedOnce (set on first onMapIdle) is
  folded into _canAnimateCamera, so no programmatic camera move is even
  attempted until the map has rendered once; the one-shot initial GPS zoom
  re-attempts on later ticks instead of burning.
- analysis_options.yaml excludes third_party/** from our lints.
- DEVELOPMENT.md documents the vendoring and the re-apply-on-upgrade step.
When many RX pins arrived within a few seconds, newer pins could render
underneath older ones. The recency sort key was seconds-since-2025-01-01,
now ~46M, which exceeds the native float32 symbol-sort-key's exact-integer
ceiling (2^24). Float32 quantizes that range to multiples of 4, so any
pings within ~4s collided on one sort key and MapLibre ordered the ties
arbitrarily. (Even as a true int, .inSeconds tied pings in the same second.)

Replace the timestamp-derived zIndex with a monotonic counter assigned once
per coverage symbol key (_coverageZIndex / _coverageZCounter):
- Stays well under 2^24, so float32 stays exact.
- Unique per key, so no ties.
- Assign-once, so existing symbols never need re-pushing (the incremental
  sync skip path is untouched).
- Newest key gets the highest value -> always on top and wins the
  topmost-first tap hit-test. An SNR-updated RX changes its key (new ts)
  -> fresh top counter, correctly redrawn on top.

Drop the counter entry in the symbol cleanup loop (prevents an unbounded
leak) and clear the map on style reload (the SymbolManager is rebuilt
empty there; keep the counter climbing).
- Add OfflineUploadResult.networkError; a null /auth (timeout) is now a retryable network error, not a misleading auth rejection

- Retry /auth on timeout (2s/4s backoff) before giving up, mirroring the batch-upload retry

- Success snackbar reads 'Upload Success'; network errors show 'Network error - tap again to retry'. The 'advert your device' message is reserved for genuine auth rejections.
- History map popups: pass pathHops through when reconstructing a TxPing
  from stored markers so multi-hop repeats render distinctly instead of
  all appearing as direct repeats
- Map: keep the GPS puck above all coverage pings via a fixed z-index
- ping_controls: landscape button keeps its color during cooldown
- app_state_provider: ping/auto-mode adjustments and added debug logging
…iles)

The wire-tag packed only (region, NNNN, counter) with no date. NNNN recycles daily, so a tag re-minted on a later day was byte-identical to the prior day's and the server's idempotency skip dropped the new ping -> DEAD tiles when driving new areas. Encode the session date too: region(15) | date(15: year-2020/month/day) | NNNN(14) | counter(11) = 55 bits -> "MM:"+10 chars. decode() now returns the date and reconstructs the full session_id. Web-safe on dart2js (value > 2^53) by working on two <=28-bit halves with arithmetic only; verified byte-identical to the PHP twin and under flutter test --platform chrome. Canonical vectors regenerated.

Also: [COVERAGE] Patched log now includes the cell status histogram, and the API queue logs wire_tag + ping_counter on upload, so collisions self-document.
Wire the Active/Hybrid and Passive buttons (portrait, compact, and
landscape layouts) to the new `isAutoPingStarting` flag so they disable
the instant a mode-start is tapped, instead of looking dead during the
awaited session-check round-trip. Send Ping already disables via
`isPingSending`.

Replace each control widget's top-level `context.watch` with
`context.select(_controlsDepsOf)` + `context.read`, so the controls
rebuild only when a control-relevant field changes — not on every
GPS / noise-floor / battery notify (~1-2 Hz during wardriving).
Countdown values still update via the inner
ListenableBuilder(timerListenable). The dependency records cover every
appState.* read in the build bodies (incl. isConnected / targetRepeaterId
/ repeaters for the Trace section) so no button goes stale while idle.

Companion to the sendPing()/toggleAutoPing() instant-feedback +
re-entrancy guards already landed in app_state_provider.dart.
Bug 1 (GPS puck blink, regression from 764f0d4): the puck shared one
MapLibre symbol layer with every coverage pin. Adding a pin rewrote and
re-laid-out the whole layer, so for ~1 frame the new pin painted over the
puck before the sort-key reordered it on top. New since 764f0d4 because the
puck previously sat UNDER the pins. Move the puck into its own dedicated
gps-puck-source + gps-puck-layer, installed topmost (no belowLayerId),
mirroring _ensureCoverageLinesLayer. Always-on-top is now guaranteed by
layer order, not sort-key contention, and a pin can never paint in the
puck's isolated layer -> blink gone by construction. enableInteraction:false
so taps fall through to repeaters. Re-installs on style reload. Removes the
old _gpsZIndex sort-key + _gpsSymbol annotation approach.

Bug 2 (intermittent "Send Ping locks out Hybrid/Passive", force-close to
clear): NOT a regression from recent commits — the new _isPingSending /
_autoPingStarting flags both clear in finally and the disable/RX-window
lifecycle was untouched. Latent issue: CountdownTimerService.isRunning is
_timer != null with no wall-clock check, so if the 500ms _update() stops
firing (iOS suspends timers while backgrounded/driving) the timer never
self-cancels; a stuck rxWindowActive then disables Send Ping itself, so the
user can't ping to reset it. Add diagnostic logging only (_logStuckTimers):
warns [TIMER] <name> isRunning past its deadline when a countdown timer
reads isRunning && remainingMs == 0. Runs on app-resume and throttled-5s off
the GPS notify (no new timer). Real one-line fix deferred until a debug log
confirms the trigger.
External 'Buy us a coffee' (Buy Me a Coffee) link triggered Apple
rejection under 3.1.1. Gate the About-section tile behind a platform
check so it's omitted on iOS while remaining on Android/Web, where
external donation links are permitted.
@MrAlders0n MrAlders0n merged commit 6353837 into main Jun 30, 2026
0 of 2 checks passed
@MrAlders0n MrAlders0n deleted the release/222a300 branch June 30, 2026 12:46
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.

2 participants