Skip to content

fix: earthquake 頁面 - #3

Merged
yayacat merged 1 commit into
ExpTechTW:Releasefrom
yayacat:Release
Oct 2, 2023
Merged

fix: earthquake 頁面#3
yayacat merged 1 commit into
ExpTechTW:Releasefrom
yayacat:Release

Conversation

@yayacat

@yayacat yayacat commented Oct 2, 2023

Copy link
Copy Markdown
Member

No description provided.

@yayacat
yayacat merged commit 80d3cf7 into ExpTechTW:Release Oct 2, 2023
This was referenced Nov 10, 2025
@claude claude Bot mentioned this pull request Nov 27, 2025
7 tasks
@claude claude Bot mentioned this pull request Dec 31, 2025
7 tasks
whes1015 added a commit that referenced this pull request Jul 12, 2026
Add the architecture gates the review's item #3 called for:

- CI (.github/workflows/ci.yml): layering gate, dart format check,
  codegen-drift check (build_runner + git diff), analyze, test — on the
  mise-pinned Flutter 3.44.3, one source of truth for the toolchain.
- Layering gate (tool/check_layering.sh): fails when core/shared import
  features, presentation imports data, or a feature imports another
  feature's data/presentation.
- Golden tests pinning the safety-critical EEW math
  (test/features/earthquake/eew_estimator_test.dart) at 1e-9 tolerance so
  an arithmetic regression fails loudly.

Fix the coupling the gate surfaced:

- Move radar from features/map/data to shared/map and wrap it in a
  RadarRepository (Result) — the home backdrop and map tab both consume it
  without a cross-feature data import.
- Move app-wide ExperimentalSettings/WeatherMode to core/settings so home
  and settings stop importing across features.
- Central route registry (shared/navigation/app_routes.dart); the router is
  the only place that imports page widgets; pages no longer declare paths.

Document the gates and navigation conventions in CLAUDE.md.
whes1015 added a commit that referenced this pull request Aug 14, 2026
…cratch

The astro batch landed unformatted — dart format --set-exit-if-changed
caught 11 files, so CI's format gate would have failed. satellite_test.dart
is the permanent home of the Spacetrack Report #3 golden vectors (pinned to
metres) plus ISS physics checks, superseding the two tool/_*_check scratch
scripts; the scratch files are dropped.
whes1015 added a commit that referenced this pull request Aug 16, 2026
* perf: map

* perf: lower sky animation cost on hidden tabs and low-end devices

The weather backdrop is the only full-screen layer that keeps redrawing
while its tab is hidden (60 fps ticker, 1792 rain particles, full-screen
shaders) and it burns low-end GPUs. Five equivalent batches:

- Recompute the ephemeris and keyframe ring on a daily/minute cadence;
  the LUT bake now runs once a minute instead of once a second
- TickerMode mutes every ticker under the sheet while Home is hidden
- Quantise the full-screen blur sigmas into 6 steps during drags
- Tier by RAM on Android (< 4 GB): render scale 0.75->0.6, rain pool
  1792->1024, snow 900->640 (native reports totalMemoryMb)
- Hoist loop invariants out of the particle and cloud loops

* perf: stop repainting the frozen sky on every scroll tick

Scrolling rebuilds _ScrollBlurredWeather every tick while the sky is
visually frozen under it:

- ImageFilter has no value equality, so a fresh blur() every tick made
  the full-screen blur layer recomposite constantly; the quantised
  sigma ladder now reuses one instance between steps
- WeatherSkyBackground reuses its painter while the ticker is stopped,
  so the CustomPaint skips repaint on the rebuilds above

Adds a widget test pinning the stopped sky to its painter across
rebuilds and a fresh one when it restarts.

* perf: pause native map render when its tab is hidden

The shell's IndexedStack keeps every tab mounted, so both MapLibre
platform views (home backdrop + map tab) kept rendering behind other
tabs. BaseMap now subscribes to VisibleTabScope and calls
setRenderPaused on the controller, so a hidden map stops burning the
GPU. Adds the forked maplibre_gl setRenderPaused API (git-pinned
platform interface and web packages) and the cupertino_icons dep.

* feat: storage scan, breakdown, and bounded caches for the debug page

iOS Settings reports the whole sandbox, which is far larger than the
150 MB ETag body budget: the SQLite file carries page/free-space
overhead, the system NSURLCache keeps its own copy of responses, and
ambient MapLibre data can linger. A native channel scans the sandbox
(cache/support/document/tmp, top 30 files); the Developer page shows
total usage, a categorized pie breakdown, and per-slice percentages.

Growth is bounded: startup configures NSURLCache to 64 MB, and Clear
cache now also compacts the SQLite file (VACUUM) and empties the
system HTTP cache.

* perf: cut wind overlay raster cost, allocation, and hidden-tab burn

The trail buffer rasterized at full screen resolution every frame
(toImageSync, a synchronous GPU round-trip on the UI thread), the stamp
path allocated up to 6400 Offsets per frame, and each particle paid a
log+tan projection. The buffer now renders at half resolution (or a
third on low-end devices), stamping goes through preallocated
Float32Lists with drawRawPoints, and the mercator projection is a LUT.
The ticker also stops while the map tab is hidden, so the overlay no
longer animates behind other tabs.

* fix: stop hidden-tab animations that never paused

VisibleTabScope handed every page the same notifier instance, so its
InheritedWidget never notified on a value change and the home sheet's
TickerMode plus the wind overlay's ticker gate froze at their first
value — both kept animating behind hidden tabs. Subscribe to the
notifier itself (as BaseMap and RefreshOnAppear already did) and pin
the contract with tests.

* feat: yellow county outline for the typhoon satellite underlay

Switching the typhoon weather underlay to satellite swaps the county
frame to the bare bright-yellow line the standalone B13 layer uses —
the shared cased stroke reads as black over opaque IR. Removal is
unconditional on either side so toggling or switching never leaves a
stale frame behind.

* fix: keep admin borders above timeline rasters, 國界 on by default

adminBaseLayerId anchored frames below the bottommost admin stroke,
which is the global casing once 國界 is on — so a scrubbed frame still
covered the county and town lines. Anchor below the topmost admin line
instead, and apply the same anchoring to radar and QPESUMS (their later
frames stacked over their own borders and scan-range outline).

國界 now ships on for every raster layer (radar, wind, QPESUMS,
satellite); the menus' "not the defaults" dot and their tests follow.

* feat: drop the 7-day cache expiry for a 350 MB oldest-first budget

SQLite cache entries no longer expire by age — only the byte budget
trims, and only once the store is actually over 350 MB, dropping
least-recently-used rows until it is back under. Debug kernel
snapshots (*.dill) count as engine in the storage pie and the largest
files now show their directory, so a tmp pile-up is attributable at a
glance.

* ci: rerun build

* feat: drop the iOS disk URL cache for a single SQLite authority

MapLibre's native downloads already persist through the Dart tile bridge
into the app's own ETag SQLite, so NSURLCache's disk copy was pure
overhead — a second, un-metered copy of the same bytes that only the
system could evict. configure() now sets diskCapacity to 0 (memory-only
16 MB stays, so a SQLite miss can still skip the network), drops any
residue left by older builds, and the storage breakdown marks the
System HTTP cache slice as residue-only.

* feat: wipe debug kernel snapshots from tmp on release launch

