Skip to content

Latest commit

 

History

History
381 lines (337 loc) · 22.1 KB

ARCHITECTURE.md

File metadata and controls

381 lines (337 loc) · 22.1 KB

DPIP Architecture

A clean, feature-first + layered architecture. The goal is that any feature can be understood, tested, and changed in isolation, and that cross-cutting concerns live in exactly one place.

Top-level layout

lib/
├── main.dart              # entry point → bootstrap()
├── bootstrap.dart         # init platform services (Firebase, prefs, SNTP, DI) then runApp
├── app/                   # app shell: wiring, not features
│   ├── app.dart           # root MaterialApp.router + providers
│   ├── router/            # go_router route table (central AppRoutes registry)
│   ├── shell/             # bottom-nav shell (StatefulShellRoute)
│   └── theme/             # Material 3 tokens (spacing/radius/motion/colour/glass)
├── core/                  # cross-cutting, feature-agnostic building blocks
│   ├── di/                # SharedDeps assembly + coreProviders (provider wiring)
│   ├── error/             # Result + Failure hierarchy
│   ├── geo/               # location services, township directory/boundaries
│   ├── logging/           # the Log facade
│   ├── meshtastic/        # LoRa mesh: BLE transport, link keeper, DPIP data plane
│   ├── models/            # shared value types (LatLng, …)
│   ├── network/           # ApiClient + ApiTier + ApiPaths, region failover,
│   │                      # SSE, ETag cache, meteor delta decode
│   ├── notifications/     # FCM/APNs + awesome channels, tap routing seam
│   ├── platform/          # native-first device_info / compass / battery / tier
│   ├── realtime/          # polling spine (channel/state/clock/staleness/SSE)
│   ├── settings/          # typed Prefs facade + persisted/ephemeral controllers
│   ├── storage/           # SQLite stores (ETag cache, network usage)
│   └── weather/           # weather-condition / icon mapping
├── features/              # one folder per feature, each self-contained
│   │                      # changelog · disaster_map · earthquake · events ·
│   │                      # home · location · log · map · meshtastic · more ·
│   │                      # notification · onboarding · settings · sponsor ·
│   │                      # typhoon · weather
│   └── <feature>/
│       ├── data/          # datasources, repository impls, JSON→model mapping
│       ├── domain/        # @freezed entities, repository interfaces (pure Dart)
│       └── presentation/  # pages/, widgets/, controllers (state)
└── shared/                # reused across ≥2 features
    ├── map/               # the reusable MapLibre surface (BaseMap, layers, timeline)
    ├── navigation/        # AppRoutes (route names/paths)
    ├── seismic/           # intensity/report colour scales
    └── widgets/           # AsyncView/RealtimeView, region bar, …

Layer rules

  • presentation depends on domain; never on data directly.
  • data implements domain repository interfaces and maps exceptions to core/error Failures.
  • domain is pure Dart (no Flutter, no Dio) — the most testable layer.
  • core and shared must not import from features.
  • Features must not import each other's internals; share via shared/ or a domain contract.
  • App-wide state several features consume lives outside any one of them — core/settings/ for ExperimentalSettings, for instance.

All five are enforced by tool/check/layering.sh, which CI runs first.

Conventions

  • Route names/paths live in shared/navigation/app_routes.dart (AppRoutes); pages don't declare their own path. The router (app/router/) is the only place that imports a page widget — navigate by name (context.pushNamed(AppRoutes.log)), never by importing another feature's page to reach its route. The layering gate rejects it.
  • Models use @freezed / @JsonSerializable; run dart run build_runner build after changing them.
  • One public declaration = one clear responsibility; extract private widgets (_Foo) rather than deeply nesting build.

Adding a feature

  1. lib/features/<name>/{data,domain,presentation}.
  2. Define the domain @freezed entity + Repository interface returning Result<T> (pure Dart — the gate forbids Flutter/Dio/data imports here).
  3. Implement data: a thin datasource calling core/network's ApiClient (with its own ApiTier) + a repository impl that maps errors via guardResult.
  4. Build presentation (page + controller); add the route to AppRoutes and the table in app/router.

Native config

Platform integration (Firebase, APNs critical-alerts, signing, permissions) is restored per-need in android/ and ios/. Firebase initializes from the generated lib/firebase_options.dart (DefaultFirebaseOptions) — not the native plists — so init never depends on a bundle resource; Android also keeps google-services.json for the GMS plugin.


Contracts

Each of these is a rule the code is expected to follow, not a description of what it happens to do. They live here so there is one copy: CLAUDE.md and AGENTS.md point at this file rather than restating it.

Logging

Always log through Log (lib/core/logging/log.dart): Log.debug / info / warning / error / handle. Never print or debugPrint — avoid_print is an error in analysis_options.yaml, so it fails analysis.

