diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 255e32e8d..2dc9915be 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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
@@ -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
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 08a450ad5..ab5d9e7d7 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -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
@@ -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
@@ -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
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index e2ad0b8e7..98892ee06 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -106,6 +106,13 @@
+
+
-
+
diff --git a/android/app/src/main/res/values/crashlytics.xml b/android/app/src/main/res/values/crashlytics.xml
new file mode 100644
index 000000000..34ab08b56
--- /dev/null
+++ b/android/app/src/main/res/values/crashlytics.xml
@@ -0,0 +1,26 @@
+
+
+
+ false
+
diff --git a/ios/NotificationService/Info.plist b/ios/NotificationService/Info.plist
new file mode 100644
index 000000000..57421ebf9
--- /dev/null
+++ b/ios/NotificationService/Info.plist
@@ -0,0 +1,13 @@
+
+
+
+
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.usernotifications.service
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).NotificationService
+
+
+
diff --git a/ios/NotificationService/NotificationDeliveryBreadcrumb.swift b/ios/NotificationService/NotificationDeliveryBreadcrumb.swift
new file mode 100644
index 000000000..a71c08b4e
--- /dev/null
+++ b/ios/NotificationService/NotificationDeliveryBreadcrumb.swift
@@ -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 }
+}
diff --git a/ios/NotificationService/NotificationService.entitlements b/ios/NotificationService/NotificationService.entitlements
new file mode 100644
index 000000000..77473c29b
--- /dev/null
+++ b/ios/NotificationService/NotificationService.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+
+ com.apple.security.application-groups
+
+ group.com.exptech.dpip.dpip.widgets
+
+
+
diff --git a/ios/NotificationService/NotificationService.swift b/ios/NotificationService/NotificationService.swift
new file mode 100644
index 000000000..50e2aabd8
--- /dev/null
+++ b/ios/NotificationService/NotificationService.swift
@@ -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 }
+}
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 88ca49102..a37d59e9e 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -47,6 +47,8 @@
NL0G00000000000000000004 /* NativeLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = NL0G00000000000000000003 /* NativeLogTests.swift */; };
LR0S00000000000000000002 /* LocationReportSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = LR0S00000000000000000001 /* LocationReportSender.swift */; };
LR0S00000000000000000004 /* LocationReportSenderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = LR0S00000000000000000003 /* LocationReportSenderTests.swift */; };
+ NSE000000000000000000002 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = NSE000000000000000000001 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ NSE000000000000000000012 /* NotificationDeliveryBreadcrumbTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = NSE000000000000000000011 /* NotificationDeliveryBreadcrumbTests.swift */; };
AA0000000000000000000E02 /* ScreenWakePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000E01 /* ScreenWakePlugin.swift */; };
AA0000000000000000000F02 /* ApnsTokenPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000F01 /* ApnsTokenPlugin.swift */; };
AA0000000000000000000G02 /* LocationTrackStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000G01 /* LocationTrackStore.swift */; };
@@ -87,6 +89,13 @@
remoteGlobalIDString = 27CACBA0305BD77F0046F79C;
remoteInfo = DPIPWidgetIntentsExtension;
};
+ NSE000000000000000000003 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 97C146E61CF9000F007C117D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = NSE00000000000000000000C;
+ remoteInfo = NotificationService;
+ };
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
@@ -105,6 +114,7 @@
files = (
27CACBAA305BD77F0046F79C /* DPIPWidgetIntentsExtension.appex in Embed Foundation Extensions */,
2715FCE530570C1D0014DC8A /* DPIPWidgetsExtension.appex in Embed Foundation Extensions */,
+ NSE000000000000000000002 /* NotificationService.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
@@ -175,6 +185,8 @@
NL0G00000000000000000003 /* NativeLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeLogTests.swift; sourceTree = ""; };
LR0S00000000000000000001 /* LocationReportSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationReportSender.swift; sourceTree = ""; };
LR0S00000000000000000003 /* LocationReportSenderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationReportSenderTests.swift; sourceTree = ""; };
+ NSE000000000000000000001 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+ NSE000000000000000000011 /* NotificationDeliveryBreadcrumbTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDeliveryBreadcrumbTests.swift; sourceTree = ""; };
AA0000000000000000000E01 /* ScreenWakePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenWakePlugin.swift; sourceTree = ""; };
AA0000000000000000000F01 /* ApnsTokenPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApnsTokenPlugin.swift; sourceTree = ""; };
AA0000000000000000000G01 /* LocationTrackStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationTrackStore.swift; sourceTree = ""; };
@@ -315,12 +327,34 @@
);
target = 27CACBA0305BD77F0046F79C /* DPIPWidgetIntentsExtension */;
};
+ NSE000000000000000000006 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = NSE00000000000000000000C /* NotificationService */;
+ };
+ NSE000000000000000000007 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ NotificationDeliveryBreadcrumb.swift,
+ );
+ target = 331C8080294A63A400263BE5 /* RunnerTests */;
+ };
+ NSE000000000000000000008 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ NativeLog.swift,
+ );
+ target = NSE00000000000000000000C /* NotificationService */;
+ };
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
- 270FCE9B305CD066003D1E62 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (270FCEA1305CD07F003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 270FCEA3305CD1DD003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 270FCEA6305CD2D6003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, E7A100000000000000000003 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = ""; };
+ 270FCE9B305CD066003D1E62 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (270FCEA1305CD07F003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 270FCEA3305CD1DD003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 270FCEA6305CD2D6003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, E7A100000000000000000003 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, NSE000000000000000000008 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = ""; };
2715FCDB30570C1C0014DC8A /* DPIPWidgets */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2715FCEA30570C1D0014DC8A /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 27CACB9C305BD2A60046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 27CACD20305C6E2D0046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 27CACBB1305BD7E60046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = DPIPWidgets; sourceTree = ""; };
27CACBA4305BD77F0046F79C /* DPIPWidgetIntentsExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (27CACC5E305C66560046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 27CACBAF305BD77F0046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = DPIPWidgetIntentsExtension; sourceTree = ""; };
+ NSE000000000000000000005 /* NotificationService */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (NSE000000000000000000006 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, NSE000000000000000000007 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = NotificationService; sourceTree = ""; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -341,6 +375,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ NSE00000000000000000000A /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -387,6 +428,7 @@
27CACC7F305C67530046F79C /* WidgetLocationCatalogTests.swift */,
NL0G00000000000000000003 /* NativeLogTests.swift */,
LR0S00000000000000000003 /* LocationReportSenderTests.swift */,
+ NSE000000000000000000011 /* NotificationDeliveryBreadcrumbTests.swift */,
270FCEA4305CD2D6003D1E62 /* CurrentWeatherSnapshotAddressTests.swift */,
270FCEF4305D1EB2003D1E62 /* WidgetSnapshotFileTests.swift */,
E7A100000000000000000002 /* CurrentWeatherWidgetSnapshotWriterTests.swift */,
@@ -420,6 +462,7 @@
97C146F01CF9000F007C117D /* Runner */,
2715FCDB30570C1C0014DC8A /* DPIPWidgets */,
27CACBA4305BD77F0046F79C /* DPIPWidgetIntentsExtension */,
+ NSE000000000000000000005 /* NotificationService */,
2715FCD630570C1C0014DC8A /* Frameworks */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
@@ -433,6 +476,7 @@
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
2715FCD530570C1C0014DC8A /* DPIPWidgetsExtension.appex */,
27CACBA1305BD77F0046F79C /* DPIPWidgetIntentsExtension.appex */,
+ NSE000000000000000000001 /* NotificationService.appex */,
);
name = Products;
sourceTree = "";
@@ -528,6 +572,28 @@
productReference = 27CACBA1305BD77F0046F79C /* DPIPWidgetIntentsExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
+ NSE00000000000000000000C /* NotificationService */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = NSE000000000000000000010 /* Build configuration list for PBXNativeTarget "NotificationService" */;
+ buildPhases = (
+ NSE000000000000000000009 /* Sources */,
+ NSE00000000000000000000A /* Frameworks */,
+ NSE00000000000000000000B /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ NSE000000000000000000005 /* NotificationService */,
+ );
+ name = NotificationService;
+ packageProductDependencies = (
+ );
+ productName = NotificationService;
+ productReference = NSE000000000000000000001 /* NotificationService.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
@@ -563,6 +629,7 @@
dependencies = (
2715FCE430570C1D0014DC8A /* PBXTargetDependency */,
27CACBA9305BD77F0046F79C /* PBXTargetDependency */,
+ NSE000000000000000000004 /* PBXTargetDependency */,
);
name = Runner;
packageProductDependencies = (
@@ -589,6 +656,9 @@
27CACBA0305BD77F0046F79C = {
CreatedOnToolsVersion = 26.6;
};
+ NSE00000000000000000000C = {
+ CreatedOnToolsVersion = 26.6;
+ };
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
@@ -623,6 +693,7 @@
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
27CACBA0305BD77F0046F79C /* DPIPWidgetIntentsExtension */,
+ NSE00000000000000000000C /* NotificationService */,
);
};
/* End PBXProject section */
@@ -642,6 +713,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ NSE00000000000000000000B /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -723,6 +801,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ NSE000000000000000000009 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -740,6 +825,7 @@
27CACC80305C67530046F79C /* WidgetLocationCatalogTests.swift in Sources */,
NL0G00000000000000000004 /* NativeLogTests.swift in Sources */,
LR0S00000000000000000004 /* LocationReportSenderTests.swift in Sources */,
+ NSE000000000000000000012 /* NotificationDeliveryBreadcrumbTests.swift in Sources */,
270FCEF5305D1EB2003D1E62 /* WidgetSnapshotFileTests.swift in Sources */,
27CACC3E305C64AA0046F79C /* WidgetLocationIntentTests.swift in Sources */,
D91A00000000000000000013 /* CurrentWeatherWidgetTimeline.swift in Sources */,
@@ -791,6 +877,11 @@
target = 27CACBA0305BD77F0046F79C /* DPIPWidgetIntentsExtension */;
targetProxy = 27CACBA8305BD77F0046F79C /* PBXContainerItemProxy */;
};
+ NSE000000000000000000004 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = NSE00000000000000000000C /* NotificationService */;
+ targetProxy = NSE000000000000000000003 /* PBXContainerItemProxy */;
+ };
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
@@ -1374,6 +1465,101 @@
};
name = Release;
};
+ NSE00000000000000000000D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+ buildSettings = {
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = 98Q7JARYZF;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = NotificationService/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = NotificationService;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
+ PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.dpip.NotificationService;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ NSE00000000000000000000E /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = 98Q7JARYZF;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = NotificationService/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = NotificationService;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
+ PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.dpip.NotificationService;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+ NSE00000000000000000000F /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = 98Q7JARYZF;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = NotificationService/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = NotificationService;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
+ PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.dpip.NotificationService;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Profile;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -1397,6 +1583,16 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ NSE000000000000000000010 /* Build configuration list for PBXNativeTarget "NotificationService" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ NSE00000000000000000000D /* Debug */,
+ NSE00000000000000000000E /* Release */,
+ NSE00000000000000000000F /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index 78aa63f73..b8cf5602a 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -92,8 +92,11 @@ import UserNotifications
/// Answers iOS for pushes that no plugin will answer for.
///
/// The app's pushes are published straight to APNs by AWS SNS — no FCM, no
-/// `mutable-content`, no Notification Service Extension. awesome_notifications
-/// cannot render such a push: its own README requires all three. That is why
+/// `mutable-content`, and so no Notification Service Extension that renders.
+/// awesome_notifications cannot render such a push: its own README requires
+/// all three. (`ios/NotificationService` is an extension, but it only records
+/// that a push arrived and returns it untouched — it renders nothing, and runs
+/// only once the backend sets `mutable-content`.) That is why
/// they arrive correctly in the background — awesome is bypassed entirely and
/// iOS presents `aps` itself — and vanish in the foreground, where Apple hands
/// the decision to whatever holds the notification-centre delegate.
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index be590ae19..122038ae4 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -37,6 +37,11 @@
$(FLUTTER_BUILD_NUMBER)DPIPWidgetAppGroupIdentifiergroup.com.exptech.dpip.dpip.widgets
+
+ FirebaseCrashlyticsCollectionEnabled
+ ITSAppUsesNonExemptEncryptionLSRequiresIPhoneOS
diff --git a/ios/RunnerTests/NotificationDeliveryBreadcrumbTests.swift b/ios/RunnerTests/NotificationDeliveryBreadcrumbTests.swift
new file mode 100644
index 000000000..d9275a6c9
--- /dev/null
+++ b/ios/RunnerTests/NotificationDeliveryBreadcrumbTests.swift
@@ -0,0 +1,107 @@
+import XCTest
+
+/// The delivery breadcrumb has to name the push without carrying its text, and
+/// has to reach the ring the app drains at launch.
+final class NotificationDeliveryBreadcrumbTests: XCTestCase {
+ private struct Request: UNNotificationRequestLike {
+ var identifier: String
+ var userInfo: [AnyHashable: Any]
+ var categoryIdentifier: String = ""
+ }
+
+ private var directory: URL!
+
+ override func setUp() {
+ super.setUp()
+ directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("push-breadcrumb-\(UUID().uuidString)")
+ try? FileManager.default.createDirectory(
+ at: directory,
+ withIntermediateDirectories: true
+ )
+ }
+
+ override func tearDown() {
+ if let directory {
+ try? FileManager.default.removeItem(at: directory)
+ }
+ directory = nil
+ super.tearDown()
+ }
+
+ func testReadsTheServerShapeAndLeavesTheTextOut() {
+ // `content` as the JSON string the server sends, with the producer's
+ // own map in `payload`.
+ let content = """
+ {"id": 7, "channelKey": "eew_alert-important-v2",
+ "title": "地震速報", "body": "臺北市 震度 4",
+ "payload": {"id": "eew-1145", "channel": "ignored"}}
+ """
+ let message = NotificationDeliveryBreadcrumb.message(
+ userInfo: ["content": content, "aps": ["alert": "x"]],
+ requestIdentifier: "6F0D2C1A-0000-4000-8000-000000000001",
+ categoryIdentifier: ""
+ )
+ XCTAssertEqual(
+ message,
+ "delivered id=eew-1145 channel=eew_alert-important-v2 "
+ + "apns=6F0D2C1A-0000-4000-8000-000000000001"
+ )
+ XCTAssertFalse(message.contains("地震"))
+ XCTAssertFalse(message.contains("臺北"))
+ }
+
+ func testFallsBackToFlatKeysAndANumericId() {
+ let message = NotificationDeliveryBreadcrumb.message(
+ userInfo: ["id": NSNumber(value: 42), "channel": "report"],
+ requestIdentifier: "",
+ categoryIdentifier: "REPORT"
+ )
+ XCTAssertEqual(message, "delivered id=42 channel=report category=REPORT")
+ }
+
+ func testAPayloadWithNothingKnownStillSaysItArrived() {
+ let message = NotificationDeliveryBreadcrumb.message(
+ userInfo: ["content": "not json"],
+ requestIdentifier: "",
+ categoryIdentifier: ""
+ )
+ XCTAssertEqual(message, "delivered")
+ }
+
+ func testAFieldCannotPoseAsTwoOrGrowWithoutBound() {
+ let message = NotificationDeliveryBreadcrumb.message(
+ userInfo: ["channel": "eew channel=forged", "id": String(repeating: "9", count: 500)],
+ requestIdentifier: "",
+ categoryIdentifier: ""
+ )
+ XCTAssertTrue(message.contains("channel=eew_channel=forged"))
+ let id = message.split(separator: " ").first { $0.hasPrefix("id=") }!
+ XCTAssertEqual(id.count, "id=".count + NotificationDeliveryBreadcrumb.maxFieldLength)
+ }
+
+ func testRecordsIntoTheRingTheAppDrains() throws {
+ let log = FileNativeLog(directory: directory)
+ let when = Date(timeIntervalSince1970: 1_760_000_000)
+ NotificationDeliveryBreadcrumb.record(
+ Request(identifier: "apns-1", userInfo: ["id": "a1", "channel": "eq"]),
+ time: when,
+ log: log
+ )
+
+ let entries = try log.take()
+ XCTAssertEqual(entries.count, 1)
+ XCTAssertEqual(entries[0].tag, "push")
+ XCTAssertEqual(entries[0].level, "info")
+ XCTAssertEqual(entries[0].message, "delivered id=a1 channel=eq apns=apns-1")
+ XCTAssertEqual(entries[0].timeMillis, 1_760_000_000_000)
+ }
+
+ func testNoAppGroupWritesNothingAndDoesNotThrow() {
+ NotificationDeliveryBreadcrumb.record(
+ Request(identifier: "apns-1", userInfo: [:]),
+ time: Date(),
+ log: nil
+ )
+ }
+}
diff --git a/lib/app/app.dart b/lib/app/app.dart
index dd54e95fb..bb055b001 100644
--- a/lib/app/app.dart
+++ b/lib/app/app.dart
@@ -4,6 +4,7 @@ import 'package:dpip/app/router/app_router.dart';
import 'package:dpip/app/router/notification_routes.dart';
import 'package:dpip/app/theme/app_theme.dart';
import 'package:dpip/core/di/shared_deps.dart';
+import 'package:dpip/core/diagnostics/startup_report.dart';
import 'package:dpip/core/geo/device_location_reporter.dart';
import 'package:dpip/core/geo/device_registration.dart';
import 'package:dpip/core/geo/location_monitor.dart';
@@ -202,6 +203,7 @@ class _AppServicesHostState extends State<_AppServicesHost>
// INFO, not DEBUG: this is the number anyone asking "why is launch slow"
// needs, and a release log keeps DEBUG out.
Log.info('first frame rendered ${Log.sinceStartMs} ms after start');
+ StartupMarks.mark('first_frame', Log.sinceStartMs);
// Spread the first polls so the post-first-frame burst doesn't hammer
// the network and UI isolate at once (EEW leads; the rest follow).
widget.realtimeService.startAll(
diff --git a/lib/app/shell/main_shell.dart b/lib/app/shell/main_shell.dart
index 45a36b973..2837faa67 100644
--- a/lib/app/shell/main_shell.dart
+++ b/lib/app/shell/main_shell.dart
@@ -1,3 +1,7 @@
+import 'dart:async';
+
+import 'package:dpip/core/diagnostics/prerelease_diagnostics.dart';
+import 'package:dpip/core/diagnostics/startup_report.dart';
import 'package:dpip/core/settings/default_map_layer_controller.dart';
import 'package:dpip/features/home/presentation/home_chrome.dart';
import 'package:dpip/features/home/presentation/home_sheet_extent.dart';
@@ -48,6 +52,10 @@ class _MainShellState extends State with RouteAware {
/// needs the replay closed (see [_closeReplayOnBranchLeave]).
static const int _dataBranchIndex = 3;
+ /// The two branches with a map on screen — see [_startupFrames].
+ static const int _homeBranchIndex = 0;
+ static const int _mapBranchIndex = 2;
+
int? _lastIndex;
/// Which branch is on screen. The branches live in an IndexedStack and stay
@@ -68,12 +76,33 @@ class _MainShellState extends State with RouteAware {
final ValueNotifier _navDismiss = ValueNotifier(0);
late final HomeSheetExtent _sheetExtent;
+ /// The launch summary's frame window, prerelease builds only. Started here
+ /// rather than at the app's first frame because the shell is the first
+ /// screen with a map on it — onboarding has none — and it owns the one
+ /// value that says whether a map is what the user is looking at.
+ StartupFrameSampler? _startupFrames;
+
@override
void initState() {
super.initState();
_trace(() => 'init current=${widget.navigationShell.currentIndex}');
_sheetExtent = context.read()
..addListener(_syncNavDismiss);
+ if (isPrereleaseBuild()) {
+ // Home (its live map backdrop and the monitor banner) and the map tab
+ // are the screens a cheap phone struggles with; a frame drawn over the
+ // events list says nothing about them.
+ _startupFrames = StartupFrameSampler(
+ counting: () =>
+ _visibleTab.isOnScreen(_homeBranchIndex) ||
+ _visibleTab.isOnScreen(_mapBranchIndex),
+ onSummary: (summary) =>
+ unawaited(PrereleaseDiagnostics.reportStartup(summary)),
+ );
+ WidgetsBinding.instance.addPostFrameCallback(
+ (_) => _startupFrames?.start(),
+ );
+ }
}
/// Only Home dismisses the bar; every other tab keeps it (dismiss 0).
@@ -123,6 +152,7 @@ class _MainShellState extends State with RouteAware {
@override
void dispose() {
_trace(() => 'dispose');
+ _startupFrames?.stop();
if (_shellRoute != null) shellRouteObserver.unsubscribe(this);
_sheetExtent.removeListener(_syncNavDismiss);
_navDismiss.dispose();
diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart
index 4e83d98c5..d304cd58f 100644
--- a/lib/bootstrap.dart
+++ b/lib/bootstrap.dart
@@ -7,6 +7,9 @@ import 'package:dpip/app/app.dart';
import 'package:dpip/app/router/app_router.dart' show onboardingRefresh;
import 'package:dpip/core/di/core_providers.dart';
import 'package:dpip/core/di/shared_deps.dart';
+import 'package:dpip/core/diagnostics/crash_reporting.dart';
+import 'package:dpip/core/diagnostics/prerelease_diagnostics.dart';
+import 'package:dpip/core/diagnostics/startup_report.dart';
import 'package:dpip/core/geo/device_location_reporter.dart';
import 'package:dpip/core/geo/device_registration.dart';
import 'package:dpip/core/geo/location_api.dart';
@@ -496,6 +499,15 @@ Future bootstrap() async {
'(durable+settings ${durableMs}ms, cache ${cacheMs}ms, '
'tiles ${tileMs}ms, towns ${townMs}ms, version ${versionMs}ms)',
);
+ // The same numbers, kept as numbers for a prerelease's launch summary
+ // (core/diagnostics/startup_report.dart). Absolute for `durable` and
+ // `ready`; the rest are the waits above.
+ StartupMarks.mark('durable', durableMs);
+ StartupMarks.mark('cache_wait', cacheMs);
+ StartupMarks.mark('tiles_wait', tileMs);
+ StartupMarks.mark('towns_wait', townMs);
+ StartupMarks.mark('version_wait', versionMs);
+ StartupMarks.mark('ready', Log.sinceStartMs);
runApp(
DpipApp(
deps: deps,
@@ -676,7 +688,15 @@ Future _initFirebase() async {
Log.info('Firebase initialized');
} catch (error, stackTrace) {
Log.handle(error, stackTrace, 'Firebase init failed (push unavailable)');
+ return;
}
+ // Every launch, production included: the SDK remembers the switch across
+ // launches, so production has to turn it off again. Errors before this point
+ // stay in the in-app log only — there is nothing to send them through yet.
+ await PrereleaseDiagnostics.start(
+ reporter: FirebaseCrashReporter(),
+ prerelease: isPrereleaseBuild(),
+ );
}
/// Initializes push after the first frame — never gate launch on FCM.
diff --git a/lib/core/diagnostics/crash_reporting.dart b/lib/core/diagnostics/crash_reporting.dart
new file mode 100644
index 000000000..4c2c6c85e
--- /dev/null
+++ b/lib/core/diagnostics/crash_reporting.dart
@@ -0,0 +1,166 @@
+/// Crash reports to Crashlytics, from builds that are not production.
+///
+/// Two pieces: [CrashReporter], the one seam that touches the Firebase plugin,
+/// and [ReportingCrashSink], which decides what deserves a report. The policy
+/// of *which builds* report lives in `prerelease_diagnostics.dart`.
+library;
+
+import 'dart:async';
+import 'dart:io';
+
+import 'package:dio/dio.dart';
+import 'package:dpip/core/logging/crash_sink.dart';
+import 'package:dpip/core/logging/log.dart';
+import 'package:firebase_crashlytics/firebase_crashlytics.dart';
+
+/// What the diagnostics need from a crash-reporting service.
+///
+/// An interface so the decisions above it are testable without a platform
+/// channel, and so nothing else in the app imports the Firebase plugin.
+abstract interface class CrashReporter {
+ /// Turns sending on or off. Persisted by the SDK across launches, which is
+ /// why a production build has to say `false` every time — see
+ /// [PrereleaseDiagnostics.start].
+ Future setCollectionEnabled(bool enabled);
+
+ /// Drops reports recorded while collection was off, so they cannot leave the
+ /// device later.
+ Future deleteUnsentReports();
+
+ Future recordError(
+ Object error,
+ StackTrace stackTrace, {
+ String? reason,
+ required bool fatal,
+ });
+
+ /// A line attached to whatever report this session sends next. Sends
+ /// nothing on its own.
+ Future log(String message);
+
+ /// A value attached to every report this session sends. Sends nothing on
+ /// its own.
+ Future setKey(String key, Object value);
+}
+
+/// [CrashReporter] over `firebase_crashlytics`.
+class FirebaseCrashReporter implements CrashReporter {
+ FirebaseCrashReporter([FirebaseCrashlytics? crashlytics])
+ : _crashlytics = crashlytics ?? FirebaseCrashlytics.instance;
+
+ final FirebaseCrashlytics _crashlytics;
+
+ @override
+ Future setCollectionEnabled(bool enabled) =>
+ _crashlytics.setCrashlyticsCollectionEnabled(enabled);
+
+ @override
+ Future deleteUnsentReports() => _crashlytics.deleteUnsentReports();
+
+ @override
+ Future recordError(
+ Object error,
+ StackTrace stackTrace, {
+ String? reason,
+ required bool fatal,
+ }) => _crashlytics.recordError(
+ error,
+ stackTrace,
+ reason: reason,
+ fatal: fatal,
+ // The plugin prints the whole report to the console in debug when left
+ // to its default. Every line already went through `Log` before reaching
+ // the sink, so that would be a second copy in a different shape.
+ printDetails: false,
+ );
+
+ @override
+ Future log(String message) => _crashlytics.log(message);
+
+ @override
+ Future setKey(String key, Object value) =>
+ _crashlytics.setCustomKey(key, value);
+}
+
+/// Forwards [Log]'s errors to a [CrashReporter], keeping only the ones a crash
+/// dashboard can do something with.
+///
+/// Three filters, each because the alternative was measurable noise:
+///
+/// * **Transport failures are dropped.** A phone on a train fails every poll,
+/// and `guardResult` hands each one to `Log.handle`. Those are the network's
+/// story, already in the in-app log, and none is a defect in the app. Dropping
+/// every [DioException] also keeps request URLs out of the report — the
+/// location endpoint carries the push token and coordinates in its path.
+/// * **One report per signature per session.** A layout fault re-reports every
+/// frame. Crashlytics would group them into one issue anyway; sending them
+/// costs a platform call, a file write and an upload each.
+/// * **[maxReportsPerSession] in total.** The Android SDK keeps only the newest
+/// eight recorded exceptions of a session and discards the rest, so a ninth
+/// would push out the first — which is usually the one that explains the
+/// others.
+class ReportingCrashSink implements CrashSink {
+ ReportingCrashSink(this._reporter);
+
+ /// Matches the Android SDK's own per-session ceiling — see the class doc.
+ static const int maxReportsPerSession = 8;
+
+ final CrashReporter _reporter;
+ final Set _seen = {};
+ int _sent = 0;
+ bool _saidFull = false;
+
+ @override
+ void report(
+ Object error,
+ StackTrace stackTrace, {
+ String? context,
+ bool fatal = false,
+ }) {
+ if (isTransportFailure(error)) return;
+ if (!_seen.add(signature(error, context))) return;
+ if (_sent >= maxReportsPerSession) {
+ if (!_saidFull) {
+ _saidFull = true;
+ // `info`, not `handle`: nothing that this sink is told about may be
+ // reported through the path that feeds it.
+ Log.info(
+ 'crash reports: $maxReportsPerSession sent this session, '
+ 'keeping the rest in the local log only',
+ );
+ }
+ return;
+ }
+ _sent++;
+ unawaited(
+ _reporter
+ .recordError(error, stackTrace, reason: context, fatal: fatal)
+ .catchError((Object failure) {
+ // Never `Log.handle`: that is the call that brought us here.
+ Log.warning('crash report not recorded: $failure');
+ }),
+ );
+ }
+
+ /// What makes two reports the same report.
+ ///
+ /// The first line of the message, not the stack: a stack differs by caller
+ /// for what is one fault, and a message is the part a person would compare.
+ /// Clipped so an error that embeds a response body cannot make the set grow
+ /// without bound.
+ static String signature(Object error, String? context) {
+ final text = error.toString();
+ final newline = text.indexOf('\n');
+ final first = newline < 0 ? text : text.substring(0, newline);
+ final clipped = first.length > 160 ? first.substring(0, 160) : first;
+ return '${error.runtimeType}|${context ?? ''}|$clipped';
+ }
+
+ /// Whether [error] is the network failing rather than the app.
+ static bool isTransportFailure(Object error) =>
+ error is DioException ||
+ error is SocketException ||
+ error is HttpException ||
+ error is TlsException ||
+ error is TimeoutException;
+}
diff --git a/lib/core/diagnostics/prerelease_diagnostics.dart b/lib/core/diagnostics/prerelease_diagnostics.dart
new file mode 100644
index 000000000..7d0334171
--- /dev/null
+++ b/lib/core/diagnostics/prerelease_diagnostics.dart
@@ -0,0 +1,188 @@
+/// Which builds send diagnostics off the device, and the switch that does it.
+library;
+
+import 'dart:io';
+
+import 'package:dpip/core/diagnostics/crash_reporting.dart';
+import 'package:dpip/core/diagnostics/startup_report.dart';
+import 'package:dpip/core/logging/log.dart';
+import 'package:dpip/core/platform/device_info.dart';
+import 'package:dpip/core/platform/install_source.dart';
+import 'package:dpip/core/platform/render_tier.dart';
+import 'package:dpip/core/version/app_build.dart';
+import 'package:flutter/foundation.dart';
+
+/// Stamped by `.github/workflows/release.yml`: `true` for a snapshot, `false`
+/// for a tag. Empty everywhere else.
+const String _definedPrerelease = String.fromEnvironment('DPIP_PRERELEASE');
+
+/// Whether this build may send diagnostics: a debug or profile build, or a
+/// release build CI marked as a snapshot.
+///
+/// Positive evidence only. A release-mode build without the mark — a tag, a
+/// local `tool/dev/build.sh`, a pull request's CI artifact — is treated as
+/// production, because sending from a build that turns out to be production
+/// is the failure that cannot be taken back. The label is deliberately not
+/// consulted: a local release build is labelled like a snapshot
+/// (`lib/core/build_info.g.dart`) without being one.
+///
+/// The parameters are seams for tests; production callers pass nothing.
+bool isPrereleaseBuild({
+ bool releaseMode = kReleaseMode,
+ String defined = _definedPrerelease,
+}) => !releaseMode || defined == 'true';
+
+/// Turns crash reporting on for a prerelease build, and off for every other.
+abstract final class PrereleaseDiagnostics {
+ static CrashReporter? _reporter;
+
+ /// The reporter, once [start] found this build a prerelease. Null in
+ /// production, and before [start] has run.
+ static CrashReporter? get reporter => _reporter;
+
+ /// Call once, after Firebase is initialized.
+ ///
+ /// Production is not a no-op. The SDK persists the collection switch across
+ /// launches, so a device that once ran a snapshot keeps sending from the
+ /// production build installed over it until something says otherwise —
+ /// every launch has to say it. Reports recorded while collection was off are
+ /// deleted for the same reason: turning collection on later, by installing a
+ /// snapshot, would otherwise upload what was recorded under production.
+ ///
+ /// The platform's own default is off on both sides (the
+ /// `firebase_crashlytics_collection_enabled` meta-data in
+ /// AndroidManifest.xml, `FirebaseCrashlyticsCollectionEnabled` in
+ /// Info.plist), so nothing is sent before this runs either.
+ static Future start({
+ required CrashReporter reporter,
+ required bool prerelease,
+ Future Function()? device,
+ }) async {
+ try {
+ await reporter.setCollectionEnabled(prerelease);
+ if (!prerelease) {
+ await reporter.deleteUnsentReports();
+ return;
+ }
+ _reporter = reporter;
+ Log.crashSink = ReportingCrashSink(reporter);
+ Log.info('crash reports: on (prerelease build)');
+ await _describe(reporter, await (device ?? _loadDevice)());
+ } catch (error) {
+ // Not `Log.handle`: the sink may already be set, and a failure to set up
+ // crash reporting reported through crash reporting is how a loop starts.
+ Log.warning('crash reports: setup failed: $error');
+ }
+ }
+
+ /// What a report needs to say about the build and the hardware, set once.
+ ///
+ /// Crashlytics already records the model and OS version. RAM and the render
+ /// tier are what decide which code path a cheap phone took, and they are not
+ /// in a report unless put there.
+ static Future _describe(
+ CrashReporter reporter,
+ DeviceDetails? device,
+ ) async {
+ await AppBuild.ensureLoaded();
+ final source = await InstallSourceService.load();
+ final keys = {
+ 'build_label': AppBuild.label,
+ 'build_code': AppBuild.code,
+ 'build_mode': kReleaseMode
+ ? 'release'
+ : kProfileMode
+ ? 'profile'
+ : 'debug',
+ 'install_source': source.name,
+ 'ram_mb': ?device?.totalMemoryMb,
+ 'android_sdk': ?device?.sdkInt,
+ if (device != null) 'render_tier': renderTierFor(device).name,
+ };
+ for (final MapEntry(:key, :value) in keys.entries) {
+ await reporter.setKey(key, value);
+ }
+ }
+
+ static Future _loadDevice() async {
+ if (!Platform.isAndroid && !Platform.isIOS) return null;
+ try {
+ return await DeviceInfoService.load();
+ } on Object {
+ return null;
+ }
+ }
+
+ static bool _startupSent = false;
+
+ /// Fewer frames than this and the percentiles describe a handful of frames,
+ /// not a launch — an app backgrounded at once, or a user who left the map.
+ static const int minStartupFrames = 60;
+
+ /// Sends one launch summary: the phase timings in [StartupMarks] and the
+ /// frame distribution in [frames].
+ ///
+ /// One report per session, as a non-fatal [StartupPerformanceReport], and
+ /// that is the whole cost. Every event is free on Crashlytics, but each one
+ /// is a row somebody has to scroll past; one per session from the snapshot
+ /// testers is a population to read percentiles from, and it groups into a
+ /// single issue because it is always raised from the same place. The
+ /// numbers ride as custom keys, which the console filters and exports, and
+ /// they stay set for the rest of the session — so a crash later in the same
+ /// session arrives saying how the launch went.
+ ///
+ /// Logged locally in every case, so a debug run shows the same line. Sent
+ /// only from a profile or release prerelease: a debug build's frames are
+ /// JIT-compiled and assertion-laden, and would drag every percentile.
+ static Future reportStartup(
+ FrameSummary frames, {
+ bool debugMode = kDebugMode,
+ }) async {
+ final marks = StartupMarks.all;
+ final phases = marks.entries.map((e) => '${e.key}=${e.value}ms').join(' ');
+ Log.info('startup summary: $phases ${frames.describe()}');
+ final reporter = _reporter;
+ if (reporter == null || debugMode || _startupSent) return;
+ if (frames.frames < minStartupFrames) {
+ Log.info(
+ 'startup summary not sent: ${frames.frames} frames '
+ '(< $minStartupFrames)',
+ );
+ return;
+ }
+ _startupSent = true;
+ try {
+ for (final MapEntry(:key, :value) in marks.entries) {
+ await reporter.setKey('startup_${key}_ms', value);
+ }
+ for (final MapEntry(:key, :value) in frames.keys.entries) {
+ await reporter.setKey(key, value);
+ }
+ await reporter.log('startup summary: $phases ${frames.describe()}');
+ await reporter.recordError(
+ const StartupPerformanceReport(),
+ StackTrace.current,
+ reason: frames.describe(),
+ fatal: false,
+ );
+ } catch (error) {
+ Log.warning('startup summary not sent: $error');
+ }
+ }
+
+ /// Test seam.
+ @visibleForTesting
+ static void debugReset() {
+ _reporter = null;
+ _startupSent = false;
+ }
+}
+
+/// The record a launch summary is filed under. Not a fault: a constant message
+/// and a constant call site, so every session's summary lands in one issue.
+class StartupPerformanceReport implements Exception {
+ const StartupPerformanceReport();
+
+ @override
+ String toString() => 'StartupPerformanceReport: launch timing summary';
+}
diff --git a/lib/core/diagnostics/startup_report.dart b/lib/core/diagnostics/startup_report.dart
new file mode 100644
index 000000000..97b249dcc
--- /dev/null
+++ b/lib/core/diagnostics/startup_report.dart
@@ -0,0 +1,208 @@
+/// How long launch took, and how smooth the first seconds after it were.
+///
+/// Measured in every build, sent from prerelease builds only — see
+/// [PrereleaseDiagnostics.reportStartup].
+library;
+
+import 'dart:async';
+import 'dart:math' as math;
+import 'dart:ui' show FrameTiming, PlatformDispatcher, TimingsCallback;
+
+import 'package:flutter/scheduler.dart';
+
+/// Milliseconds from launch to each named phase, in the order they happened.
+///
+/// `bootstrap` already logs these; they are kept here as numbers as well so a
+/// summary can carry them without parsing a log line back.
+abstract final class StartupMarks {
+ static final Map _marks = {};
+
+ /// Records [phase] at [ms] after launch. The first value for a phase wins:
+ /// a hot restart re-runs bootstrap, and its numbers are not a launch.
+ static void mark(String phase, int ms) => _marks.putIfAbsent(phase, () => ms);
+
+ static Map get all => Map.unmodifiable(_marks);
+
+ static void debugReset() => _marks.clear();
+}
+
+/// The distribution of a window of frames, in microseconds.
+///
+/// A frame is **janky** when either half of it — building the frame on the UI
+/// thread, or rasterizing it on the raster thread — took longer than one
+/// refresh interval. Either one alone is enough to miss the vsync, and naming
+/// which half was slow is what tells a reader where to look.
+class FrameSummary {
+ const FrameSummary({
+ required this.frames,
+ required this.janky,
+ required this.budgetUs,
+ required this.buildUs,
+ required this.rasterUs,
+ });
+
+ /// Summarizes parallel lists of build and raster durations.
+ factory FrameSummary.of(
+ List buildUs,
+ List rasterUs, {
+ required int budgetUs,
+ }) {
+ assert(buildUs.length == rasterUs.length);
+ var janky = 0;
+ for (var i = 0; i < buildUs.length; i++) {
+ if (buildUs[i] > budgetUs || rasterUs[i] > budgetUs) janky++;
+ }
+ return FrameSummary(
+ frames: buildUs.length,
+ janky: janky,
+ budgetUs: budgetUs,
+ buildUs: Percentiles.of(buildUs),
+ rasterUs: Percentiles.of(rasterUs),
+ );
+ }
+
+ final int frames;
+ final int janky;
+ final int budgetUs;
+ final Percentiles buildUs;
+ final Percentiles rasterUs;
+
+ double get jankyFraction => frames == 0 ? 0 : janky / frames;
+
+ /// One line, the same one the log and the report carry.
+ String describe() {
+ String ms(int us) => (us / 1000).toStringAsFixed(1);
+ final percent = (jankyFraction * 100).toStringAsFixed(1);
+ return 'frames=$frames janky=$janky ($percent%) budget=${ms(budgetUs)}ms '
+ 'build p50/p90/p99=${ms(buildUs.p50)}/${ms(buildUs.p90)}/'
+ '${ms(buildUs.p99)}ms '
+ 'raster p50/p90/p99=${ms(rasterUs.p50)}/${ms(rasterUs.p90)}/'
+ '${ms(rasterUs.p99)}ms';
+ }
+
+ /// Flat values for a crash report's custom keys.
+ Map get keys => {
+ 'frames_sampled': frames,
+ 'frames_janky': janky,
+ 'frame_budget_us': budgetUs,
+ 'build_p50_us': buildUs.p50,
+ 'build_p90_us': buildUs.p90,
+ 'build_p99_us': buildUs.p99,
+ 'raster_p50_us': rasterUs.p50,
+ 'raster_p90_us': rasterUs.p90,
+ 'raster_p99_us': rasterUs.p99,
+ };
+}
+
+/// Nearest-rank percentiles: each is a value that was actually observed, so a
+/// p99 of 40 ms means some frame really took 40 ms.
+class Percentiles {
+ const Percentiles(this.p50, this.p90, this.p99);
+
+ factory Percentiles.of(List values) {
+ if (values.isEmpty) return const Percentiles(0, 0, 0);
+ final sorted = List.of(values)..sort();
+ int at(double p) {
+ final rank = (p * sorted.length).ceil();
+ return sorted[math.max(0, math.min(sorted.length, rank) - 1)];
+ }
+
+ return Percentiles(at(0.50), at(0.90), at(0.99));
+ }
+
+ final int p50;
+ final int p90;
+ final int p99;
+}
+
+/// Collects frame timings for a bounded window, then hands over one summary.
+///
+/// Bounded twice — by [window] and by [maxFrames] — so a session that runs
+/// for hours costs exactly as much as one that runs for twenty seconds: two
+/// integer lists of at most [maxFrames] each, and nothing at all once the
+/// summary is out. The engine only reports timings while a callback is
+/// registered, so after [stop] it stops measuring too.
+///
+/// Frames arrive in batches (about once a second in a release build), so the
+/// last part-second of the window is not counted, and [counting] is asked once
+/// per batch rather than once per frame: a batch that straddles a tab switch
+/// is counted or dropped whole. A window in which no frame is drawn — an app
+/// that sat still — still ends on time, with what it has.
+class StartupFrameSampler {
+ StartupFrameSampler({
+ required this.onSummary,
+ this.counting = _always,
+ this.window = const Duration(seconds: 20),
+ this.maxFrames = 1200,
+ int? budgetUs,
+ void Function(TimingsCallback)? addTimings,
+ void Function(TimingsCallback)? removeTimings,
+ }) : budgetUs = budgetUs ?? refreshBudgetUs(),
+ _add = addTimings ?? SchedulerBinding.instance.addTimingsCallback,
+ _remove =
+ removeTimings ?? SchedulerBinding.instance.removeTimingsCallback;
+
+ final void Function(FrameSummary summary) onSummary;
+
+ /// Whether a batch arriving now describes the screen being measured.
+ final bool Function() counting;
+ final Duration window;
+ final int maxFrames;
+ final int budgetUs;
+ final void Function(TimingsCallback) _add;
+ final void Function(TimingsCallback) _remove;
+
+ final List _build = [];
+ final List _raster = [];
+ Timer? _timer;
+ bool _running = false;
+
+ void start() {
+ if (_running) return;
+ _running = true;
+ _add(_onTimings);
+ _timer = Timer(window, _finish);
+ }
+
+ /// Abandons the window without a summary.
+ void stop() {
+ if (!_running) return;
+ _running = false;
+ _timer?.cancel();
+ _remove(_onTimings);
+ }
+
+ void _onTimings(List timings) {
+ if (!_running || !counting()) return;
+ for (final timing in timings) {
+ _build.add(timing.buildDuration.inMicroseconds);
+ _raster.add(timing.rasterDuration.inMicroseconds);
+ if (_build.length >= maxFrames) {
+ _finish();
+ return;
+ }
+ }
+ }
+
+ void _finish() {
+ if (!_running) return;
+ stop();
+ onSummary(FrameSummary.of(_build, _raster, budgetUs: budgetUs));
+ _build.clear();
+ _raster.clear();
+ }
+
+ static bool _always() => true;
+
+ /// One refresh interval of the display the app is on.
+ ///
+ /// Not a fixed 16.7 ms: a 90 Hz panel misses its vsync at 11.1 ms, and the
+ /// cheap phones this is for are increasingly 90 Hz. A reading outside what
+ /// any phone panel does is taken for 60 Hz.
+ static int refreshBudgetUs() {
+ final views = PlatformDispatcher.instance.views;
+ final rate = views.isEmpty ? 60.0 : views.first.display.refreshRate;
+ final hz = rate >= 30 && rate <= 240 ? rate : 60.0;
+ return (1000000 / hz).round();
+ }
+}
diff --git a/pubspec.lock b/pubspec.lock
index 96c2b255b..ed39ce630 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -9,6 +9,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "103.0.0"
+ _flutterfire_internals:
+ dependency: transitive
+ description:
+ name: _flutterfire_internals
+ sha256: "78f98c1f9c4dbbd22c2bb7b7f17c4a5c06150e8b2cb791a0947979ad0d3dabd5"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.73"
analyzer:
dependency: transitive
description:
@@ -312,6 +320,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.9.0"
+ firebase_crashlytics:
+ dependency: "direct main"
+ description:
+ name: firebase_crashlytics
+ sha256: "2d9c791abef1cfd66c2572f5a4e172aa9f43476961af49742b1511dd327611df"
+ url: "https://pub.dev"
+ source: hosted
+ version: "5.2.4"
+ firebase_crashlytics_platform_interface:
+ dependency: transitive
+ description:
+ name: firebase_crashlytics_platform_interface
+ sha256: cbedfe34081c4ad5c4e0558165383d4fa6b8fd70ae6e696190aad345adbb346c
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.8.24"
fixnum:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index ce747ded7..94856ccd4 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -33,6 +33,14 @@ dependencies:
# Pinned to the exact version (not `^`) so a plain `pub upgrade` can't drift
# the minor either.
firebase_core: 4.11.0
+ # Crash reports from snapshot and development builds only; a production build
+ # never turns collection on (lib/core/diagnostics/prerelease_diagnostics.dart).
+ # Pinned exactly, for the same reason as firebase_core: 5.2.4 is the release
+ # cut together with firebase_core 4.11.0 and asks for the same native SDK
+ # (firebase-ios-sdk 12.15.0 exact, Android BoM 34.15.0) with the same floors
+ # (iOS 15.0, minSdk 23). Every later 5.2.x requires a newer firebase_core —
+ # which is the bump the pin above exists to stop.
+ firebase_crashlytics: 5.2.4
flutter:
sdk: flutter
flutter_localizations:
diff --git a/test/core/diagnostics/crash_reporting_test.dart b/test/core/diagnostics/crash_reporting_test.dart
new file mode 100644
index 000000000..74f339bdc
--- /dev/null
+++ b/test/core/diagnostics/crash_reporting_test.dart
@@ -0,0 +1,313 @@
+/// Crash reports leave the device from snapshots and development builds only,
+/// and only the ones worth reading.
+///
+/// The failure that matters most here is silent: a production build that
+/// sends. The SDK remembers the collection switch across launches, so the
+/// production path is tested as an action (switch off, drop the backlog), not
+/// as the absence of one.
+library;
+
+import 'dart:async';
+import 'dart:io';
+
+import 'package:dio/dio.dart';
+import 'package:dpip/core/diagnostics/crash_reporting.dart';
+import 'package:dpip/core/diagnostics/prerelease_diagnostics.dart';
+import 'package:dpip/core/logging/log.dart';
+import 'package:dpip/core/platform/device_info.dart';
+import 'package:dpip/core/platform/install_source.dart';
+import 'package:dpip/core/version/app_build.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+class _FakeReporter implements CrashReporter {
+ final calls = [];
+ final errors = <({Object error, String? reason, bool fatal})>[];
+ final keys = {};
+ final lines = [];
+ bool failRecord = false;
+
+ @override
+ Future setCollectionEnabled(bool enabled) async =>
+ calls.add('collection=$enabled');
+
+ @override
+ Future deleteUnsentReports() async => calls.add('deleteUnsent');
+
+ @override
+ Future recordError(
+ Object error,
+ StackTrace stackTrace, {
+ String? reason,
+ required bool fatal,
+ }) async {
+ if (failRecord) throw StateError('channel gone');
+ errors.add((error: error, reason: reason, fatal: fatal));
+ }
+
+ @override
+ Future log(String message) async => lines.add(message);
+
+ @override
+ Future setKey(String key, Object value) async => keys[key] = value;
+}
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ setUp(() {
+ AppBuild.debugSet(label: '26w42a', code: 426000700);
+ InstallSourceService.debugSet(InstallSource.testFlight);
+ });
+
+ tearDown(() {
+ Log.crashSink = null;
+ PrereleaseDiagnostics.debugReset();
+ InstallSourceService.debugSet(null);
+ });
+
+ group('isPrereleaseBuild', () {
+ test('a release build is production unless CI marked it a snapshot', () {
+ expect(isPrereleaseBuild(releaseMode: true, defined: ''), isFalse);
+ expect(isPrereleaseBuild(releaseMode: true, defined: 'false'), isFalse);
+ // Only the exact value the workflow writes — a typo must fail safe.
+ expect(isPrereleaseBuild(releaseMode: true, defined: '1'), isFalse);
+ expect(isPrereleaseBuild(releaseMode: true, defined: 'true'), isTrue);
+ });
+
+ test('debug and profile builds always count', () {
+ expect(isPrereleaseBuild(releaseMode: false, defined: ''), isTrue);
+ expect(isPrereleaseBuild(releaseMode: false, defined: 'false'), isTrue);
+ });
+
+ test('the release workflow stamps the mark from its prerelease output', () {
+ final workflow = File(
+ '${Directory.current.path}/.github/workflows/release.yml',
+ ).readAsStringSync();
+ expect(
+ workflow,
+ contains(
+ r'--dart-define=DPIP_PRERELEASE=${{ needs.version.outputs.prerelease }}',
+ ),
+ );
+ // And that output is false for a tag, which is what production ships.
+ expect(
+ workflow,
+ contains('echo "prerelease=false" >> "\$GITHUB_OUTPUT"'),
+ );
+ });
+ });
+
+ // Each of these fails silently: the first two by sending before the app has
+ // decided, the last by a release build dying at launch while debug works.
+ group('native defaults', () {
+ String read(String path) =>
+ File('${Directory.current.path}/$path').readAsStringSync();
+
+ test('Android ships with collection off', () {
+ expect(
+ read('android/app/src/main/AndroidManifest.xml'),
+ matches(
+ RegExp(
+ r'android:name="firebase_crashlytics_collection_enabled"\s+'
+ r'android:value="false"',
+ ),
+ ),
+ );
+ });
+
+ test('iOS ships with collection off', () {
+ expect(
+ read('ios/Runner/Info.plist'),
+ matches(
+ RegExp(r'FirebaseCrashlyticsCollectionEnabled\s*'),
+ ),
+ );
+ });
+
+ test('the build-ID switch exists and survives resource shrinking', () {
+ expect(
+ read('android/app/src/main/res/values/crashlytics.xml'),
+ contains('false'),
+ );
+ // Only a raw keep file is read by the shrinker, and it matches the R
+ // field's spelling — the dotted name is silently never matched.
+ expect(
+ read('android/app/src/main/res/raw/keep.xml'),
+ contains('@bool/com_crashlytics_RequireBuildId'),
+ );
+ });
+ });
+
+ group('PrereleaseDiagnostics.start', () {
+ test('production switches collection off and drops the backlog', () async {
+ final reporter = _FakeReporter();
+ await PrereleaseDiagnostics.start(reporter: reporter, prerelease: false);
+
+ expect(reporter.calls, ['collection=false', 'deleteUnsent']);
+ expect(Log.crashSink, isNull);
+ expect(PrereleaseDiagnostics.reporter, isNull);
+ expect(reporter.keys, isEmpty);
+ });
+
+ test(
+ 'a prerelease build sends, and says what hardware it ran on',
+ () async {
+ final reporter = _FakeReporter();
+ await PrereleaseDiagnostics.start(
+ reporter: reporter,
+ prerelease: true,
+ device: () async => const DeviceDetails(
+ manufacturer: 'Acme',
+ model: 'A1',
+ osVersion: '14',
+ sdkInt: 34,
+ totalMemoryMb: 3072,
+ ),
+ );
+
+ expect(reporter.calls, ['collection=true']);
+ expect(Log.crashSink, isA());
+ expect(PrereleaseDiagnostics.reporter, same(reporter));
+ expect(reporter.keys['build_label'], '26w42a');
+ expect(reporter.keys['build_code'], 426000700);
+ expect(reporter.keys['install_source'], 'testFlight');
+ expect(reporter.keys['ram_mb'], 3072);
+ expect(reporter.keys['android_sdk'], 34);
+ expect(reporter.keys['build_mode'], 'debug');
+ // The tier is decided from the host platform, so only its presence is
+ // pinned here; render_tier_test owns the rule itself.
+ expect(reporter.keys['render_tier'], isA());
+ },
+ );
+
+ test('an unreadable device leaves the hardware keys out', () async {
+ final reporter = _FakeReporter();
+ await PrereleaseDiagnostics.start(
+ reporter: reporter,
+ prerelease: true,
+ device: () async => null,
+ );
+ expect(reporter.keys.containsKey('ram_mb'), isFalse);
+ expect(reporter.keys.containsKey('render_tier'), isFalse);
+ expect(reporter.keys['build_label'], '26w42a');
+ });
+ });
+
+ group('ReportingCrashSink', () {
+ test('forwards a handled error with its context', () async {
+ final reporter = _FakeReporter();
+ Log.crashSink = ReportingCrashSink(reporter);
+
+ final error = StateError('bad row');
+ Log.handle(error, StackTrace.current, 'decoding report');
+ await pumpEventQueue();
+
+ expect(reporter.errors, hasLength(1));
+ expect(reporter.errors.single.error, same(error));
+ expect(reporter.errors.single.reason, 'decoding report');
+ expect(reporter.errors.single.fatal, isFalse);
+ });
+
+ test('drops the network failing, which is not a defect', () async {
+ final reporter = _FakeReporter();
+ final sink = ReportingCrashSink(reporter);
+ final options = RequestOptions(path: '/api/v2/location/1/token/1/25,121');
+
+ sink.report(
+ DioException.connectionError(
+ requestOptions: options,
+ reason: 'offline',
+ ),
+ StackTrace.current,
+ );
+ sink.report(
+ DioException.badResponse(
+ statusCode: 503,
+ requestOptions: options,
+ response: Response(requestOptions: options, statusCode: 503),
+ ),
+ StackTrace.current,
+ );
+ sink.report(const SocketException('no route'), StackTrace.current);
+ sink.report(TimeoutException('slow'), StackTrace.current);
+ sink.report(const HandshakeException('tls'), StackTrace.current);
+ await pumpEventQueue();
+
+ expect(reporter.errors, isEmpty);
+ });
+
+ test('the same fault is reported once a session', () async {
+ final reporter = _FakeReporter();
+ final sink = ReportingCrashSink(reporter);
+
+ for (var frame = 0; frame < 30; frame++) {
+ sink.report(
+ FlutterErrorLike('RenderFlex overflowed by 4 pixels'),
+ StackTrace.current,
+ context: 'layout',
+ fatal: true,
+ );
+ }
+ sink.report(
+ FlutterErrorLike('RenderFlex overflowed by 4 pixels'),
+ StackTrace.current,
+ context: 'a different place',
+ );
+ await pumpEventQueue();
+
+ expect(reporter.errors, hasLength(2));
+ expect(reporter.errors.first.fatal, isTrue);
+ });
+
+ test('stops at the per-session ceiling', () async {
+ final reporter = _FakeReporter();
+ final sink = ReportingCrashSink(reporter);
+
+ for (var i = 0; i < ReportingCrashSink.maxReportsPerSession + 5; i++) {
+ sink.report(StateError('fault $i'), StackTrace.current);
+ }
+ await pumpEventQueue();
+
+ expect(
+ reporter.errors,
+ hasLength(ReportingCrashSink.maxReportsPerSession),
+ );
+ // The first ones are kept: they are usually the cause of the rest.
+ expect((reporter.errors.first.error as StateError).message, 'fault 0');
+ });
+
+ test('a reporter that throws never reaches the caller', () async {
+ final reporter = _FakeReporter()..failRecord = true;
+ Log.crashSink = ReportingCrashSink(reporter);
+
+ expect(
+ () => Log.handle(StateError('boom'), StackTrace.current, 'ctx'),
+ returnsNormally,
+ );
+ await pumpEventQueue();
+ expect(reporter.errors, isEmpty);
+ });
+
+ test('a signature ignores everything after the first line', () {
+ final a = ReportingCrashSink.signature(
+ StateError('same\nbody one'),
+ 'ctx',
+ );
+ final b = ReportingCrashSink.signature(
+ StateError('same\nbody two'),
+ 'ctx',
+ );
+ expect(a, b);
+ final long = ReportingCrashSink.signature(StateError('x' * 1000), null);
+ expect(long.length, lessThan(220));
+ });
+ });
+}
+
+/// A stand-in with a stable message, the shape a repeated layout fault has.
+class FlutterErrorLike implements Exception {
+ FlutterErrorLike(this.message);
+ final String message;
+ @override
+ String toString() => message;
+}
diff --git a/test/core/diagnostics/startup_report_test.dart b/test/core/diagnostics/startup_report_test.dart
new file mode 100644
index 000000000..724596733
--- /dev/null
+++ b/test/core/diagnostics/startup_report_test.dart
@@ -0,0 +1,307 @@
+/// A launch summary has to stay bounded and has to say the same thing about
+/// the same frames — and it must leave the device at most once a session.
+library;
+
+import 'dart:ui' show FrameTiming, TimingsCallback;
+
+import 'package:dpip/core/diagnostics/crash_reporting.dart';
+import 'package:dpip/core/diagnostics/prerelease_diagnostics.dart';
+import 'package:dpip/core/diagnostics/startup_report.dart';
+import 'package:dpip/core/logging/log.dart';
+import 'package:dpip/core/platform/install_source.dart';
+import 'package:fake_async/fake_async.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+/// A frame whose build took [buildUs] and raster took [rasterUs].
+FrameTiming frame(int buildUs, int rasterUs) => FrameTiming(
+ vsyncStart: 0,
+ buildStart: 0,
+ buildFinish: buildUs,
+ rasterStart: buildUs,
+ rasterFinish: buildUs + rasterUs,
+ rasterFinishWallTime: buildUs + rasterUs,
+);
+
+class _Engine {
+ TimingsCallback? callback;
+ int removed = 0;
+
+ void add(TimingsCallback c) => callback = c;
+ // `==`, as SchedulerBinding's own list removal does: a method tear-off is
+ // equal to, not identical with, the one that was added.
+ void remove(TimingsCallback c) {
+ if (c == callback) callback = null;
+ removed++;
+ }
+
+ void deliver(List timings) => callback?.call(timings);
+}
+
+class _Reporter implements CrashReporter {
+ final keys = {};
+ final errors =