flutter run leaves main.dart.dill / .swap.dill (~87 MB each) in tmp on
every debug launch and iOS keeps tmp across app updates, so a dev
device that runs release picks up hundreds of MB of JIT kernels it
cannot use. Release startup clears tmp once — release has nothing of
its own there, and Android's handler is a no-op by design.

* fix: widen the wind overlay's speed-bucket counter past 255

The perf rewrite counted each bucket's points in a Uint8List, and a whole
6400-particle population can land in one bucket under strong wind — the
count then wraps at 255, dropping the bucket (or most of it) so new
particles vanish and stale trails outlive a rotation. Count in 16 bits,
and make the streak tests actually see the particles: the sampled
boundary was Scaffold's white one (blank overlays passed), and the z7
viewport held too few particles to trip the wrap. A zoomed-in Taiwan
field now puts thousands of points in one bucket, pinning the count at
300+ bright pixels — the buggy build measures ~60.

* refactor: house the notification sounds and standardise their encoding

The AIFFs sat loose in ios/Runner and the OGGs beside the Android
resources, sized 5.0 MB and 287 KB between them with no common spec —
several were already clipping at 0 dBFS while others sat 3 dB quieter,
and the OGGs were Vorbis stereo. Move the iOS sounds into Runner/Sounds
(pbxproj paths updated) and re-encode everything: 44.1 kHz mono, peak
normalised to -1 dBFS, Android as 128 kbps MP3 and iOS as IMA4 AIFF
(notification sounds must stay in an Apple container, so MP3 is not an
option there). iOS drops from 5.0 MB to 640 KB.

* chore: upgrade Flutter to 3.47 and refresh dependencies

Flutter 3.44.8 -> 3.47.0 (Dart 3.13) via mise; SDK floor to ^3.13.0.
Dart 3.13 reserves `final` on parameters for primary constructors, so the
freezed 3.x codegen no longer compiles — freezed 4.0.0-dev.3 + build_runner
2.16 regenerate all 23 models (output otherwise unchanged). Firebase stays
pinned 4.11.0/16.4.1 (exact, not ^, so pub upgrade can't drift it).

Dependency bumps: dio 5.11, go_router 17.5, package_info_plus 10.2.1,
talker 5.1.20, json_serializable 6.14.1. All 38 touched files are the Dart
3.13 formatter's reflow plus one lint fix (unawaited_return_in_try_block in
MapTileCache.warm).

* fix: don't clear the township on the monitor's first status refresh

The first `_refresh()` only seeds `_status` — its "previous" is the
optimistic initial value, not a confirmed usable state. If a fix published
the township while that refresh was in flight (slow geolocator channel), the
GPS-lost branch then overrode it with null. The lost branch now requires
`_seeded`, so a seed refresh can never clobber a fix that already landed.

* feat: draw the report map's intensity markers in code, drop the PNGs

The 19 bundled marker PNGs (intensity-1…9, dark variants, cross) are now
painted locally into the same badge geometry — rounded-square shell + the
discrete intensity colour from IntensityColors (single source of truth,
can't drift from the legend) + level digit — and cached PNG bytes feed
MapLibre exactly as the assets did. Removes ~28KB of assets and the
pubspec declarations; structural tests pin the geometry.

* build: ship arm64-only release APKs and ignore android build output

Flutter ≥3.35 auto-unions its 3-ABI abiFilters with the app's, dragging the
map SDK's libmaplibre.so (10MB/ABI) in for architectures the engine doesn't
ship. Clearing and pinning arm64-v8a (minSdk 26, emulators run debug)
cuts the release APK 53MB → 37.6MB; CI's redundant --target-platform flag
goes away with it. android/build + android/app/build join the ignore list.

* fix: stop double-counting the ETag cache in the storage pie

The breakdown subtracts known big files (the SQLite DB etc.) from the
directory that contains them, but on iOS the dirs and files came out of
different APIs, so path styles could differ (/private/var vs /var) and the
subtraction silently missed — the same 123MB appeared as both "Caches" and
"ETag cache (SQLite)", summing past 100%. Standardize both path sets to
the resolved spelling, tolerate the /var spelling in the Dart matcher, and
label a directory that gave up a known file "(other)" so the pie reads
ETag as part of Caches, not a sibling.

* feat: query travel times from a per-event interpolated source

Port the reference CWA travel-time grid (depth × dist, P + S–P) into the
domain and pre-interpolate one depth into two 1-D curves per event, so each
distance↔time query is a single bisect + linear interp instead of a linear
scan. The replay map caches one source per alert across ticks; wave-radius
goldens are unchanged (depth 0).

* chore: slim the bundled assets

Gzip box.json (7 KB → 0.8 KB) and drop the redundant uncompressed
travel_time.json; re-encode the two purely-visual sky textures lossy
(starmap 96 → 64 KB, sun_rays 34 → 4 KB) with the generator tool updated
to match; re-gzip location.json at level 9.

* perf: parallelise launch and offload asset decoding to isolates

Firebase, prefs, the SQLite cache, the town directory and package info now
load concurrently instead of serially; notification init moved after the
first frame so FCM never gates launch. The town-boundary binary, town
directory and travel-time grid decode in background isolates (their gzip +
parse previously stalled the UI isolate), and the realtime feeds stagger
their first polls so the post-first-frame burst doesn't hit the network and
JSON decode all at once. Log.sinceStart marks bootstrap-ready and
first-frame times.

* docs: refresh the docs against the current codebase

Sweep the markdown after the perf rewrite: Flutter 3.47 / mise toolchain,
ApiClient+ApiTier+ApiPaths networking (no more redundant/exclusive/external
apis), the 15 shipped features, wind/DPM-restroom-shelter endpoints, the real
weather-shader layer stack, and the DPIP repo slug (no longer DPIP-Pocket).

* perf: trim per-tick map overhead across all layers

- scope the camera-epoch rebuild to the overlay subtree, so a pan/zoom
  settle no longer rebuilds the platform view, chrome and legend
- memoise the base-map style string (varies only by palette)
- fast-path the geo-circle ring math (cached bearing table, hoisted
  centre/delta constants) and memoise frame-id time parsing
- replace the wind particle 1/cos(lat) per particle per frame with a LUT
- skip empty-EEW and same-payload re-pushes on the replay and RTS layers
- parallelise independent platform round trips (timeline neighbour
  mount, typhoon overlay visibility)
- cache the radar scan-range ring and lightning same-frame shows

* perf: cache blur filters and bake the night star field once

- home sheet/map blur: reuse the ImageFilter across drag ticks instead of
  rebuilding it (and recompositing the full-screen blur) every frame —
  sigma quantises to the same step, so the filter only changes on a level
  crossing
- weather sky: bake the four-layer star field and the sun glare into
  textures once instead of re-rasterising the fragment shader every frame
- report list / weather ranking: memoise DateFormat instances per locale
- rain trend: memoise label widths

Splits the star layers out of night.frag into night_field.frag (RGBA =
bright-pass core/glow, medium, faint) and pins the bake with a shader test
that each channel actually lights pixels.

* feat: add the LoRa mesh transport, link keeper and DPIP data plane

- vendor meshtastic_flutter (third_party/) with two upstream fixes:
  requestMtu is skipped off Android (CoreBluetooth negotiates MTU and
  flutter_blue_plus throws there), and text/JSON payloads decode as
  UTF-8 (fromCharCodes garbles CJK)
- MeshtasticService (domain) + MeshtasticClientImpl (data): BLE transport
  over the vendored package, with platform-aware permission handling and
  a package:logging bridge into the app Log
- MeshLink: session owner created in bootstrap — persists the chosen
  radio, reconnects across pages/restarts, and only detach() stops it
- DpipMeshGateway + DpipMeshPacket: PRIVATE_APP payloads in a versioned
  5-byte envelope on the fixed DPIP channel; wire codes pinned by tests
- typed failures for radio channel slot exhaustion and key conflicts
- preferences keys for the persisted radio and the message log

* feat: add the mesh page and route it from More

- page with scan/connect/disconnect, node list and a chat composer bound
  to MeshChatController (message log persisted in prefs)
- route registered in app_router.dart + More entry under Advanced
- 18 meshtastic keys added to all 11 ARB files and regenerated

* feat: wire BLE permissions for the mesh radio

- Android: legacy BLUETOOTH/BLUETOOTH_ADMIN pair (maxSdkVersion 30) plus
  BLUETOOTH_CONNECT/SCAN with neverForLocation for Android 12+
- iOS: NSBluetoothAlways/Peripheral usage descriptions (also gates the
  permission_handler SPM target)
- iOS Package.resolved refreshed for flutter_blue_plus and friends

* build: add the macOS target

Generated by `flutter create --platforms=macos` (network entitlements
added for the sandboxed app). Also refreshes .metadata to the pinned
Flutter revision.

* docs: document the LoRa mesh layers

CLAUDE.md: transport / session / data-plane split, the PRIVATE_APP
envelope on the fixed DPIP channel, and the best-effort delivery
caveat. ARCHITECTURE.md: core/meshtastic + features/meshtastic in the
tree map.

* feat: add a radio diagnostics sheet to the mesh page

Bottom sheet showing everything the attached radio knows: identity,
battery/uptime, LoRa settings (region, preset, hop limit, TX power,
channel utilisation), live packet traffic by port, and the channel
table. Raw values on purpose — a wrong-looking number is the point.
Also a "connect anyway" affordance when another app holds the radio.
New keys in all 11 ARB files, regenerated.

* test: extend the fake mesh service with radio diagnostics

* feat: keep the screen awake for the mesh chat

A mesh conversation can sit open for a long stretch, and the radio is
watched while it happens — let the page ask for the display to stay on
through a platform channel (idle-timer disable on iOS, keep-screen-on
window flag on Android). Backgrounding still neutralises it, and the
flag is cleared when the page that asked for it goes away.

* feat: persist mesh data, raise local alerts, show a usage chart

The mesh becomes a first-class channel, not just a transport demo:

- conversations and 24h airtime are stored in their own database
  (application-support, not the purgeable HTTP cache) with a migration
  from the in-memory log era
- heard nodes are tracked and surfaced, and new nodes raise a local
  notification — off-grid, no server involved
- alerts for incoming messages route to the mesh notification channel
- the traffic counters move behind a dedicated class the UI and the
  recorder share, and the chat page gains a utilisation chart plus a
  keep-awake toggle

* feat: plot mesh nodes on the map

Heard radios appear as a map layer with a tap-through sheet showing
what the node reports (position, last seen, distance). The layer
subscribes to the node store, so nodes appear and age as the radio
hears them.

* feat: render the real Moon for any chosen date

A lunar phase page under 資料 with a scrubbable daily timeline like
the radar's. The phase itself is a pure local computation (Meeus
closed form — no ephemeris table, no network), and the disc is the
real Moon: NASA's CGI Moon Kit colour and elevation maps bundled as
assets, projected orthographically and lit per frame by a shader
(Lommel-Seeliger scattering, opposition surge, terminator softening,
earthshine). The timeline gains optional time-format and slot-width
parameters; the defaults keep the radar's look unchanged.