Uncaught Flutter/async errors are captured automatically (Log.installErrorHandlers() in bootstrap.dart). The log is persisted to the logs table with a rolling 24-hour retention (core/logging/log_store.dart) and a row-count backstop under it, so no clock event can empty it. Writes are buffered and flushed on a timer, on a burst, and when the app backgrounds — a log call never costs a database round trip, and the store never throws (it is called from error handlers, where a failure would replace a diagnostic with a crash).

In-app viewer: the App 日誌 page (More tab → LogPage), which replays the last 24 hours from the table on open, so it covers the launch that crashed rather than only the current one.

Crash reports leave the device from prerelease builds only — debug, profile, and a release build release.yml stamped --dart-define=DPIP_PRERELEASE=true (a snapshot). Everything else is production, including a release build with no mark at all. Crashlytics is linked into every build with collection off by default (AndroidManifest.xml, Info.plist); PrereleaseDiagnostics.start (core/diagnostics/) turns it on for a prerelease and, because the SDK remembers the switch across launches, turns it off again on every production launch. ReportingCrashSink then drops transport failures, repeats, and anything past eight reports a session. Only errors and explicitly set keys are sent — never Log lines, some of which carry coordinates. A profile or release prerelease also files one launch summary per session (startup_report.dart): the bootstrap phase marks plus 20 s of frame timings taken while Home or the map tab is on screen, as a single non-fatal StartupPerformanceReport with the numbers in custom keys.

State management

provider. bootstrap() assembles the shared infrastructure into a SharedDeps (core/di/shared_deps.dart) and hands it to each feature's *Providers(deps) aggregate (wired through core/di/core_providers.dart); feature state lives in the feature's presentation.

Networking

never call hosts directly — use the region-aware ApiClient (core/network/api_client.dart) with an ApiTier from core/network/api_region.dart (LB vs Core, exclusive vs multi-active) and path constants from core/network/api_paths.dart. No DNS-balanced bare hosts. See api.md. ApiClient fails over to the next region only on transient/server faults (connection drop, timeout, 5xx) and logs each failover; a 4xx or a cancellation throws immediately (it would recur on every region). Pass a CancelToken to abort a superseded request. SSE streams go through ApiClient.openStream + core/network/sse_client.dart. Fatal and handled errors forward to the CrashSink set on Log — Crashlytics, in a prerelease build (see Logging).

Data & errors

models are @freezed value types with generated fromJson/toJson (@JsonKey for wire names; boolishInt/intFromBool for 0/1 bools) — template: features/earthquake/domain/eew.dart. A feature exposes a Repository interface in domain/ returning Result<T> (core/error/result.dart); the impl lives in data/, owns JSON→model mapping, and converts transport/decode errors to a typed Failure (core/error/failure.dart) via mapException — never leak raw JSON or a DioException above the data layer, and never try/catch-swallow into a blank UI. strict-casts/strict-raw-types are on. Run build_runner after model changes; add a fromJson round-trip test.

A list that is still true when old (past reports, event history) may opt into stale-if-error: its data source calls getOrSaved (an ApiClient extension in core/network/stale_if_error.dart), and when every region host fails the ETag cache's last body comes back as an Ok with stale set — why, and since when the server last vouched for it. Never for an alert feed, and never shown without that marker: AsyncView and FreshnessBanner render it.

Realtime feeds

live feeds (EEW now, RTS later) flow through the polling spine in core/realtime/. A RealtimeChannel<T> polls a RealtimeSource<T> (returns Result<T>) on a fixed cadence and exposes Stream<RealtimeState<T>> with a connecting/live/stale/offline status. Freshness is a pure, golden-pinned function (staleness.dart) measured against a corrected ServerClock — a feed ages to stale/offline on its own when polls stop, and a failed poll keeps the last data (never blanks). Add a feed by implementing RealtimeSource (in data/) and subclassing RealtimeNotifier (in presentation/) — no engine change; the transport lives entirely behind the source seam. EEW now streams over SSE (sse_realtime_source.dart holds one connection, buffers the latest event, and answers each poll from that buffer — Ok while connected, Err while reconnecting — so the channel/state/staleness are untouched); the data: payload is the same JSON the GET returned. A bursty feed (EEW, silent between events) uses connection-open liveness; a continuous feed (RTS, ~1 Hz) uses event-recency. Neither trusts an open socket on its own: the servers write a : ping comment on an idle stream, the parser surfaces it, and a connection that receives nothing at all for sseSilenceLimit (2.2 × the ping interval, sse_client.dart) is half-open — a handover or NAT timeout that never sent a FIN — so the source answers Err and replaces it, and silence never reads as calm. A change of network (core/platform/network_changes.dart: Android's default-network callback, iOS's NWPathMonitor) is fanned out by RealtimeService to every source, and an SSE source drops its connection at once and reopens after a random 1–2 s settle with its backoff reset, rather than waiting out the silence limit; the backoff itself is jittered ±50 % so a region's audience does not reconnect in lockstep. While the EEW stream is down (past a 3 s grace), its source polls the one-shot GET every 10 s through the same decode and agency filters and answers from that snapshot, flagged as a fallback (RealtimeSource.servingFallback): the channel shows it but never classifies it better than stale (classifyStatus(fallback:)), since a snapshot every ten seconds cannot keep live's promise, and polling stops the moment the stream answers. RealtimeService + AppLifecycleListener pause polling on background and resume (recompute status → resync clock → refetch) on foreground; background alerting is push's job. A safety-critical feed that is stale/offline must never be presented as current.

