From 5d4cee03cd419ac220b0bea2410cae099127a955 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 16 Oct 2026 07:25:46 +0800 Subject: [PATCH 1/3] feat(diagnostics): report crashes from snapshot builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: all New(zh-Hant): 測試版會自動把錯誤回報給開發團隊,正式版不會傳送任何錯誤報告 New(en-US): snapshot builds report errors to the developers automatically; release builds send no error reports --- .github/workflows/release.yml | 8 +- ARCHITECTURE.md | 16 +- android/app/src/main/AndroidManifest.xml | 7 + android/app/src/main/res/raw/keep.xml | 11 +- .../app/src/main/res/values/crashlytics.xml | 26 ++ ios/Runner/Info.plist | 5 + lib/bootstrap.dart | 10 + lib/core/diagnostics/crash_reporting.dart | 166 ++++++++++ .../diagnostics/prerelease_diagnostics.dart | 118 +++++++ pubspec.lock | 24 ++ pubspec.yaml | 8 + .../diagnostics/crash_reporting_test.dart | 313 ++++++++++++++++++ 12 files changed, 708 insertions(+), 4 deletions(-) create mode 100644 android/app/src/main/res/values/crashlytics.xml create mode 100644 lib/core/diagnostics/crash_reporting.dart create mode 100644 lib/core/diagnostics/prerelease_diagnostics.dart create mode 100644 test/core/diagnostics/crash_reporting_test.dart 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..614f5c385 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -119,6 +119,18 @@ 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. + ## State management `provider`. `bootstrap()` assembles the shared @@ -138,8 +150,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 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/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) DPIPWidgetAppGroupIdentifier group.com.exptech.dpip.dpip.widgets + + FirebaseCrashlyticsCollectionEnabled + ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 4e83d98c5..c36d1e233 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -7,6 +7,8 @@ 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/geo/device_location_reporter.dart'; import 'package:dpip/core/geo/device_registration.dart'; import 'package:dpip/core/geo/location_api.dart'; @@ -676,7 +678,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..541cd1880 --- /dev/null +++ b/lib/core/diagnostics/prerelease_diagnostics.dart @@ -0,0 +1,118 @@ +/// 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/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; + } + } + + /// Test seam. + @visibleForTesting + static void debugReset() => _reporter = null; +} 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; +} From 426a39f14c77f41ba3b61abf14658f5e3a3d462a Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 16 Oct 2026 07:34:29 +0800 Subject: [PATCH 2/3] feat(diagnostics): summarize launch timing from snapshot builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: all New(zh-Hant): 測試版會回報啟動速度與地圖畫面的流暢度,協助改善低階手機的效能 New(en-US): snapshot builds report launch time and map smoothness, to help speed the app up on low-end phones --- ARCHITECTURE.md | 5 +- lib/app/app.dart | 2 + lib/app/shell/main_shell.dart | 30 ++ lib/bootstrap.dart | 10 + .../diagnostics/prerelease_diagnostics.dart | 72 +++- lib/core/diagnostics/startup_report.dart | 208 ++++++++++++ .../core/diagnostics/startup_report_test.dart | 307 ++++++++++++++++++ 7 files changed, 632 insertions(+), 2 deletions(-) create mode 100644 lib/core/diagnostics/startup_report.dart create mode 100644 test/core/diagnostics/startup_report_test.dart diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 614f5c385..2ba6a75b5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -129,7 +129,10 @@ 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. +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 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 c36d1e233..d304cd58f 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -9,6 +9,7 @@ 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'; @@ -498,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, diff --git a/lib/core/diagnostics/prerelease_diagnostics.dart b/lib/core/diagnostics/prerelease_diagnostics.dart index 541cd1880..7d0334171 100644 --- a/lib/core/diagnostics/prerelease_diagnostics.dart +++ b/lib/core/diagnostics/prerelease_diagnostics.dart @@ -4,6 +4,7 @@ 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'; @@ -112,7 +113,76 @@ abstract final class PrereleaseDiagnostics { } } + 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; + 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/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 = []; + final lines = []; + + @override + Future setCollectionEnabled(bool enabled) async {} + @override + Future deleteUnsentReports() async {} + @override + Future recordError( + Object error, + StackTrace stackTrace, { + String? reason, + required bool fatal, + }) async => errors.add(error); + @override + Future log(String message) async => lines.add(message); + @override + Future setKey(String key, Object value) async => keys[key] = value; +} + +FrameSummary summaryOf(int frames, {int buildUs = 4000}) => FrameSummary.of( + List.filled(frames, buildUs), + List.filled(frames, 5000), + budgetUs: 16667, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Percentiles', () { + test('nearest rank: every percentile is a value that was observed', () { + final p = Percentiles.of(List.generate(100, (i) => i + 1)); + expect(p.p50, 50); + expect(p.p90, 90); + expect(p.p99, 99); + }); + + test('one slow frame in a hundred is the p99, not an average', () { + final values = [...List.filled(99, 5000), 80000]; + final p = Percentiles.of(values); + expect(p.p50, 5000); + expect(p.p99, 5000); + expect(Percentiles.of([...values, 80000]).p99, 80000); + }); + + test('empty and single inputs do not throw', () { + expect(Percentiles.of(const []).p99, 0); + expect(Percentiles.of(const [7]).p50, 7); + expect(Percentiles.of(const [7]).p99, 7); + }); + }); + + group('FrameSummary', () { + test('a frame is janky when either thread missed the budget', () { + final summary = FrameSummary.of( + [4000, 20000, 4000, 4000], + [4000, 4000, 30000, 4000], + budgetUs: 16667, + ); + expect(summary.frames, 4); + expect(summary.janky, 2); + expect(summary.jankyFraction, 0.5); + expect(summary.keys['frames_janky'], 2); + expect(summary.keys['build_p99_us'], 20000); + expect(summary.keys['raster_p99_us'], 30000); + expect( + summary.describe(), + startsWith('frames=4 janky=2 (50.0%) budget=16.7ms'), + ); + }); + + test('an empty window is not a division by zero', () { + final summary = FrameSummary.of(const [], const [], budgetUs: 16667); + expect(summary.jankyFraction, 0); + expect(summary.describe(), contains('frames=0')); + }); + }); + + group('StartupFrameSampler', () { + test('summarizes at the end of the window and stops listening', () { + fakeAsync((async) { + final engine = _Engine(); + FrameSummary? out; + StartupFrameSampler( + onSummary: (s) => out = s, + window: const Duration(seconds: 20), + budgetUs: 16667, + addTimings: engine.add, + removeTimings: engine.remove, + ).start(); + + engine.deliver([frame(4000, 5000), frame(20000, 5000)]); + async.elapse(const Duration(seconds: 19)); + expect(out, isNull); + engine.deliver([frame(4000, 5000)]); + async.elapse(const Duration(seconds: 2)); + + expect(out!.frames, 3); + expect(out!.janky, 1); + expect(engine.callback, isNull, reason: 'the engine stops reporting'); + // Nothing after the window counts or calls back again. + engine.deliver([frame(1, 1)]); + expect(out!.frames, 3); + }); + }); + + test('a frame ceiling ends the window early', () { + fakeAsync((async) { + final engine = _Engine(); + final summaries = []; + StartupFrameSampler( + onSummary: summaries.add, + maxFrames: 5, + budgetUs: 16667, + addTimings: engine.add, + removeTimings: engine.remove, + ).start(); + + engine.deliver(List.generate(8, (_) => frame(1000, 1000))); + expect(summaries, hasLength(1)); + expect(summaries.single.frames, 5); + async.elapse(const Duration(minutes: 1)); + expect(summaries, hasLength(1), reason: 'the timer was cancelled'); + }); + }); + + test('batches that arrive off the measured screen are dropped', () { + fakeAsync((async) { + final engine = _Engine(); + var onMap = true; + FrameSummary? out; + StartupFrameSampler( + onSummary: (s) => out = s, + counting: () => onMap, + budgetUs: 16667, + addTimings: engine.add, + removeTimings: engine.remove, + ).start(); + + engine.deliver([frame(1000, 1000), frame(1000, 1000)]); + onMap = false; + engine.deliver([frame(90000, 90000)]); + onMap = true; + engine.deliver([frame(1000, 1000)]); + async.elapse(const Duration(seconds: 21)); + + expect(out!.frames, 3); + expect(out!.janky, 0); + }); + }); + + test('stop abandons the window without a summary', () { + fakeAsync((async) { + final engine = _Engine(); + var called = false; + final sampler = StartupFrameSampler( + onSummary: (_) => called = true, + budgetUs: 16667, + addTimings: engine.add, + removeTimings: engine.remove, + )..start(); + sampler.stop(); + async.elapse(const Duration(minutes: 1)); + expect(called, isFalse); + expect(engine.callback, isNull); + }); + }); + + test('the budget follows a sane refresh rate', () { + final budget = StartupFrameSampler.refreshBudgetUs(); + // 240 Hz .. 30 Hz. + expect(budget, inInclusiveRange(4166, 33334)); + }); + }); + + group('PrereleaseDiagnostics.reportStartup', () { + late _Reporter reporter; + + setUp(() async { + StartupMarks.debugReset(); + PrereleaseDiagnostics.debugReset(); + InstallSourceService.debugSet(InstallSource.testFlight); + reporter = _Reporter(); + await PrereleaseDiagnostics.start( + reporter: reporter, + prerelease: true, + device: () async => null, + ); + reporter.keys.clear(); + }); + + tearDown(() { + StartupMarks.debugReset(); + PrereleaseDiagnostics.debugReset(); + InstallSourceService.debugSet(null); + Log.crashSink = null; + }); + + test('sends one summary with the phases and the frames as keys', () async { + StartupMarks.mark('ready', 420); + StartupMarks.mark('first_frame', 610); + + await PrereleaseDiagnostics.reportStartup( + summaryOf(120), + debugMode: false, + ); + + expect(reporter.errors.single, isA()); + expect(reporter.keys['startup_ready_ms'], 420); + expect(reporter.keys['startup_first_frame_ms'], 610); + expect(reporter.keys['frames_sampled'], 120); + expect(reporter.lines.single, contains('ready=420ms')); + }); + + test('at most once a session', () async { + await PrereleaseDiagnostics.reportStartup( + summaryOf(120), + debugMode: false, + ); + await PrereleaseDiagnostics.reportStartup( + summaryOf(120), + debugMode: false, + ); + expect(reporter.errors, hasLength(1)); + }); + + test('a debug build only logs it', () async { + await PrereleaseDiagnostics.reportStartup( + summaryOf(120), + debugMode: true, + ); + expect(reporter.errors, isEmpty); + expect(reporter.keys, isEmpty); + }); + + test('too few frames is not a launch worth sending', () async { + await PrereleaseDiagnostics.reportStartup( + summaryOf(PrereleaseDiagnostics.minStartupFrames - 1), + debugMode: false, + ); + expect(reporter.errors, isEmpty); + // And it does not use up the session's one summary. + await PrereleaseDiagnostics.reportStartup( + summaryOf(PrereleaseDiagnostics.minStartupFrames), + debugMode: false, + ); + expect(reporter.errors, hasLength(1)); + }); + + test('production has no reporter, so nothing is sent', () async { + PrereleaseDiagnostics.debugReset(); + await PrereleaseDiagnostics.start(reporter: reporter, prerelease: false); + await PrereleaseDiagnostics.reportStartup( + summaryOf(120), + debugMode: false, + ); + expect(reporter.errors, isEmpty); + }); + + test('the first mark for a phase wins over a hot restart', () { + StartupMarks.mark('ready', 400); + StartupMarks.mark('ready', 9000); + expect(StartupMarks.all['ready'], 400); + }); + }); +} From 4906aff1f16b27398d25c44f44f9cb4504b340ca Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 16 Oct 2026 07:46:45 +0800 Subject: [PATCH 3/3] chore(notify): record push delivery in an iOS service extension --- ARCHITECTURE.md | 9 + ios/NotificationService/Info.plist | 13 ++ .../NotificationDeliveryBreadcrumb.swift | 100 +++++++++ .../NotificationService.entitlements | 12 ++ .../NotificationService.swift | 82 ++++++++ ios/Runner.xcodeproj/project.pbxproj | 198 +++++++++++++++++- ios/Runner/AppDelegate.swift | 7 +- .../NotificationDeliveryBreadcrumbTests.swift | 107 ++++++++++ 8 files changed, 525 insertions(+), 3 deletions(-) create mode 100644 ios/NotificationService/Info.plist create mode 100644 ios/NotificationService/NotificationDeliveryBreadcrumb.swift create mode 100644 ios/NotificationService/NotificationService.entitlements create mode 100644 ios/NotificationService/NotificationService.swift create mode 100644 ios/RunnerTests/NotificationDeliveryBreadcrumbTests.swift diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2ba6a75b5..ab5d9e7d7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -284,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/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/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 + ) + } +}