* i18n: localize the moon page

Adds the moon catalogue entry, phase names, age and next-full-moon
readouts, and the timeline caption across all eleven locales, with
Traditional Chinese as the source for the zh variants.

* chore: drop unused meshtastic l10n keys

The page rewrite replaced the old scan/connect flow; these keys are no
longer referenced anywhere.

* feat(astro): one ephemeris, not four — moon position from Meeus ELP-2000

MoonPhase becomes a thin reading of a new MoonEphemeris: longitude,
latitude, distance, parallax, angular diameter and the sun's longitude
come from a single evaluation of the table 45.A/45.B truncation instead
of a hand-written series per readout, so phase, distance, libration and
rise/set can never disagree with each other. New MoonRiseSet walks the
moon's altitude across the day with refraction and parallax-corrected
horizon (h0 = 0.7275π − 34′), bisecting each crossing; rise/set is null
when the ~50-min daily slip skips a day. nextAngle now coasts to the
target at the mean rate and re-measures (no scan/bracket), and
distanceKm / apparentDiameterDegrees / nextNewMoon join the surface.
Measured against JPL Horizons (2024–2027) and the USNO (Taipei, Sydney,
Reykjavík); Meeus's worked example 45.a is pinned as a test.

* feat(moon): calendar + glyph, and the page reads the real distance

The page now shows the moon's distance and apparent diameter alongside
the phase readouts, and the shader lookup is fixed to the near side: the
maps centre on 0° longitude, so the disc centre must land on u = 0.5 —
atan(x, z) instead of atan(x, −z), which quietly rendered the far side;
the libration rotations are re-derived as the selenographic point facing
Earth, and the moon_glyph / moon_calendar widgets back the phase legend
and a per-day picker.

* refactor(mesh): drop the tx-by-port breakdown

What leaves is only ever ours — chat and DPIP — so a per-port table adds
nothing the totals don't already say; what arrives comes from every app
on the mesh, which is where the breakdown is informative.

* chore: drop the unused procedural moon_surface shader

Superseded by the NASA map + moon_display lighting; nothing has loaded
it since the texture switch.

* feat(astro): sun, planets and solar terms pages

One shared ephemeris spine for the whole sky — sun_ephemeris and
planet_ephemeris feed daylight, twilight, the golden/blue hour and the
planets page; solar_terms marks the 24 solar terms on a calendar strip.
The data hub's astronomy section becomes a two-column card grid, and the
moon page gains observer-tilt rendering (moon_orientation) so the globe
leans the way it actually appears from the chosen place.

* fix(mesh): let node taps reach the map's onMapTap

An interactive maplibre layer fires feature#onTap instead of map#onMapClick,
and nothing listens to feature taps — a tap on a dot went nowhere while a tap
on empty sea still reached onMapTap. The hit test is Dart-side, so the layers
never need interaction: disable it and every tap lands in the map handler.

* feat(mesh): node sheet trends — SNR/battery history and distance

The store now keeps a per-node ring of recent telemetry (in memory, de-duped
against re-emitted bursts) and can measure great-circle distance from the
radio's own node. The sheet shows the distance beside the coordinates and
sparklines the last readings, with the latest value on the header — SNR and
battery read as a story instead of a single snapshot.

* fix(map): refresh the timeline on foreground and tab return

The scaffold had no lifecycle hook: frames were fetched once on entry, so
returning from the background showed the timeline frozen at the pre-
background "now" (a half-hour away is three radar frames missed). Subscribe
to app lifecycle and the shell's visible tab, and re-fetch + re-centre on
the present whenever the surface comes back on screen — hidden tabs stay
idle. The "now" frame is picked with the calibrated clock (AppTime.utc)
instead of device time in both the scaffold and the timeline, so the NTP
resync on foreground makes the selection land on the real present.

* feat(astro): night-sky stack — eclipses, satellites, stars and more