Calibrated time

the ServerClock corrects device time via real SNTP (ntp_time_source.dart → flutter_ntp, UDP/123, time.exptech.com.tw primary / time.apple.com backup), anchored to a monotonic clock so a device-clock or timezone change can't move it (only a resync does).

When both SNTP hosts fail, fallback_time_source.dart drops to https://api.lb.exptech.dev/ntp (http_time_source.dart; the iOS widget mirrors it in WidgetHTTPTimeSource.swift) — corporate Wi-Fi, hotel and campus networks block UDP/123, and there the alternative is no calibration at all. The order is precision, not preference: SNTP resolves "now" to about a millisecond, one HTTPS round trip only to ±RTT/2. This is the one bare host the repository requests, because a clock reading has no region to pin; the body is never trusted on its own but checked against the Cloudflare-stamped Date header of the same response, which is what catches a backend whose own clock is wrong — see api.md for the 15.7-second measurement that made the check necessary. RealtimeService resyncs it every 60s (paused in background, resynced on resume). Read corrected time anywhere via the global AppTime facade (AppTime.utc / AppTime.utc8, the fixed-offset Taipei wall clock) — never DateTime.now() for anything the server timestamps. tool/check/wall_clock.sh holds every device-clock read in lib/ to that: each one carries // wall-clock: <reason>, on its line or in the comment directly above, saying why device time is right there — a store that only compares its own stamps, an OS stamp aged by the clock that made it, the clock being calibrated.

Async-state UI

render async/realtime state through the shared views in shared/widgets/, never a hand-rolled FutureBuilder/Consumer that drops the error or stale case into a blank screen. AsyncView<T> maps a one-shot Future<Result<T>> to loading/data/empty/error (with retry), and a saved copy (Ok.stale) to data under a FreshnessBanner with its age; RealtimeView<T> maps a RealtimeState<T>, adding connecting/stale/offline and the same banner so aged safety data is never shown as current. Building blocks: LoadingView / ErrorView / EmptyView. Template consumer: features/earthquake/presentation/pages/earthquake_page.dart.

Push notifications

(core/notifications/): awesome_notifications_fcm owns the remote-push transport (token + message receipt) on both platforms; awesome_notifications renders the rich per-channel notification and routes taps. firebase_messaging is not a dependency — upstream says the two must not coexist, and its jobs are covered: awesome_notifications_fcm's token handlers replace its refresh stream, and on iOS the app's own notification-centre delegate decides foreground presentation. Keep Firebase pinned (firebase_core; a newer major raises the min iOS target). The push token is the raw APNs device token on iOS, read from the app delegate (ApnsTokenPlugin), and the FCM registration token on Android; NotificationService stores only the platform's own kind, because the backend keys on it and registering the other one fails silently. Backend sends data messages (channel/id/title/body, or the same fields packed into a JSON content string); onFcmSilentData displays them via awesome so each honours its channel. A notification-payload message in the background is shown by the OS — on Android against the plain channel ids PlainChannels mirrors, so it still gets its channel's sound. On iOS, a push published straight to APNs is one awesome cannot present in the foreground; NotificationDelegateProxy in AppDelegate.swift presents it and forwards everything else to awesome. Alert categories live in notification_channels.dart — bump version when one changes so Android re-creates it. Permission requests go through NotificationService, which asks the plugin to prompt only when the OS status is notDetermined: once iOS has been told "no", awesome opens the settings page itself and parks its completion until the app returns, so the awaited Future can simply never come back. A decided state therefore returns PermissionOutcome.needsSettings without calling the plugin, and every request carries a timeout so a button can never be inert. Channels register as one batch; if that batch is rejected the service falls back to registering them individually, so one bad channel costs only itself and is named in the log instead of leaving the app with no channels and no push transport. (initialize needs at least one channel, so the fallback seeds itself with the first one that is accepted.) tool/check/notification_sounds.sh catches an unresolvable resource://raw/<name> before it ships. Taps funnel through NotificationTaps (core carries the channel key; app/ maps it to an AppRoutes tab). Notification permission is asked for from onboarding and from 更多 → 權限檢查 (PermissionChecklist), never at launch. External, not code: push only works on a physical device; on Android, FCM registration needs the app's signing SHA-1 in the Firebase console; the backend publishes iOS pushes straight to APNs, so the APNs auth key in the Firebase console matters only to a message sent through FCM. The push token registers through the location feature (/v2/location, DeviceLocationReporter in core/geo/) on meaningful moves, and DeviceRegistration reports once more whenever the stored token changes and once a day without a successful report — a device that never moves would otherwise never tell the server its new token.

