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:
2026-08-17 19:32:54 -05:00
parent 0e605ef174
commit be50448917
14 changed files with 443 additions and 9 deletions

View File

@@ -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 |

View File

@@ -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).

View File

@@ -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 {

View File

@@ -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(),

View File

@@ -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);

View 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}),
);
}

View File

@@ -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.
// //

View File

@@ -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),

View File

@@ -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:

View File

@@ -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:

View File

@@ -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();

View 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');
});
});
}

View File

@@ -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

View File

@@ -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();