The sky above the app: eclipse.dart computes lunar (and locally visible
solar) eclipses from the ephemeris positions, satellite.dart propagates
TLEs (SGP4) with a TLE table bundled for the ISS, star_catalog.dart ships a
compressed star/constellation catalog, and meteor_showers / night_window /
tidal_forcing / deep_sky cover the rest of the night-observing questions.
Observer gains topocentric parallax (Meeus ch. 40) so a solar eclipse is
computed from where you stand, and the lunisolar 歲 boundary now resolves
both directions around the winter solstice instead of only one. The tool/
scripts are the scratch verifiers used against reference vectors.

* chore(astro): format the night-sky stack; golden SGP4 tests replace scratch

The astro batch landed unformatted — dart format --set-exit-if-changed
caught 11 files, so CI's format gate would have failed. satellite_test.dart
is the permanent home of the Spacetrack Report #3 golden vectors (pinned to
metres) plus ISS physics checks, superseding the two tool/_*_check scratch
scripts; the scratch files are dropped.

* fix(astro): night window from noon, TLE freshness in three tiers

The night-window solver ran midnight-to-midnight, so the first dusk it
found was this evening's and the first dawn this morning's — a pair in the
wrong order describing a night that already ended. Solve from local noon,
where a night actually sits. TLEs are good for days, and the bundled
snapshot decays; tle_source now tries a daily fetch, falls back to the last
cached set in Prefs, and only then to the bundled file — with the snapshot
trimmed to just ISS/POISK/CSS.

* feat(astro): tonight, almanac, sky chart and tide pages

Four data-hub pages on the night-sky stack: tonight_report.dart assembles
the observing window (dark hours, moon, ISS passes, meteor showers) into
one TonightPage; AlmanacPage shows the lunisolar date with upcoming
eclipses; SkyChartPage draws the star catalog and planets; TidePage plots
the tidal forcing. All routes registered under the data branch, and the
full l10n set for the new pages lands with them.

* test(astro): tonight report, TLE source, data hub, and sky features

tonight_report_test pins the assembled observing window, tle_source_test
covers the fetch→cache→bundle fallback tiers, data_page_test guards the
hub's new entries, and sky_features_test grows to cover the noon-solved
night window.

* feat(astro): expose Prefs to the tree for the TLE freshness tiers

TonightPage reads the cached element sets through Prefs, so core providers
hand the shared instance down instead of the page constructing its own.

* refactor(storage): SharedPreferences out, SQLite in

The typed Prefs facade and its key registry are replaced by a settings
store backed by the durable database (core/storage/app_database.dart):
two SQLite files split by durability — the purgeable HTTP cache file, and
an application-support file that holds settings, TLE elements and the mesh
conversation, so "clear cache" cannot reach user data by construction.
Settings load into memory once at bootstrap (reads stay synchronous for
build) and writes go to the DB in the background. The mesh node table
moves from a JSON blob in a settings key to a row-per-node SQLite table,
restored asynchronously with a whenRestored gate, and the legacy mesh-log
migration is dropped. TLE freshness tiers keep their last-fetched elements
in the same database. The prefs gate becomes the storage gate
(tool/check_storage.sh), and shared_preferences leaves the lockfile.

* test(storage): migrate the suite to the SQLite settings store

Every test that constructed Prefs / SharedPreferences mocks now builds a
SettingsStore (in-memory or backed by sqflite_common_ffi), covers the new
app_database and settings_store, and the mesh node store tests exercise the
asynchronous restore against a real table.

* feat(permissions): report outcomes and route to Settings when a prompt can't repeat

Both platforms prompt once per permission; after that a request returns
silently, so a row that only re-calls it is a button that does nothing.
Permission requests now return a typed PermissionOutcome (granted / denied /
needsSettings) instead of opening Settings themselves, and the onboarding
screen reacts to needsSettings with a dialog that explains why and offers
the system page. The critical-alert row gets its own request too: bundling
it with the ordinary one made it unreachable once notifications were
already allowed. Every step is logged end to end.

* feat(notifications): isolate a rejected channel and gate channel sounds in CI

initialize validates every channel natively and throws on the first it
rejects, so one bad sound or icon used to leave the app with no channels
at all and no push transport. A batch failure now falls back to registering
channels one at a time, costing only the offending channel and naming it
in the log. check_notification_sounds.sh fails the build on an
unresolvable resource://raw/<name> before it can ship.

* feat(logging): persist the log to SQLite

Bridges Talker's stream into a 24-hour log table the moment bootstrap has
a database, so the last thing a crash or background kill destroys is on
disk instead of only in memory. Logging keeps working in memory when the
database would not open; flush() writes the buffer at backgrounding.

* refactor(network): drop the net_total compatibility migration

The legacy lifetime-counters table has not been created since the hourly
buckets shipped; the DROP on open and its place in cacheTables are dead
weight now. The column backfill for old bucket tables stays.

* feat(log): replay persisted logs in the viewer and flush on background

The log screen now replays the last 24 hours from the logs table into
Talker's history on open, so it covers the launch that crashed instead of
only the current session; lines are skipped by timestamp when memory
already reaches back. App lifecycle flushes the write buffer at
backgrounding, the moment a kill is most likely.

* fix(permissions): never let a prompt hang, and make Open Settings actually open

Once iOS has decided "denied", awesome_notifications opens the system
settings page itself and parks the request's completion until the app
returns, so the awaited Future can simply never come back. requestPermission
now asks the plugin to prompt only while the OS status is still
notDetermined, and every plugin request carries a 20s timeout so a
permission button can never be inert. Both settings buttons now route
through one implementation using permission_handler's openAppSettings:
awesome's own page silently did nothing on iOS, which made it the second
dead end in the same flow.

* feat(settings): standalone 權限檢查 page reachable from More

The permission rows onboarding shows are now one shared
PermissionChecklist, and a new /permissions page presents them with the
reasons behind each one. That is when they are actually needed: an alert
that never arrived is usually a grant declined months ago, and the system
settings app does not say which of DPIP's four permissions is missing.
Onboarding keeps its framing and the finish-time nudge via the checklist's
reported state.

* feat(update): detect the install source on both platforms

An update prompt has to send the user to the distributor, not the
platform: a TestFlight tester sent to the App Store finds the stable
build, and a sideloaded APK has no store listing at all. iOS reads the
receipt filename (sandboxReceipt is the TestFlight marker; DEBUG builds
are sideloads, not beta), Android reads the installing package name.

* feat(update): pure update-check logic and release model

findUpdate picks the newest release of the caller's channel and only when
it is genuinely newer: ordering is a numeric component compare within one
channel (DPIP inflates the patch number for pre-releases, so a plain
semver compare would mis-rank across channels — the channel filter comes
first). ReleaseNote gains html_url for sideload builds, whose only update
destination is the GitHub release page.

* feat(update): once-per-version update prompt in the app shell

Mounted in the shell, renders nothing: one check after the first frame
reusing the changelog's ETag-revalidated request, a dialog only when this
build's channel has a newer release. The offer is recorded before the
dialog shows, so each version is offered exactly once, and the update
button names its destination (App Store / TestFlight / Play / download).

* refactor(permissions): drop app_settings.dart, route every surface through one opener

The banner duplicated the open-settings helper; both now use
system_settings.dart, the single implementation that actually navigates
on iOS.

* feat(weather): bundled weather-icon font, generated from Google's manifest