Delivery breadcrumbs (iOS). ios/NotificationService is a Notification Service Extension that records one line per push it sees — the alert's id, its channel and the APNs request id, never the text — into the app group's native log ring, which bootstrap drains into Log (importNativeLogs). It hands the content back unchanged within 200 ms whatever the write does. iOS runs it only for a push whose aps has "mutable-content": 1 alongside an alert; until the backend sets that field, the extension is installed and never started, and pushes behave exactly as before.

LoRa mesh

the off-grid path. Three layers, each with one job. MeshtasticService (domain) is the transport — connect/scan, packet streams, sendData, and the radio's channel/region config; its BLE impl lives in data/ over the vendored third_party/meshtastic_flutter (locally forked — see its CHANGELOG). MeshLink (created in bootstrap, not by a page) owns the session: the chosen radio is persisted and is the intent to stay connected, so it survives page changes, drops and app restarts; only detach() stops it. It also provisions the radio after every connect. DpipMeshGateway is the data plane: DPIP disaster payloads ride PRIVATE_APP (256) inside a 5-byte versioned envelope (dpip_mesh.dart) on the fixed DPIP channel (PSK AQ==, region TW) — never on TEXT_MESSAGE_APP, which belongs to the user's chat. A feed broadcasts by handing over a DpipMeshPacket and receives by listening to inbound; it never sees Bluetooth. Wire codes and the envelope layout are pinned by tests — changing one is a protocol break, so bump the version instead. Mesh delivery is best-effort (lossy, duty-cycle limited, unacknowledged): a safety-critical feed may add it as a path, never rely on it as the only one. Radio writes go through local admin messages (from == 0 exempts them from the remote-admin session key); a channel write is read back before it counts, and a region change is confirmed by the user because the firmware reboots the radio and takes every other channel with it.

Native-first

prefer platform channels / built-ins over third-party plugins where practical (e.g. core/platform/ device_info, compass).

Persistence

everything persisted lives in SQLite, split into two files by durability: dpip.db in application-support (settings, tle, mesh_*) and http_etag_cache.db in the platform cache directory (http_cache, net_bucket). The split is the point — the OS may empty the cache directory whenever it wants space, so nothing that cannot be re-fetched may live there, and AppDatabase.clearCache() holds no handle to the durable file, which makes "clear cache deleted my settings" unavailable rather than merely avoided. Tables are one-per-category and each is owned by exactly one store (core/storage/app_database.dart documents the map). Settings go through the typed SettingsStore facade (core/settings/settings_store.dart), keyed by a SettingKey<T> from the SettingKeys registry (core/settings/setting_keys.dart) — never a raw string. SettingKey's constructor is private, so a key can only be minted in the registry; SettingsStore has no String-taking overload, so an ad-hoc key can't reach storage — the compiler rejects it, not review. Reads are synchronous (the table is loaded into memory once at bootstrap); writes are async and log rather than throw. Add a setting = add one SettingKey<T>; never change an existing key string without a migration. shared_preferences is not a dependency — nothing may import it — and only a table's owning store may import sqflite; both enforced by tool/check/storage.sh.

The device's movement history is a third file, location_track.db in application-support, outside that split because Dart does not own it: the native background-location path writes it (LocationTrackStore on both platforms, every background fix, delta-encoded, 50 MB with oldest-first eviction) and Dart only reads it, through a read-only connection (core/geo/location_track.dart). It never leaves the device. Nothing uploads a position from it — a diagnostics dump the user sends carries only its fix count and size — it is excluded from backups (iOS isExcludedFromBackup, Android allowBackup="false"), and uninstalling the app or clearing its data deletes it. Recording has no switch of its own — it follows the background-location grant — and the map's 軌跡 layer, which shows it, says all of this in its sheet; its clear goes through the native side, never a file delete from Dart.

  • Every file starts with a doc comment; one public declaration = one clear responsibility.