Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,11 @@ jobs:
# `DPIP_LABEL` also rides in as a dart-define, which is what the app
# itself displays on every platform.
#
# `DPIP_PRERELEASE` is the only thing that lets a release-mode build send
# crash reports (lib/core/diagnostics/prerelease_diagnostics.dart): `true`
# for a snapshot, `false` for a tag. Without it a build counts as
# production, so leaving it out fails safe — and silences the testers.
#
# Android builds twice on purpose: the APK is what a person sideloads from
# the GitHub release, the AAB is the only thing Play accepts.
- name: Build
Expand All @@ -301,7 +306,8 @@ jobs:
--dart-define=DPIP_LABEL=${{ needs.version.outputs.label }} \
--dart-define=DPIP_CODE=${{ needs.version.outputs.code }} \
--dart-define=DPIP_TRAIN=${{ needs.version.outputs.train }} \
--dart-define=DPIP_DATE=${{ needs.version.outputs.date }}"
--dart-define=DPIP_DATE=${{ needs.version.outputs.date }} \
--dart-define=DPIP_PRERELEASE=${{ needs.version.outputs.prerelease }}"
if [ "${{ matrix.platform }}" = "android" ]; then
bash tool/dev/build.sh android $COMMON
bash tool/dev/build.sh bundle $COMMON
Expand Down
28 changes: 26 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,21 @@ In-app viewer: the **App 日誌** page (More tab → `LogPage`), which replays t
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
Expand All @@ -138,8 +153,8 @@ 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 an optional `CrashSink` set on `Log` (Crashlytics
wire-up point).
handled errors forward to the `CrashSink` set on `Log` — Crashlytics, in a
prerelease build (see [Logging](#logging)).

## Data & errors

Expand Down Expand Up @@ -269,6 +284,15 @@ 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
Expand Down
7 changes: 7 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@
<meta-data
android:name="com.exptech.dpip.default_notification_icon"
android:resource="@drawable/ic_stat_name"/>
<!-- Crashlytics is linked into every build and sends nothing unless the
app turns it on. Only a snapshot or development build does that, at
runtime (lib/core/diagnostics/prerelease_diagnostics.dart); a
production build leaves this default in force. -->
<meta-data
android:name="firebase_crashlytics_collection_enabled"
android:value="false" />
<!-- No android:taskAffinity="" on purpose. The Flutter project template
adds it against task hijacking (StrandHogg), which Android 11+ already
prevents and which it only partly stops on Android 8 to 10. What it
Expand Down
11 changes: 10 additions & 1 deletion android/app/src/main/res/raw/keep.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,14 @@
earthquake early-warning channel among them — while the debug build, which is
not shrunk, registered all 24. tool/check/notification_sounds.sh could not
catch it: it reads the source tree, and the source tree is correct.

The Crashlytics switch is here for the same reason — read by name, referenced
by nothing. Gone, the SDK kills the process at launch; see
res/values/crashlytics.xml. This is the only file the shrinker reads
`tools:keep` from: the attribute does nothing on a values resource. And it is
spelled with underscores, the way the R class names it: the shrinker matches
`com_crashlytics_RequireBuildId`, and the dotted name the SDK looks up is
silently never matched.
-->
<resources xmlns:tools="http://schemas.android.com/tools" tools:keep="@raw/*" />
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@raw/*,@bool/com_crashlytics_RequireBuildId" />
26 changes: 26 additions & 0 deletions android/app/src/main/res/values/crashlytics.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Lets Crashlytics start without its Gradle plugin.

The SDK refuses to start without a build ID, and only the Crashlytics Gradle
plugin writes one. It refuses by throwing from the component's constructor
(CrashlyticsCore.onPreExecute in firebase-crashlytics 20.0.6), and the
component is eager in the default FirebaseApp, which FirebaseInitProvider
creates before any app code runs: the whole process dies on launch, in every
build, production included, and collection being off makes no difference.

The build ID only matches an uploaded R8 mapping file to a report. This app
uploads none (the plugin's upload task would add a network dependency to
every release build, the tagged one included), and the reports that matter
here are Dart errors, whose stacks are not obfuscated. So the requirement is
switched off rather than satisfied.

The SDK reads the value by name at runtime and nothing else refers to it, so
the resource shrinker would delete it from a release build — and that build
would die on launch while every debug build worked. It is kept in
res/raw/keep.xml: the shrinker honours `tools:keep` only in a raw resource,
and silently ignores it here.
-->
<resources>
<bool name="com.crashlytics.RequireBuildId">false</bool>
</resources>
13 changes: 13 additions & 0 deletions ios/NotificationService/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>
100 changes: 100 additions & 0 deletions ios/NotificationService/NotificationDeliveryBreadcrumb.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import Foundation

/// One line saying a push reached this device, written where the app will
/// read it on its next launch.
///
/// The question this answers is the one a missing alert raises first: did
/// APNs deliver it at all? Without it the app only ever learns about pushes
/// the user tapped, so "it never arrived" and "it arrived and was swiped
/// away" look the same.
///
/// The line carries identifiers only — the alert's id, its channel, and the
/// APNs request id. Never the title or body: those name places, and this ends
/// up in a log the user may share.
enum NotificationDeliveryBreadcrumb {
static let tag = "push"
static let maxFieldLength = 64

/// The log line for a push whose payload is `userInfo`.
///
/// The server publishes straight to APNs with the alert's fields in a
/// `content` object (sometimes as a JSON string — see
/// `NotificationTap.fromData` on the Dart side, which reads the same
/// shapes), with the producer's own map in `payload` beside it. Each field
/// is looked for in those places, nearest first, and left out when absent.
static func message(
userInfo: [AnyHashable: Any],
requestIdentifier: String,
categoryIdentifier: String
) -> String {
let content = object(userInfo["content"])
let payload = object(content["payload"] ?? userInfo["payload"])
let id = text(payload["id"] ?? content["id"] ?? userInfo["id"])
let channel = text(
content["channelKey"] ?? payload["channel"] ?? userInfo["channel"]
)
var parts = ["delivered"]
if let id { parts.append("id=\(id)") }
if let channel { parts.append("channel=\(channel)") }
if let category = text(categoryIdentifier) {
parts.append("category=\(category)")
}
if let request = text(requestIdentifier) {
parts.append("apns=\(request)")
}
return parts.joined(separator: " ")
}

/// Records the breadcrumb for a push received at `time`.
static func record(
_ request: UNNotificationRequestLike,
time: Date,
log: NativeLog?
) {
log?.record(
level: .info,
tag: tag,
message: message(
userInfo: request.userInfo,
requestIdentifier: request.identifier,
categoryIdentifier: request.categoryIdentifier
),
time: time
)
}

private static func object(_ raw: Any?) -> [String: Any] {
if let dictionary = raw as? [String: Any] { return dictionary }
guard
let string = raw as? String,
let data = string.data(using: .utf8),
let decoded = try? JSONSerialization.jsonObject(with: data),
let dictionary = decoded as? [String: Any]
else {
return [:]
}
return dictionary
}

/// A field as a short single token, or nil when there is nothing to say.
/// Whitespace is collapsed so one value cannot pose as two fields.
private static func text(_ raw: Any?) -> String? {
let value: String
switch raw {
case let string as String: value = string
case let number as NSNumber: value = number.stringValue
default: return nil
}
let token = value.split(whereSeparator: { $0.isWhitespace }).joined(separator: "_")
guard !token.isEmpty else { return nil }
return String(token.prefix(maxFieldLength))
}
}

/// The three things the breadcrumb reads off a `UNNotificationRequest`, so a
/// test can hand it a payload without constructing one.
protocol UNNotificationRequestLike {
var identifier: String { get }
var userInfo: [AnyHashable: Any] { get }
var categoryIdentifier: String { get }
}
12 changes: 12 additions & 0 deletions ios/NotificationService/NotificationService.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The app's own group, where the native log ring lives. Nothing else:
the extension reads no keychain and talks to no server. -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.exptech.dpip.dpip.widgets</string>
</array>
</dict>
</plist>
82 changes: 82 additions & 0 deletions ios/NotificationService/NotificationService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import Foundation
import UserNotifications

/// Leaves a delivery breadcrumb for every push, and changes nothing about it.
///
/// iOS runs this only for a push whose `aps` carries `"mutable-content": 1`
/// together with an alert. Without that flag the push is shown exactly as
/// before and this never starts — it is the backend that turns it on, one
/// payload field, and nothing in the app needs to change when it does.
///
/// **The content handed back is always the content received.** An alert in
/// this app is safety information, and an extension that edits, delays or
/// drops one is a way to lose it. So the order is fixed: the write is started
/// on a background queue, and the content goes back as soon as the write
/// finishes or `deliveryDeadline` passes, whichever is first — the write
/// takes milliseconds, the deadline is a ceiling on what a wedged file
/// coordinator can cost. iOS allows about 30 seconds; this uses a fraction of
/// one. If iOS calls `serviceExtensionTimeWillExpire` anyway, the content goes
/// back there, still unchanged.
///
/// The breadcrumb goes into the app group's native log ring, which the app
/// drains into `Log` at launch (`importNativeLogs` in bootstrap) — the same
/// path the widgets already write through.
final class NotificationService: UNNotificationServiceExtension {
static let deliveryDeadline: TimeInterval = 0.2

private let lock = NSLock()
private var contentHandler: ((UNNotificationContent) -> Void)?
private var content: UNNotificationContent?

override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
let received = Date()
lock.lock()
self.contentHandler = contentHandler
content = request.content
lock.unlock()

// Strong captures on purpose. If this object went away before either
// block ran, the handler would never be called, and iOS would hold the
// alert for the rest of its budget before showing it — the delay this
// class exists not to cause. The deadline block runs on time whatever
// the write does, so nothing is held for longer than that.
DispatchQueue.global(qos: .userInitiated).asyncAfter(
deadline: .now() + Self.deliveryDeadline
) {
self.deliver()
}
DispatchQueue.global(qos: .utility).async {
NotificationDeliveryBreadcrumb.record(
request,
time: received,
log: FileNativeLog.appGroup()
)
self.deliver()
}
}

override func serviceExtensionTimeWillExpire() {
deliver()
}

/// Hands the original content back exactly once, whoever gets here first.
private func deliver() {
lock.lock()
let handler = contentHandler
let original = content
contentHandler = nil
content = nil
lock.unlock()
if let handler, let original {
handler(original)
}
}
}

extension UNNotificationRequest: UNNotificationRequestLike {
var userInfo: [AnyHashable: Any] { content.userInfo }
var categoryIdentifier: String { content.categoryIdentifier }
}
Loading
Loading