Flutter's bundled MaterialIcons has no rain glyph at all, and the
workaround — declaring rainy as 0xf07c2 — drew Icons.severe_cold, a
snowflake, on every rainy hour. build_weather_icons.py instantiates a
6.7 KB subset of Material Symbols Outlined (Apache-2.0) from the variable
font and generates both the font and every IconData codepoint from
Google's .codepoints manifest in one run, so a codepoint can never be
guessed again. It is an asset, not a package, so the built-in-icons rule
still holds; the licence is registered for the app's open-source page.

* feat(weather): distinct phenomenon glyphs and day/night sky icons

Every CWB phenomenon now has a real glyph (rain, mixed precipitation,
hail, graupel) instead of borrowed approximations, and plain skies flip
between sun and moon by the same refraction-corrected sun times as the
backdrop — a clear 02:00 chip shows a moon while the 14:00 chip beside it
shows a sun. Strength that a glyph cannot show (heavy rain vs light, 雷雹
vs 雹) stays in the accent and the backdrop. weather_icons_test rasterises
every glyph so a missing or duplicated one fails loudly.

* test(mesh): isolate each test's in-memory database

sqflite hands back the same handle for a repeated path and :memory: is a
path, so tests shared one database and tearDown closed the handle the
next test was about to use. singleInstance: false gives every test its
own database.

* perf(map): terrain relief now really unloads when toggled off

`visibility: none` only stopped the hillshade from rendering — MapLibre
keeps loading a source's tiles while any layer references it, so a
512px DEM mesh per tile (roughly 1MB texture + 1MB floats each, ~49
tiles per viewport) kept downloading, decoding and occupying memory
with the switch off. The toggle now removes layer + source and re-adds
them from the same constants the baked style uses, so off releases
everything and on restores the identical look. Style reloads re-bake
both, so the load handler re-asserts the choice.

Also raise the DEM source minzoom 0→6: below z6 the relief shrinks to
a few dozen screen pixels (the home backdrop sits there at ~z5.5) yet
paid the same full-size download/decode bill, and the detail only
starts reading at z7. Warm the DEM viewport from the tile store like
the basemap, so revisits hit SQLite instead of re-downloading.

* fix(map): terrain relief no longer blanks out when zooming out

The z6 source minzoom blanked the hillshade below it — a visible
regression the moment the user zoomed out. Low-zoom DEM cost was never
the real problem: a whole-island view is one or two 512px tiles, while
the ~49-tile viewports that made the feature run hot only appear at
zoom-in. Revert to minzoom 0, keeping the real fixes: the relief
toggle now removes layer + source instead of flipping visibility, and
the DEM viewport is warmed from the tile store like the basemap.

* perf(realtime): drop the SSE connection while the app is backgrounded

A poll source idles for free — the channel simply stops calling fetch —
but one holding a connection open keeps costing radio time and keeps
receiving a continuous feed no one will read. The channel now calls
source.pause()/resume() around its own pause; SseRealtimeSource cancels
the subscription on pause (a cancel raises no onDone, so it can never
start a reconnect of its own) and re-arms the lazy open with the backoff
reset on resume, so the first post-resume fetch builds a fresh
connection exactly like the first one. Background alerting was already
push's job, never the stream's.

* perf(map,home): stop painting and simulating surfaces no one can see

Flutter skips painting a route covered by an opaque page but keeps its
tickers running, and a platform view's native render loop draws entirely
outside Flutter's compositor. Three gaps closed:

- The shell now watches the root navigator (shellRouteObserver) and
  publishes VisibleTab.shellOnTop, so a full-screen page pushed over
  every tab is visible to surfaces under it.
