fix: earthquake 頁面 - #3
Merged
Merged
Conversation
This was referenced Nov 10, 2025
Merged
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…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.