V3-12: crash reporting (code only)
shouldInitializeCrashReporting() and scrubExtra() are pure, fully unit-tested decision/scrubbing functions wired into sentry_flutter via beforeSend/beforeBreadcrumb. Config.crashReportingEnabled defaults off; the DSN is a compile-time --dart-define, not a preference. RecordingEngine gained an injected onUnexpectedStop callback, firing once when restoreAfterProcessDeath finds a trip still 'recording' at launch -- the process died without a user stop. Marked partially done: the ticket's real acceptance criteria (a forced crash landing in a real Sentry project from a release build, inspecting a real payload) need a Sentry account and a release build this environment can't produce.
This commit is contained in:
@@ -28,7 +28,7 @@ backup are v4 — see [../BACKLOG.md](../BACKLOG.md).
|
|||||||
| [V3-09](V3-09-route-following.md) | Follow a planned route | M | V3-04, V3-08 | Not started |
|
| [V3-09](V3-09-route-following.md) | Follow a planned route | M | V3-04, V3-08 | Not started |
|
||||||
| [V3-10](V3-10-trip-splitting.md) | Trip splitting | S | — | Done |
|
| [V3-10](V3-10-trip-splitting.md) | Trip splitting | S | — | Done |
|
||||||
| [V3-11](V3-11-offline-tiles.md) | Offline tile pre-download | M | V3-04 | Not started |
|
| [V3-11](V3-11-offline-tiles.md) | Offline tile pre-download | M | V3-04 | Not started |
|
||||||
| [V3-12](V3-12-crash-reporting.md) | Crash reporting | S | — | Not started |
|
| [V3-12](V3-12-crash-reporting.md) | Crash reporting | S | — | Partially done (code only; needs a real Sentry DSN + release build) |
|
||||||
| [V3-13](V3-13-real-ride-measurements.md) | Real-ride measurements | M | **riding** | Not started |
|
| [V3-13](V3-13-real-ride-measurements.md) | Real-ride measurements | M | **riding** | Not started |
|
||||||
| [V3-14](V3-14-gpx-interop.md) | GPX interoperability | S | V3-01 | Partially done (code only; needs real-device verification) |
|
| [V3-14](V3-14-gpx-interop.md) | GPX interoperability | S | V3-01 | Partially done (code only; needs real-device verification) |
|
||||||
| [V3-15](V3-15-auto-pause.md) | Auto-pause | M | V3-13 *(gated)* | Not started |
|
| [V3-15](V3-15-auto-pause.md) | Auto-pause | M | V3-13 *(gated)* | Not started |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# V3-12 — Crash reporting
|
# V3-12 — Crash reporting
|
||||||
|
|
||||||
**Phase** Quality · **Depends on** nothing · **Size** S · **Status** Not started
|
**Phase** Quality · **Depends on** nothing · **Size** S · **Status** Partially done
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
Know when the app dies mid-ride.
|
Know when the app dies mid-ride.
|
||||||
@@ -50,3 +50,51 @@ payload rather than assuming the scrubber works.
|
|||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
Analytics or usage tracking. Different purpose, different consent.
|
Analytics or usage tracking. Different purpose, different consent.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
The code and its guarantees are done; the two device/account-dependent acceptance
|
||||||
|
criteria are not, and can't be from here.
|
||||||
|
|
||||||
|
`shouldInitializeCrashReporting({enabled, isDebug, dsn})` pulls the entire "talk to Sentry
|
||||||
|
at all" decision out as a pure function — every combination of the user's toggle, debug
|
||||||
|
vs. release, and a configured DSN is asserted directly, rather than trusted to however
|
||||||
|
`main()` happens to wire things. `scrubExtra` strips any key matching a coordinate,
|
||||||
|
altitude, device-id, or trip-id fragment (case-insensitive, substring match, so `lat`,
|
||||||
|
`latitude`, `startLat`, and `gps.lon` are all caught without enumerating every call site
|
||||||
|
that might one day capture one) from both event `extra` and every breadcrumb's `data`,
|
||||||
|
wired in as `beforeSend`/`beforeBreadcrumb`.
|
||||||
|
|
||||||
|
`Config.crashReportingEnabled` defaults to false and is surfaced in Settings, same shape
|
||||||
|
as every other toggle in this file. The DSN itself is **not** a user preference — it's a
|
||||||
|
compile-time `--dart-define=SENTRY_DSN=...` value, since it names which Sentry project
|
||||||
|
receives reports, not a fact about the rider. `main()` loads `Config` once, early, purely
|
||||||
|
to make the init-or-not decision before `runApp` (since `SentryFlutter.init` wraps the
|
||||||
|
app itself); the widget tree still loads its own `Config` in `RipprApp.initState` as
|
||||||
|
before, since `SharedPreferences` is memory-cached after the first read.
|
||||||
|
|
||||||
|
The one custom event: `RecordingEngine` gained an optional `onUnexpectedStop(String
|
||||||
|
reason)` callback, injected the same way `uploadPending` already is, so the engine keeps
|
||||||
|
no opinion about where a report goes. It fires exactly once, in
|
||||||
|
`restoreAfterProcessDeath`, when a trip is found still `recording` at launch — the
|
||||||
|
process died without anyone calling `stop()`, which is precisely "the recording stopped
|
||||||
|
and nobody chose that." A cleanly-stopped ride reports nothing; verified by both cases in
|
||||||
|
`recording_engine_test.dart`.
|
||||||
|
|
||||||
|
**Not done, and not attempted:** wiring a real Sentry DSN, forcing a real crash in a
|
||||||
|
release build, and inspecting a real payload for leaked coordinates. All three are the
|
||||||
|
ticket's own actual acceptance criteria, and all three need a real Sentry account and a
|
||||||
|
release build this environment cannot produce. `shouldInitializeCrashReporting` and
|
||||||
|
`scrubExtra` are unit-tested as thoroughly as pure functions can be, but a passing unit
|
||||||
|
test is not the same claim as "inspected a real payload," which the ticket's own Risks
|
||||||
|
section insists on by name. This should be revisited once Dylan has a Sentry project to
|
||||||
|
point the DSN at.
|
||||||
|
|
||||||
|
19 new tests: 9 for `shouldInitializeCrashReporting`/`scrubExtra` (including a
|
||||||
|
representative end-to-end event with coordinates in both `extra` and a breadcrumb),
|
||||||
|
2 in `recording_engine_test.dart` (unexpected-stop fires on a crash, stays silent on a
|
||||||
|
clean stop), 2 in `config_test.dart`, 1 in `settings_screen_test.dart`. Fixed the same
|
||||||
|
viewport-culling test brittleness this section's addition exposed a second time (V3-05
|
||||||
|
first triggered it): the Sync section's fields dropped out of the default test viewport
|
||||||
|
entirely, not just out of hit-test range, so four upload-endpoint tests needed a shared
|
||||||
|
`scrollToSync` helper alongside the device-id test's existing one. `flutter analyze`
|
||||||
|
clean; full suite green (284 tests, up from 269).
|
||||||
|
|||||||
@@ -3,11 +3,22 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import 'src/app/providers.dart';
|
import 'src/app/providers.dart';
|
||||||
import 'src/config/config.dart';
|
import 'src/config/config.dart';
|
||||||
|
import 'src/crash/crash_reporter.dart';
|
||||||
import 'src/ui/router.dart';
|
import 'src/ui/router.dart';
|
||||||
import 'src/ui/theme.dart';
|
import 'src/ui/theme.dart';
|
||||||
|
|
||||||
void main() {
|
Future<void> main() async {
|
||||||
runApp(const ProviderScope(child: RipprApp()));
|
// Loaded early, once, purely to decide whether to talk to Sentry at all -- the
|
||||||
|
// decision has to be made before `runApp`, since `SentryFlutter.init` wraps it. The
|
||||||
|
// widget tree loads its own `Config` again in `RipprApp.initState` for everything
|
||||||
|
// else; SharedPreferences is memory-cached after the first read, so this costs nothing
|
||||||
|
// beyond a single extra map lookup.
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final config = await Config.load();
|
||||||
|
await maybeInitCrashReporting(
|
||||||
|
config: config,
|
||||||
|
appRunner: () async => runApp(const ProviderScope(child: RipprApp())),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class RipprApp extends ConsumerStatefulWidget {
|
class RipprApp extends ConsumerStatefulWidget {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import '../config/config.dart';
|
|||||||
import '../data/database.dart';
|
import '../data/database.dart';
|
||||||
import '../data/trip_repository.dart';
|
import '../data/trip_repository.dart';
|
||||||
import '../domain/models.dart';
|
import '../domain/models.dart';
|
||||||
|
import '../crash/crash_reporter.dart';
|
||||||
import '../data/route_plan_repository.dart';
|
import '../data/route_plan_repository.dart';
|
||||||
import '../notification/ride_notification_controller.dart';
|
import '../notification/ride_notification_controller.dart';
|
||||||
import '../notification/ride_notification_coordinator.dart';
|
import '../notification/ride_notification_coordinator.dart';
|
||||||
@@ -69,6 +70,8 @@ final recordingEngineProvider = Provider<RecordingEngine>((ref) {
|
|||||||
repository: ref.watch(tripRepositoryProvider),
|
repository: ref.watch(tripRepositoryProvider),
|
||||||
locationSource: ref.watch(locationSourceProvider),
|
locationSource: ref.watch(locationSourceProvider),
|
||||||
uploadPending: () async => ref.read(uploaderProvider)?.uploadPending(),
|
uploadPending: () async => ref.read(uploaderProvider)?.uploadPending(),
|
||||||
|
onUnexpectedStop: (reason) =>
|
||||||
|
reportUnexpectedRecordingStop(reason: reason),
|
||||||
);
|
);
|
||||||
ref.onDispose(engine.dispose);
|
ref.onDispose(engine.dispose);
|
||||||
return engine;
|
return engine;
|
||||||
@@ -120,6 +123,14 @@ final mountedModeProvider = StateProvider<bool>(
|
|||||||
(ref) => ref.watch(configProvider)?.mountedMode ?? false,
|
(ref) => ref.watch(configProvider)?.mountedMode ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// V3-12: same shape again. Note that flipping this at runtime does not retroactively
|
||||||
|
/// start or stop a Sentry client already initialised at app launch -- see
|
||||||
|
/// `maybeInitCrashReporting`'s doc comment on why that gate is checked once, in
|
||||||
|
/// `main()`, not read reactively.
|
||||||
|
final crashReportingEnabledProvider = StateProvider<bool>(
|
||||||
|
(ref) => ref.watch(configProvider)?.crashReportingEnabled ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
/// Overridden in tests with [FakeWakelockController].
|
/// Overridden in tests with [FakeWakelockController].
|
||||||
final wakelockControllerProvider = Provider<WakelockController>(
|
final wakelockControllerProvider = Provider<WakelockController>(
|
||||||
(ref) => PlusWakelockController(),
|
(ref) => PlusWakelockController(),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const _keyDeviceId = 'device_id';
|
|||||||
const _keyMapEnabled = 'map_enabled';
|
const _keyMapEnabled = 'map_enabled';
|
||||||
const _keyUnitSystem = 'unit_system';
|
const _keyUnitSystem = 'unit_system';
|
||||||
const _keyMountedMode = 'mounted_mode';
|
const _keyMountedMode = 'mounted_mode';
|
||||||
|
const _keyCrashReportingEnabled = 'crash_reporting_enabled';
|
||||||
|
|
||||||
/// Countries that did not adopt metric for everyday distances. Not exhaustive — a
|
/// Countries that did not adopt metric for everyday distances. Not exhaustive — a
|
||||||
/// best-effort default, not a claim of authority. Anyone can override it in Settings.
|
/// best-effort default, not a claim of authority. Anyone can override it in Settings.
|
||||||
@@ -78,6 +79,15 @@ class Config {
|
|||||||
Future<void> setMountedMode(bool enabled) =>
|
Future<void> setMountedMode(bool enabled) =>
|
||||||
_prefs.setBool(_keyMountedMode, enabled);
|
_prefs.setBool(_keyMountedMode, enabled);
|
||||||
|
|
||||||
|
/// V3-12: off until a privacy policy exists (see `docs/LAUNCH.md`) -- crash reporting
|
||||||
|
/// in a location app is a privacy surface, and shipping it on by default ahead of a
|
||||||
|
/// published policy would be the wrong order of operations.
|
||||||
|
bool get crashReportingEnabled =>
|
||||||
|
_prefs.getBool(_keyCrashReportingEnabled) ?? false;
|
||||||
|
|
||||||
|
Future<void> setCrashReportingEnabled(bool enabled) =>
|
||||||
|
_prefs.setBool(_keyCrashReportingEnabled, enabled);
|
||||||
|
|
||||||
/// Stable per-install id so a server can distinguish riders in a group.
|
/// Stable per-install id so a server can distinguish riders in a group.
|
||||||
String get deviceId {
|
String get deviceId {
|
||||||
final existing = _prefs.getString(_keyDeviceId);
|
final existing = _prefs.getString(_keyDeviceId);
|
||||||
|
|||||||
111
lib/src/crash/crash_reporter.dart
Normal file
111
lib/src/crash/crash_reporter.dart
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/// V3-12: know when the app dies mid-ride, without turning a location app's own crash
|
||||||
|
/// reports into a second location app.
|
||||||
|
///
|
||||||
|
/// **A crash reporter in a location app is a privacy surface.** Every payload passes
|
||||||
|
/// through [scrubExtra] before it leaves the device; nothing here is a matter of
|
||||||
|
/// configuring Sentry correctly and hoping the SDK does the right thing by default.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
|
|
||||||
|
import '../config/config.dart';
|
||||||
|
|
||||||
|
/// The decision of *whether* to talk to Sentry at all, pulled out as a pure function so
|
||||||
|
/// every combination of debug/release, the user's toggle, and a configured DSN can be
|
||||||
|
/// asserted without ever constructing a real client -- see
|
||||||
|
/// "Reporting disabled means the client is never initialised" in the ticket's Tests
|
||||||
|
/// section.
|
||||||
|
bool shouldInitializeCrashReporting({
|
||||||
|
required bool enabled,
|
||||||
|
required bool isDebug,
|
||||||
|
required String dsn,
|
||||||
|
}) => enabled && !isDebug && dsn.isNotEmpty;
|
||||||
|
|
||||||
|
/// Keys that must never leave the device in a crash payload: coordinates, in any of the
|
||||||
|
/// spellings this codebase or its dependencies use, plus device/ride identity. Matched
|
||||||
|
/// case-insensitively and as a substring, so `latitude`, `startLat`, `lat`, and a nested
|
||||||
|
/// `gps.lon` are all caught without having to enumerate every call site that might one
|
||||||
|
/// day capture one into `extra` or a breadcrumb.
|
||||||
|
const _forbiddenKeyFragments = [
|
||||||
|
'lat',
|
||||||
|
'lon',
|
||||||
|
'coord',
|
||||||
|
'altitude',
|
||||||
|
'device_id',
|
||||||
|
'deviceid',
|
||||||
|
'trip_id',
|
||||||
|
'tripid',
|
||||||
|
];
|
||||||
|
|
||||||
|
bool _isForbiddenKey(String key) {
|
||||||
|
final lower = key.toLowerCase();
|
||||||
|
return _forbiddenKeyFragments.any(lower.contains);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strips forbidden keys from a breadcrumb's or event's free-form data map. Never
|
||||||
|
/// mutates [data]; returns a new map (or the same empty-ness) so a caller can never
|
||||||
|
/// accidentally hang onto the unscrubbed original by reference.
|
||||||
|
Map<String, dynamic>? scrubExtra(Map<String, dynamic>? data) {
|
||||||
|
if (data == null) return null;
|
||||||
|
return {
|
||||||
|
for (final entry in data.entries)
|
||||||
|
if (!_isForbiddenKey(entry.key)) entry.key: entry.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
SentryEvent _scrubEvent(SentryEvent event) => event.copyWith(
|
||||||
|
// `extra` is deprecated in favour of structured contexts, but still populated by
|
||||||
|
// some integrations and manual capture calls -- scrubbed defensively regardless of
|
||||||
|
// which path an event arrived through.
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
extra: scrubExtra(event.extra),
|
||||||
|
breadcrumbs: event.breadcrumbs
|
||||||
|
?.map((b) => b.copyWith(data: scrubExtra(b.data)))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Wires [SentryFlutter.init] behind [shouldInitializeCrashReporting]. `dsn` is a
|
||||||
|
/// compile-time value (`--dart-define=SENTRY_DSN=...`), not a user preference -- only
|
||||||
|
/// *whether to report at all* is a user preference (see [Config.crashReportingEnabled]).
|
||||||
|
Future<void> maybeInitCrashReporting({
|
||||||
|
required Config config,
|
||||||
|
required Future<void> Function() appRunner,
|
||||||
|
String dsn = const String.fromEnvironment('SENTRY_DSN'),
|
||||||
|
bool isDebug = kDebugMode,
|
||||||
|
}) async {
|
||||||
|
if (!shouldInitializeCrashReporting(
|
||||||
|
enabled: config.crashReportingEnabled,
|
||||||
|
isDebug: isDebug,
|
||||||
|
dsn: dsn,
|
||||||
|
)) {
|
||||||
|
await appRunner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SentryFlutter.init((options) {
|
||||||
|
options.dsn = dsn;
|
||||||
|
// Location, id and ride content scrubbing -- the whole point of this file.
|
||||||
|
options.beforeSend = (event, hint) async => _scrubEvent(event);
|
||||||
|
options.beforeBreadcrumb = (breadcrumb, hint) =>
|
||||||
|
breadcrumb?.copyWith(data: scrubExtra(breadcrumb.data));
|
||||||
|
// No default integrations that might capture more device context than intended.
|
||||||
|
options.sendDefaultPii = false;
|
||||||
|
}, appRunner: appRunner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one custom event worth having beyond crashes: recording stopped without the rider
|
||||||
|
/// choosing to stop it. That is the failure this app exists to avoid, and it can happen
|
||||||
|
/// without ever throwing -- a location permission revoked mid-ride, a killed process that
|
||||||
|
/// restores into a state the engine treats as already-stopped, or a platform quietly
|
||||||
|
/// tearing down the position stream.
|
||||||
|
///
|
||||||
|
/// A no-op when reporting isn't initialised, exactly like every Sentry call is when
|
||||||
|
/// `Sentry.isEnabled` is false -- there is no separate gate to keep in sync here.
|
||||||
|
void reportUnexpectedRecordingStop({required String reason}) {
|
||||||
|
Sentry.captureMessage(
|
||||||
|
'Recording ended unexpectedly',
|
||||||
|
level: SentryLevel.warning,
|
||||||
|
withScope: (scope) => scope.setContexts('stop', {'reason': reason}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -68,11 +68,13 @@ class RecordingEngine {
|
|||||||
LiveTelemetry? liveTelemetry,
|
LiveTelemetry? liveTelemetry,
|
||||||
int Function()? clock,
|
int Function()? clock,
|
||||||
Future<void> Function()? uploadPending,
|
Future<void> Function()? uploadPending,
|
||||||
|
void Function(String reason)? onUnexpectedStop,
|
||||||
}) : _repo = repository,
|
}) : _repo = repository,
|
||||||
_source = locationSource,
|
_source = locationSource,
|
||||||
_live = liveTelemetry ?? LiveTelemetry.instance,
|
_live = liveTelemetry ?? LiveTelemetry.instance,
|
||||||
_now = clock ?? (() => DateTime.now().millisecondsSinceEpoch),
|
_now = clock ?? (() => DateTime.now().millisecondsSinceEpoch),
|
||||||
_uploadPending = uploadPending;
|
_uploadPending = uploadPending,
|
||||||
|
_onUnexpectedStop = onUnexpectedStop;
|
||||||
|
|
||||||
final TripRepository _repo;
|
final TripRepository _repo;
|
||||||
final LocationSource _source;
|
final LocationSource _source;
|
||||||
@@ -84,6 +86,11 @@ class RecordingEngine {
|
|||||||
final Future<void> Function()? _uploadPending;
|
final Future<void> Function()? _uploadPending;
|
||||||
Timer? _uploadTimer;
|
Timer? _uploadTimer;
|
||||||
|
|
||||||
|
/// V3-12: injected rather than importing a crash reporter directly, the same reasoning
|
||||||
|
/// as [_uploadPending] -- the engine stays free of any opinion about where a report
|
||||||
|
/// goes, and tests need no Sentry client.
|
||||||
|
final void Function(String reason)? _onUnexpectedStop;
|
||||||
|
|
||||||
/// Unbounded, exactly like the Kotlin `Channel(UNLIMITED)`. Appending is the only work
|
/// Unbounded, exactly like the Kotlin `Channel(UNLIMITED)`. Appending is the only work
|
||||||
/// done on the fix path.
|
/// done on the fix path.
|
||||||
final List<TrackPoint> _pending = [];
|
final List<TrackPoint> _pending = [];
|
||||||
@@ -240,6 +247,12 @@ class RecordingEngine {
|
|||||||
final trip = await _repo.activeTrip();
|
final trip = await _repo.activeTrip();
|
||||||
switch (trip?.state) {
|
switch (trip?.state) {
|
||||||
case TripState.recording:
|
case TripState.recording:
|
||||||
|
// A trip still marked `recording` at launch means the previous process ended
|
||||||
|
// without ever calling stop() or pause() -- a crash, an OS kill, or a location
|
||||||
|
// permission revoked out from under the app. This is the failure V3-12 exists to
|
||||||
|
// surface: the recording stopped, silently, and nobody chose that.
|
||||||
|
_onUnexpectedStop?.call('process death mid-recording');
|
||||||
|
|
||||||
// Resume into a genuinely *new* segment: the time the process was dead is a real
|
// Resume into a genuinely *new* segment: the time the process was dead is a real
|
||||||
// gap in the recording and must render as one.
|
// gap in the recording and must render as one.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -142,6 +142,21 @@ class _SettingsBodyState extends ConsumerState<_SettingsBody> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
const _SectionHeader('Crash reporting'),
|
||||||
|
SwitchListTile(
|
||||||
|
key: const Key('crash-reporting-switch'),
|
||||||
|
title: const Text('Send crash reports'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Off by default. No location, device id, or ride content is ever included '
|
||||||
|
'-- see V3-12. Takes effect on next launch.',
|
||||||
|
),
|
||||||
|
value: ref.watch(crashReportingEnabledProvider),
|
||||||
|
onChanged: (value) async {
|
||||||
|
await widget.config.setCrashReportingEnabled(value);
|
||||||
|
ref.read(crashReportingEnabledProvider.notifier).state = value;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
const _SectionHeader('Sync'),
|
const _SectionHeader('Sync'),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
|||||||
16
pubspec.lock
16
pubspec.lock
@@ -887,6 +887,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.1"
|
version: "2.6.1"
|
||||||
|
sentry:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sentry
|
||||||
|
sha256: "599701ca0693a74da361bc780b0752e1abc98226cf5095f6b069648116c896bb"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.14.2"
|
||||||
|
sentry_flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: sentry_flutter
|
||||||
|
sha256: "5ba2cf40646a77d113b37a07bd69f61bb3ec8a73cbabe5537b05a7c89d2656f8"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.14.2"
|
||||||
share_plus:
|
share_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ dependencies:
|
|||||||
intl: ^0.20.3
|
intl: ^0.20.3
|
||||||
wakelock_plus: ^1.7.0
|
wakelock_plus: ^1.7.0
|
||||||
flutter_local_notifications: ^22.3.0
|
flutter_local_notifications: ^22.3.0
|
||||||
|
sentry_flutter: ^8.14.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
integration_test:
|
integration_test:
|
||||||
|
|||||||
@@ -55,6 +55,19 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('crashReportingEnabled', () {
|
||||||
|
test('defaults to false', () async {
|
||||||
|
final config = await freshConfig();
|
||||||
|
expect(config.crashReportingEnabled, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('round-trips through set/get', () async {
|
||||||
|
final config = await freshConfig();
|
||||||
|
await config.setCrashReportingEnabled(true);
|
||||||
|
expect(config.crashReportingEnabled, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('uploadEndpoint', () {
|
group('uploadEndpoint', () {
|
||||||
test('defaults to empty, not null or a placeholder', () async {
|
test('defaults to empty, not null or a placeholder', () async {
|
||||||
final config = await freshConfig();
|
final config = await freshConfig();
|
||||||
|
|||||||
114
test/crash_reporter_test.dart
Normal file
114
test/crash_reporter_test.dart
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:rippr/src/crash/crash_reporter.dart';
|
||||||
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
|
|
||||||
|
/// V3-12: a crash reporter in a location app is a privacy surface. Every test here
|
||||||
|
/// exists to make a specific claim provable rather than assumed -- "the scrubber strips
|
||||||
|
/// coordinates" and "disabled means never initialised" are both named directly in the
|
||||||
|
/// ticket's Tests section.
|
||||||
|
void main() {
|
||||||
|
group('shouldInitializeCrashReporting', () {
|
||||||
|
test('requires enabled, release, and a configured dsn all at once', () {
|
||||||
|
expect(
|
||||||
|
shouldInitializeCrashReporting(enabled: true, isDebug: false, dsn: 'https://x'),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the user toggle being off means the client is never initialised', () {
|
||||||
|
expect(
|
||||||
|
shouldInitializeCrashReporting(enabled: false, isDebug: false, dsn: 'https://x'),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('debug builds send nothing, even if enabled', () {
|
||||||
|
expect(
|
||||||
|
shouldInitializeCrashReporting(enabled: true, isDebug: true, dsn: 'https://x'),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no dsn configured means no client, regardless of the toggle', () {
|
||||||
|
expect(
|
||||||
|
shouldInitializeCrashReporting(enabled: true, isDebug: false, dsn: ''),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('scrubExtra', () {
|
||||||
|
test('strips every coordinate-shaped key', () {
|
||||||
|
final scrubbed = scrubExtra({
|
||||||
|
'latitude': 51.0447,
|
||||||
|
'longitude': -114.0719,
|
||||||
|
'lat': 51.0,
|
||||||
|
'lon': -114.0,
|
||||||
|
'startLat': 51.0,
|
||||||
|
'gps_coords': '51.0,-114.0',
|
||||||
|
'altitude': 1045.0,
|
||||||
|
'safe_field': 'kept',
|
||||||
|
})!;
|
||||||
|
|
||||||
|
expect(scrubbed.containsKey('safe_field'), isTrue);
|
||||||
|
expect(scrubbed, hasLength(1),
|
||||||
|
reason: 'no field describing a location may survive: $scrubbed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strips device and trip identity', () {
|
||||||
|
final scrubbed = scrubExtra({
|
||||||
|
'device_id': 'abc-123',
|
||||||
|
'deviceId': 'abc-123',
|
||||||
|
'trip_id': 42,
|
||||||
|
'tripId': 42,
|
||||||
|
'screen': 'record',
|
||||||
|
})!;
|
||||||
|
|
||||||
|
expect(scrubbed, {'screen': 'record'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matching is case-insensitive and matches substrings', () {
|
||||||
|
final scrubbed = scrubExtra({'Latitude': 1.0, 'nested.longitude': 2.0})!;
|
||||||
|
expect(scrubbed, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null in, null out', () {
|
||||||
|
expect(scrubExtra(null), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an already-clean map is returned unchanged in content', () {
|
||||||
|
final scrubbed = scrubExtra({'screen': 'settings', 'action': 'tap'})!;
|
||||||
|
expect(scrubbed, {'screen': 'settings', 'action': 'tap'});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('event scrubbing end to end', () {
|
||||||
|
test('a representative event with coordinates in extra and a breadcrumb comes '
|
||||||
|
'out clean', () {
|
||||||
|
final event = SentryEvent(
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
extra: {'latitude': 51.0447, 'longitude': -114.0719, 'screen': 'record'},
|
||||||
|
breadcrumbs: [
|
||||||
|
Breadcrumb(
|
||||||
|
message: 'fix received',
|
||||||
|
data: {'lat': 51.0, 'lon': -114.0, 'accuracy': 5.0},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Exercises the same path SentryOptions.beforeSend is configured with in
|
||||||
|
// maybeInitCrashReporting, without needing a real Sentry client.
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
final scrubbedExtra = scrubExtra(event.extra)!;
|
||||||
|
final scrubbedBreadcrumbData = scrubExtra(event.breadcrumbs!.single.data)!;
|
||||||
|
|
||||||
|
expect(scrubbedExtra.containsKey('latitude'), isFalse);
|
||||||
|
expect(scrubbedExtra.containsKey('longitude'), isFalse);
|
||||||
|
expect(scrubbedExtra['screen'], 'record');
|
||||||
|
expect(scrubbedBreadcrumbData.containsKey('lat'), isFalse);
|
||||||
|
expect(scrubbedBreadcrumbData.containsKey('lon'), isFalse);
|
||||||
|
expect(scrubbedBreadcrumbData['accuracy'], 5.0,
|
||||||
|
reason: 'accuracy is not a coordinate and carries no location on its own');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -336,6 +336,50 @@ void main() {
|
|||||||
reason: 'the dead time is a real gap and must render as one');
|
reason: 'the dead time is a real gap and must render as one');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a recording trip found at restart reports an unexpected stop (V3-12)',
|
||||||
|
() async {
|
||||||
|
await engine.start();
|
||||||
|
await ride(5);
|
||||||
|
await engine.dispose();
|
||||||
|
|
||||||
|
String? reportedReason;
|
||||||
|
final revived = RecordingEngine(
|
||||||
|
repository: repo,
|
||||||
|
locationSource: source,
|
||||||
|
clock: () => fakeNow,
|
||||||
|
onUnexpectedStop: (reason) => reportedReason = reason,
|
||||||
|
);
|
||||||
|
addTearDown(revived.dispose);
|
||||||
|
|
||||||
|
fakeNow = 20000;
|
||||||
|
await revived.restoreAfterProcessDeath();
|
||||||
|
|
||||||
|
expect(reportedReason, isNotNull,
|
||||||
|
reason: 'the recording stopped without anyone choosing that -- the exact '
|
||||||
|
'failure V3-12 exists to surface');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a cleanly-completed trip reports nothing at restart', () async {
|
||||||
|
await engine.start();
|
||||||
|
await ride(5);
|
||||||
|
await engine.stop();
|
||||||
|
await engine.dispose();
|
||||||
|
|
||||||
|
var called = false;
|
||||||
|
final revived = RecordingEngine(
|
||||||
|
repository: repo,
|
||||||
|
locationSource: source,
|
||||||
|
clock: () => fakeNow,
|
||||||
|
onUnexpectedStop: (_) => called = true,
|
||||||
|
);
|
||||||
|
addTearDown(revived.dispose);
|
||||||
|
|
||||||
|
await revived.restoreAfterProcessDeath();
|
||||||
|
|
||||||
|
expect(called, isFalse,
|
||||||
|
reason: 'a rider who pressed Stop is not a crash and must not be reported');
|
||||||
|
});
|
||||||
|
|
||||||
test('the dead time is never measured as distance', () async {
|
test('the dead time is never measured as distance', () async {
|
||||||
// The bug this guards is in the native app: after a crash the open segment is
|
// The bug this guards is in the native app: after a crash the open segment is
|
||||||
// adopted rather than closed, so computeSummary -- the authoritative pass at trip
|
// adopted rather than closed, so computeSummary -- the authoritative pass at trip
|
||||||
|
|||||||
@@ -90,10 +90,36 @@ void main() {
|
|||||||
expect(config.mountedMode, isTrue);
|
expect(config.mountedMode, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('the crash reporting switch writes through to Config (V3-12)',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(host());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final initial = tester
|
||||||
|
.widget<SwitchListTile>(find.byKey(const Key('crash-reporting-switch')))
|
||||||
|
.value;
|
||||||
|
expect(initial, isFalse, reason: 'off until a privacy policy exists');
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('crash-reporting-switch')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(config.crashReportingEnabled, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The Sync section has been pushed below the default test viewport by every section
|
||||||
|
/// added above it since this suite was first written (V3-05, then V3-12) -- a
|
||||||
|
/// `ListView` does not mount elements outside its viewport/cache extent, so `find`
|
||||||
|
/// cannot see them until scrolled into view.
|
||||||
|
Future<void> scrollToSync(WidgetTester tester) async {
|
||||||
|
await tester.drag(find.byType(ListView).first, const Offset(0, -600));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
group('upload endpoint', () {
|
group('upload endpoint', () {
|
||||||
testWidgets('a valid https URL is saved', (tester) async {
|
testWidgets('a valid https URL is saved', (tester) async {
|
||||||
await tester.pumpWidget(host());
|
await tester.pumpWidget(host());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await scrollToSync(tester);
|
||||||
|
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.byKey(const Key('endpoint-field')),
|
find.byKey(const Key('endpoint-field')),
|
||||||
@@ -110,6 +136,7 @@ void main() {
|
|||||||
'silently stored', (tester) async {
|
'silently stored', (tester) async {
|
||||||
await tester.pumpWidget(host());
|
await tester.pumpWidget(host());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await scrollToSync(tester);
|
||||||
|
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.byKey(const Key('endpoint-field')),
|
find.byKey(const Key('endpoint-field')),
|
||||||
@@ -126,6 +153,7 @@ void main() {
|
|||||||
testWidgets('a non-http scheme is rejected', (tester) async {
|
testWidgets('a non-http scheme is rejected', (tester) async {
|
||||||
await tester.pumpWidget(host());
|
await tester.pumpWidget(host());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await scrollToSync(tester);
|
||||||
|
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.byKey(const Key('endpoint-field')),
|
find.byKey(const Key('endpoint-field')),
|
||||||
@@ -142,6 +170,7 @@ void main() {
|
|||||||
await config.setUploadEndpoint('https://example.test/ingest');
|
await config.setUploadEndpoint('https://example.test/ingest');
|
||||||
await tester.pumpWidget(host());
|
await tester.pumpWidget(host());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await scrollToSync(tester);
|
||||||
|
|
||||||
await tester.enterText(find.byKey(const Key('endpoint-field')), '');
|
await tester.enterText(find.byKey(const Key('endpoint-field')), '');
|
||||||
await tester.tap(find.byKey(const Key('save-endpoint')));
|
await tester.tap(find.byKey(const Key('save-endpoint')));
|
||||||
@@ -156,13 +185,11 @@ void main() {
|
|||||||
await tester.pumpWidget(host());
|
await tester.pumpWidget(host());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await scrollToSync(tester);
|
||||||
|
|
||||||
expect(find.text(config.deviceId), findsOneWidget);
|
expect(find.text(config.deviceId), findsOneWidget);
|
||||||
expect(find.byKey(const Key('copy-device-id')), findsOneWidget);
|
expect(find.byKey(const Key('copy-device-id')), findsOneWidget);
|
||||||
|
|
||||||
// The list has grown past one screen (V3-05 added a section above this), so the
|
|
||||||
// control is not necessarily within the default test viewport.
|
|
||||||
await tester.drag(find.byType(ListView).first, const Offset(0, -400));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
// Tapping must not throw even though no real clipboard exists in the test harness.
|
// Tapping must not throw even though no real clipboard exists in the test harness.
|
||||||
await tester.tap(find.byKey(const Key('copy-device-id')));
|
await tester.tap(find.byKey(const Key('copy-device-id')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|||||||
Reference in New Issue
Block a user