- BaseMap pauses its native render loop when the app leaves the
  foreground (native loops ignore Flutter's frame stop), when its tab
  is hidden, or when a page covers the shell. `inactive` keeps running
  on purpose: the app switcher and notification shade still show it.
- The home sky pauses its shader/particle stepping when covered, and its
  CustomPaint gets a RepaintBoundary so an animated frame no longer
  re-rasterises the full-screen layer it shares with the sheet chrome.

* fix(map): keep chrome above the raster through every timeline scrub

belowLayerId inserts a layer *immediately* below its anchor, so when
frames and borders shared one anchor, each newly-mounted frame landed
on top of borders that were never re-stacked: dragging the timeline
buried the county/township lines under the echo and left them there.
Every timeline layer now mounts its frames under a dedicated seam layer
(an empty, zero-cost line layer that holds a position) and every piece
of chrome above it, so the two can never interleave. The satellite
layer anchors its frames above the base style's borders — it draws its
own bright outlines precisely because the default white dies on
imagery, and both sets on screen read as double weight. Anchors are
asserted as resulting order, not anchor strings (layer_stacking_test).

Also re-key the map overlay only for layers that need a rebuild on
camera settle (overlayFollowsCamera): wind particles read the live
camera every tick, and re-keying them tore down the ticker and trail
buffer on every pan.

* fix(map): wind field can never freeze for the rest of the session

A single throw inside the ticker callback stopped the animation
permanently: Ticker reschedules itself only after the callback returns,
and isActive still reports true afterwards, so nothing ever restarted
it. _onTick now catches everything, names each bail-out (unmounted, no
controller, no field, no camera, no size), and a watchdog steps in if
the animation stalls without a tick — rebuilding the simulation and the
trail buffer from scratch. Its own RepaintBoundary keeps every streak
frame from dirtying the overlay layer it shares with static chrome.

* feat(more): rank the two calls to action at the top of the page

Support is the page's one ask — no ads, servers cost money — so it is
the only row with a gradient, a glow and a filled badge, built from the
scheme's own tonal roles so it stays premium in dark mode. Discord
follows as the clear second: the same badge-and-two-lines construction
but flat, so the pair reads as a hierarchy instead of two competing
banners. Both sit above every menu group; Discord left the links list
so it appears exactly once. The shared open-external-link helper now
lives on the page file.

* feat(theme): the support callout's gold, outside the color scheme

Gold is the one colour in the app not generated from the seed, and
deliberately so: the scheme's roles exist to make everything look like
one app, which is exactly why nothing drawn from them can read as
*paid* — a primary-tinted card is just another card. Two palettes, not
one colour with an opacity: a bright gold has no contrast on a white
page and a deep gold turns to mud on a near-black one, so light is a
warm champagne wash under a deep bronze ink and dark is a low brown-gold
under a bright ink. Both ink pairs clear WCAG AA (pinned by test).

* test(map): borders stay over the typhoon underlay across a re-sync

The chrome sync is diff-based — it re-adds a border only when its
toggle changes — so anything that re-mounts the raster must leave the
borders above it. Pins the seam's work from the typhoon side: switching
underlays and back still leaves county and township lines over the
radar image.

* perf(realtime): drop the SSE connection only on a real background

`inactive` is not a background: it fires for the notification shade,
the app switcher, an incoming call and every permission dialog — all
frequent and usually momentary. Tearing the socket down for those
reconnects constantly, costs more radio than the poll it saves, and
flaps a safety-critical feed through `offline` each time. pause() now
takes releaseTransport, onBackground passes it and onInterrupted
(inactive) does not; both stop polling, only a real background
(paused/hidden) releases the connection.

Also guard the reconnect with a generation counter: a backoff is an
un-cancellable pending future, and when the whole background stint fits
inside one backoff window the stale timer and the re-armed lazy open
both call _openConnection, which assigned _subscription unconditionally
— the first socket was orphaned, still subscribed, still receiving,
with nothing left to cancel it. One leaked SSE socket per background
cycle.

* perf(map): stop re-uploading an empty EEW source once per second

_pushUpdate ends with an unconditional _pushEew, and the RTS feed
notifies about once a second, so a calm feed was replacing the same
empty GeoJSON collection — a platform-channel round trip and a native
source replacement — every second for as long as the layer was
attached, including while the map tab was hidden (pausing the render
loop does not stop the Dart listener). A boolean now tracks what is
actually on the map; only the empty case is guarded, because while an
alert is live the wavefront geometry changes with the calibrated clock
and must still be sent.

* test(shell): pin that the route observer reaches the root navigator

VisibleTab.shellOnTop is only as good as the RouteObserver actually
seeing a full-screen route pushed over the StatefulShellRoute — if it
did not, the flag would stay true forever and every consumer would
silently keep believing it is on screen. Not a test, the analyzer, or
the app would fail; this pins the assumption.

* feat(more): announcements under Discord, notification log with notifications

The announcement row sat four deep in the links list although it is
how ExpTech reaches everyone at once; it now leads the page as a third
card, right behind the two calls to action — flat and neutral, since
it asks nothing of the user and must not compete with the Support
gradient. The notification log moves out of the links list into the
notification group, where you look when an alert did not arrive.

* fix(map): wind streaks survive rotation — no more stale-picture freeze

The streaks were an accumulation buffer: every frame drew the previous
picture back at a fade, stamped the new dots over it, and rasterised
with toImageSync — one synchronous GPU readback on the UI thread per
frame. When that readback returns a stale texture the overlay shows a
frozen picture while everything behind it runs perfectly: the sim
steps, the ticker fires, the painter is marked dirty, so no stall
watchdog can see it and no pan or zoom clears it. Rotating the map
hammers that path hardest, since every rotation frame discards the
buffer and demands a fresh readback.

A streak is now simply the last 14 frames of positions drawn together
with a linear alpha ramp — the same picture the fade produced, from
data the CPU owns outright, with no texture that can go stale. The
cost is a fixed number of extra drawRawPoints calls instead of one
composite; the low device tier shortens the tail (6 frames) instead of
coarsening the buffer, and the rotation bearing fix keeps the whole
field aligned with what MapLibre actually draws (north up at 90° was
drawn upside down before).

* fix(map): opening a settings page no longer re-fetches the whole timeline

VisibleTab reports two independent things through one notifier —
which branch is selected, and whether a page covers the shell. The map
scaffold read "the notifier fired" as "the tab came back", so every
cover and uncover re-loaded the active timeline: the radar re-fetched
for the act of opening settings, and again on the way back. _onTabChanged
now fires on the hidden → visible *edge* instead, tracked via _wasVisible;
VisibleTab's contract is pinned by a new test group.

* perf(map): reseed the wind field once per gesture, not per zoom frame

The particle population is a function of zoom, so a pinch re-sizes it
on every frame: growing seeds particles into a viewport that is still
moving, shrinking truncates the list, and the field arrives at the
final zoom carrying whatever that churn produced. The layer now tears
the field down for the whole gesture and reseeds once on release —
cheaper, and always correct, since the reseed starts from the camera
the gesture settled on.

* perf(home): invisible rain card no longer steps two water solvers per frame

Fading the hero card to opacity 0 stopped the ticker, which made the
resume path (scheduled by the stopped-ticker check) restart it — so a
card whose painter returns without drawing a pixel kept advancing two
solvers and marking the frame dirty every vsync. The opacity term was
missing from both the stop-check and the resume gate; they now share
one gate set, so a resume can never undo a stop it is not allowed to
beat.

* perf(home): blur 0 is a full-cost blur, not a free no-op

A sigma of 0 still pushes the layer, reads the backdrop back and
resamples it — and the sheet quantises its travel to 6 steps, so the
top step lands on exactly 0 as the *resting* posture at full extent,
directly over the map platform view: the backdrop-filter path Flutter's
own docs call out as expensive on iOS. BackdropFilter.enabled
short-circuits in RenderBackdropFilter.paint before the filter resolves
or the layer pushes, while the widget/element/render tree stay in place,
so no re-parent flash. The blur re-engages as soon as the surface starts
moving again.

* fix(time): calibrated clock everywhere a server or device timestamp is read

Audit of DateTime.now(): most uses are legitimately wall-clock (LRU
last-used ordering, elapsed measurements, log retention, relative UI
readouts), but four compared against stamps that were written with
AppTime or that must not move with a user-set clock:

- nowFrameIndex's default clock was the wall clock while the frame
  times it orders are server timestamps; every caller already passed
  AppTime.utc, so the fallback is now the only thing that could have
  drifted.
- The location last-known-fresh gate (10 min) used the wall clock: a
  user who set their clock forward ages a good fix out (a wasted
  10-second request), set it back and the app keeps trusting a fix
  that is 20 minutes old — the latter is wrong-place hazard data.
- The mesh "x ago" readouts compared AppTime-stamped samples against
  the wall clock; one baseline everywhere now.

* perf(storage): open the durable database in WAL

Every commit on the rollback journal is a journal write, an fsync, a
directory fsync, the page write-back, the journal delete and another
directory fsync — several barriers and a double write of every changed
page, for the handful of rows a buffered log flush or a mesh-node update
actually touches. WAL appends those pages to one long-lived file and
defers the write-back to a checkpoint that amortizes over many commits.

`synchronous` stays FULL here. Settings, the mesh conversation and the
log cannot be fetched again, so a commit still fsyncs before it counts;
WAL removes the journal dance, not the durability.

Configured in `onConfigure` because that is the only sqflite callback
that runs outside a transaction, and `journal_mode` cannot change inside
one. Best-effort, like the open itself: a database that will not take WAL
keeps working on the rollback journal.

The HTTP cache file is deliberately left alone. VACUUM does not truncate
a WAL database — the reclaimed image lands in the -wal and the file only
shrinks at a checkpoint, and this app never closes the connection — so
putting it in WAL would quietly break what the developer page promises
when it says "the database file is compacted".

* perf(startup): finish the decode on the isolate that already has the data

Both of these already move their expensive work off the UI isolate, and
both then hand the result back in a shape that costs almost as much to
adopt as it did to produce — inside the first-frame window, which is the
one place the isolates exist to protect.

TownBoundaries.fromDecoded: the decode isolate builds each ring as a
Float64List and typed data survives the hand-off as itself, but the
comprehension re-listed it anyway — boxing all 350k vertices into a
growable List<dynamic>, with its doubling reallocations, then copying
them a second time into a fresh Float64List. Taking the buffer as it
arrives measures 10.4 ms -> 0.45 ms on the bundled boundary set.
Aliasing is safe: rings are only ever read, and both callers discard the
decoded map on return.

SeismicTravelTimeSource: the isolate only gunzipped and parsed the JSON;
the 25,016-record table was then assembled on the UI isolate — 106
int.parse, 25,016 record allocations and 75,048 num->double casts.
Building it inside the isolate also shrinks what crosses the boundary,
since records go over instead of the JSON maps they were built from.

The comment claiming a 35 KB asset loaded when the replay map opens was
wrong on both counts: it is 213 KB on disk, 834 KB inflated, and it is
loaded at bootstrap.

* perf(cache): touch and meter the tile cache once per batch, not once per row

The native tile bridge asks for tiles in batches, and readBytesBatch was
turning each batch into per-row bookkeeping: an LRU-touch write and a
cache-hit metering write for every tile it returned. During a pan or a
radar scrub that is a write transaction every few milliseconds, on the
same file the rest of the app is reading through.

Both are now folded to one write per batch, behind a single arming path
so the two cannot drift apart. The LRU flush no longer fires for a sweep
that will not run, and the eviction scan pages through victims instead of
materialising the whole table across the platform channel to drop a
fraction of it.

Retention and budget semantics are unchanged — the same rows are evicted,
in the same order, at the same threshold.

* perf(home): dispose every ui.Picture handed to toImageSync

toImageSync rasterises the picture but does not take ownership of it, so
each of these left the recorded display list alive with nothing holding a
reference to free it. The card-water composite does it on every painted
frame, twice, for as long as rain is running — the sprite bakes only
once per mount, but they leak just the same.

The image outlives the picture in every case here: toImageSync has
already produced it, and the two sprite bakers read the image out before
returning, so the picture has no remaining reader at the point it is
dropped.

* perf(home): compare the sparkline's samples by value, not by identity

_TempSparklinePainter.shouldRepaint compared a List<double> the enclosing
build had just allocated, so the identity check was unconditionally true
and the painter reported dirty on every rebuild.

HomeForecastSection sits inside HomeContent's ListenableBuilder on the
sheet's scroll controller, which rebuilds the whole hero panel on every
scroll tick. Past the first 140 px every other derived value in the
dashboard has clamped, so this was the only thing still dirtying paint —
re-rasterising the dashboard layer at display rate to produce
pixel-identical output for the rest of the gesture.

listEquals over the samples, plus the colour, which is the only other
input the painter reads.

* chore(deps): bump the maplibre fork to the working setRenderPaused

ExpTechTW/flutter-maplibre-gl 0674b9be -> 37e4eb9d, which makes map#pause
actually stop a map on both platforms. Until now setRenderPaused was
inert everywhere: iOS raised the display link to its maximum rate instead
of lowering it, and Android called a renderer method whose body is a bare
return. Every hidden tab's map has been rendering at full rate.

BaseMap already issues the calls, so nothing here changes — the plugin
side just starts honouring them.

* feat(mesh): a day of mesh history, and one retention schedule

Restores the half of the channel-name fix that was left out of the tree:
`meshtastic_page` was committed calling `controller.channelNames` while the
controller that defines it was not, which broke `flutter analyze` and, being a
compile error, the Android build with it.

Mesh history, kept 24h:
- the radio's own pack voltage (percent pins at 101% on external power, so the
  volts are the only figure that shows a cell ageing) and how many nodes it
  could see, total and online — a coverage collapse shows there first;
- each neighbour's battery, voltage and SNR in a new `mesh_node_metrics`
  table, per node rather than averaged. The in-memory ring it replaces is
  bounded by count, so on a busy mesh it held minutes.

Retention is now one service on one schedule (start, then hourly) instead of
each store pruning on whatever it happened to be doing. Three stores never
pruned on an idle app at all: `LogStore.flush` and `NetworkUsageStore.flush`
both return early when nothing is buffered, and they were the only things that
trimmed; `Log.pruneOlderThan` — the in-memory Talker ring the log viewer reads
— was never called from anywhere.

Debug page: per-table row counts and bytes, biggest first. "The database is
40 MB" is not actionable; "mesh_node_metrics is 38 MB across 900,000 rows"
names both the table and the window that is wrong.

* fix(location): never leave Android without a background spine, and show why

Two halves of one investigation into why Android background location stops
reporting. They are one commit because the fix and the readout that proves
it worked touch the same methods.

THE FIX. BackgroundLocationChannel's "start" cancelled the alarm fallback
before it tried to arm the geofence, and arming is all-or-nothing on
FusedFix.get() returning a location — which fails whenever location is off
at that moment, the 15 s BALANCED request times out indoors, or there is no
fresh cached fix. The device was then left with no geofence, no alarm and
nothing scheduled to retry, so reporting stopped silently until the user
next opened the app. The alarm now stays until Play services confirms a
fence is live, and every other path out of armGeofence re-schedules it.

Confirming that needed GeofenceManager.register to say whether it worked;
addGeofences is asynchronous, so returning told the caller nothing. It
reports through an optional callback rather than a blocking await because
LocationBootReceiver and GeofenceReceiver's error path both call it
straight from onReceive, where Tasks.await throws.

THE READOUT. A "Background location" section on the developer page, fed by
a new `diagnostics` method both platforms answer with the same keys.
It reports whether something is monitoring *now*, which mechanism, the
authorization, whether native holds a push token, where the fence or region
is centred, and when the last report was attempted and how it went.

Armed is deliberately observed rather than remembered. iOS accepts
startMonitoringSignificantLocationChanges() without Always authorization
and then delivers nothing, so a stored "we called start" bit would read
healthy on exactly the broken device; it is derived from monitoredRegions,
the one thing Core Location confirms back. Android's Geofencing API cannot
be queried at all, so the arm result is recorded when it lands and cleared
on removal, refusal and disable; the alarm is probed with FLAG_NO_CREATE.

Recording the last report — including failures, with the status code — is
what separates "never fires" from "fires and cannot reach the server".
Both platforms previously swallowed the outcome, so the two looked
identical from outside.

The three Dart gates that silently disable everything (no push token, no
"Always" grant) now log, so the in-app log page can answer this from a
user's phone instead of needing logcat.

* fix(location): every failed geofence arm falls back to the alarm

bc5ee1c2 closed this hole in one of the three places that arm a geofence
and left the other two open, which is the worse half: the channel only
arms when the user opens the app, while the receivers are what keep a
closed app reporting.

The reachable failure is a user turning Location off. Play services drops
the fence and broadcasts GEOFENCE_NOT_AVAILABLE; GeofenceReceiver.reArm
re-registers in the same breath — while Location is still off — so
addGeofences refuses. It passed no result callback, so that refusal only
set armed=false and wrote a logcat line. The alarm had already been
cancelled when the fence first armed, so the device was left with no
fence, no alarm and nothing that would ever notice. Turning Location back
on does not help: Play services does not restore removed geofences and no
broadcast brings us back, so reporting was over until the next app open.

The fallback moves out of BackgroundLocationChannel into
LocationAlarmScheduler.ensure, and all five register call sites now act on
the result:

  channel start          fail -> alarm   (already did)
  exit re-centre         fail -> alarm
  reArm after an error   fail -> alarm
  boot re-arm            fail -> alarm
  alarm fire             ok   -> cancel the alarm

That last one is the way back up. Without it a device degraded to the
alarm stays there, paying Doze-throttled wakeups the geofence would not,
until someone opens the app — and the users this spine exists for are
exactly the ones who do not.

LocationBootReceiver also gains the goAsync() the other two receivers
already had. Registration is a binder call into Play services, so
returning from onReceive can strand it; and BOOT_COMPLETED regularly
lands before GMS location is ready, where a not-yet-initialised network
location provider returns the same GEOFENCE_NOT_AVAILABLE — a boot that
failed to arm used to leave the device silent.

* perf: cut steady-state churn on the hottest per-frame paths

Equivalence-preserving only — every change is a hoist, a strength reduction,
or a buffer reuse, pinned by the existing parity/golden suites (wind web
parity, card water pipeline, sky gradient, EEW goldens), all unchanged.

Map:
- mesh node layer: coalesce store notifications into one trailing GeoJSON
  push. The store notifies per packet and a config download replays the whole
  node table — ~250 notifications, each re-serialising a 250-feature GeoJSON
  across the platform channel, which is what the connect-time jank was.
- mesh node sort: resolve the online cutoff once per access instead of per
  comparison (thousands of DateTime subtractions a second on a busy mesh),
  and give the page badge a count that does not pay for the sort.
- wind sim: hoist the four view-bound products out of the 6400-particle loop;
  stamp and record the trail head in one pass instead of two; divide by the
  speed scale once, not per particle.

Home:
- card water solver: reuse per-iteration scratch buffers (four Float32Lists
  five times per 20 ms tick — a thousand allocations a second of GC churn
  while it rains) and hoist diameter² out of the neighbour search.

One observable timing change, deliberate: mesh nodes now land on the map at
most 250 ms after the store hears them, instead of once per packet.

* feat(permissions): let the user stop Android hibernating the app

Android's unused-app restrictions end background reporting for exactly the
people it exists for: someone installs DPIP, grants "Allow all the time",
and never opens it again because no disaster has happened. After a few
months of no interaction Android revokes the runtime permissions, and from
Android 12 force-stops the package and clears its caches. targetSdk
resolves to 36, so both apply.

Nothing in the app survived that or noticed it. A force-stopped package is
in the stopped state and receives no broadcasts at all — BOOT_COMPLETED
included — until the user launches it by hand, so the boot re-arm cannot
recover it; on Android 15 force-stop also cancels the app's PendingIntents,
taking the geofence and the fallback alarm outright. BgLocationStore.enabled
stayed true throughout, so every internal check still read healthy.

The exemption is a user-set system toggle, not a permission, so the new row
reports the state and opens the page rather than requesting anything. It is
separate from the existing battery-optimization row: that one covers Doze
and does nothing about this.

Three states, not a bool. `unavailable` hides the row entirely — a device
too old for the API, or without the Play services that back-port it, has
nothing the user could change, and an un-actionable warning on a disaster
app's permission page is worse than no row. DISABLED is the good state (the
user has turned restrictions off); every flavour of "on" reports the same,
because the action required is identical and the API level is already on
the developer page.

Both androidx dependencies are pinned explicitly rather than taken from the
transitive graph. concurrent-futures is needed because
getUnusedAppRestrictionsStatus returns a ListenableFuture and androidx.core
declares guava's listenablefuture without putting it on a consumer's
compile classpath.

Not covered here: OEM "sleeping apps" managers (Samsung, MIUI, EMUI) apply
the same stopped state on a days-not-months timescale and are not reachable
through this API — they need the user to exempt the app in the vendor's own
battery UI.

* perf: stop invisible work — rebuilds, timers, UI-isolate JSON, launch cost

Two audited rounds (findings adversarially verified against the code before
any edit; 26 plausible claims refuted and dropped). Everything here is
behaviour-preserving; the full suite, golden parity tests and gates pass.

Rebuilds that served nobody:
- report sheet: dragging re-grouped and re-sorted every felt township per
  frame — the expanded/peek subtrees are built once per State build and the
  extent builder now short-circuits them by identity
- home page watches only the freezed weather slice (identical fetches no
  longer rebuild the dashboard), and the scroll-driven panel rebuilds only
  while a dial can still change (all three saturate by 200 px)
- mesh chat: the controller no longer blanket-forwards node-store
  notifications (every packet rebuilt the page); badge, node sheet and
  sender names each select their own slice
- moon page: the fragment shader is minted once at load — a new native
  shader per rebuild kept shouldRepaint permanently true and leaked the
  old instance every timeline tick

Timers that outlived their audience:
- the three EEW countdown cards share a SecondTicker mixin: stopped under
  the lock screen and behind other tabs, snapped current on return (a Timer
  is not a Ticker, so TickerMode and lifecycle never reached them)
- RTS: station GeoJSON uploads and the 5 Hz wavefront ticker stop while the
  map surface is hidden (tab switch or background) and flush once on return;
  polling itself never stops — it is a safety feed
- replay: page-scoped RTS/EEW channels (deliberately outside
  RealtimeService) now pause on background, as does the 1 Hz blink; also
  fixes the frozen last wavefront — the isEmpty skip made the clearing
  write unreachable once a replayed alert expired
- retention's first sweep waits out the launch window; screen wake on the
  mesh page holds only while a radio is connected

UI-isolate JSON, both cache paths:
- 304 revalidation (the hottest path: 65-130 KB station catalogues that
  almost never change) parses inside the store's existing gunzip worker
  hop via readJson — Dio's own isolate offload never sees a 304
- fresh 200s encode the body once instead of twice (byte metering is
  bit-identical)

Launch and memory:
- town boundaries decode overlaps the DB opens; schema re-runs collapse
  ~12 serial round trips into batches; the location reporter no longer
  queues behind a cold GPS fix
- moon textures (~10 MB RGBA), the rain-card sprite bake and the star
  catalog are decoded once per app run instead of per open (the per-mount
  sprite bake also leaked); icon bakes dispose their Picture/Image handles

* feat(map): put township labels in the middle of the township

The label was drawn at TownDirectory's point, which is the administrative
seat, not the middle. For a mountain township that is the inhabited valley
at one corner of a shape running tens of kilometres into the range:
臺中市和平區's label sat 44 km from where the name belongs. Across all 367
the median label was 2.1 km out and 184 were more than 2 km out.

The new point is the pole of inaccessibility — of every point inside the
polygon, the one furthest from any edge. That is the standard place to
label an area, and unlike a centroid it is always inside the shape, which
a centroid is not for anything as concave as Taiwan's coastal and mountain
townships. Computed offline with Mapbox's polylabel and baked into a
generated table, because the search is far too slow to run at startup.

TownDirectory's own lat/lng is deliberately untouched. It anchors
TownDirectory.nearest, the GPS→township fallback used at sea, in boundary
gaps and before the polygons load, and that answer decides which township
an alert is addressed to. The nearest settlement is the right answer there;
the geometric middle is not.

Picking which polygon is the township's main body needed care. Choosing
the roomiest put 雲林縣口湖鄉's label in the Taiwan Strait: it carries a
second polygon that is a 9-vertex box over ~270 km² of sea, far roomier
than its ~80 km² of land, and the label was still legitimately "inside
口湖" so the obvious test passed. It now prefers the polygon containing the
administrative seat, which is on the main body by definition. 23 townships
have more than one polygon; that rule resolves 22 of them.

The label GeoJSON is also memoised. It is a ~41 KB string over 368
features rebuilt on every BaseMap build, and it only changes when the
directory instance does — once, at bootstrap.

One thing this surfaced that is not a label problem: 新竹市香山區 has no
polygon at all in the boundary source, and 新竹市北區's covers its ground,
so TownBoundaries.codeAt answers 北區 for a GPS fix anywhere in 香山. That
misroutes township-level alert targeting for everyone there and can only
be fixed in the source data. The test pins the gap at exactly that one
township so a wider one is caught.

* feat(more): warn when a permission is missing, and give the mesh its own section

Two changes to the same page, from one request.

THE DOT. Nothing outside the permission page said anything was wrong, so a
user whose notification or location grant had lapsed — or whom Android had
quietly revoked after months of not opening the app — had no reason to go
looking. A Material badge now rides the More tab and the 權限檢查 row it
leads to, both reading one app-wide PermissionHealth so the two surfaces
cannot disagree.

It counts only the three the app cannot work without: notifications (no
alert is delivered at all), foreground location (nothing knows which
township to warn about), and background location (the township goes stale
the moment the app closes). The Android battery exemption and unused-app
restrictions are deliberately excluded even though they matter — they are
optimisations a user may knowingly decline, and a dot that…
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.

1 participant