Files
rippr/lib/src/recording/location_source.dart
Dylan 5a7c598610 T10/T11: location seam and recording pipeline; fix a native crash-recovery bug
T10 -- LocationSource abstraction plus FakeLocationSource, so the riskiest code
in the app is testable with no device. Also found that geolocator ships its own
Android foreground service with enableWakeLock, which could drop
flutter_foreground_task and both its deprecation paths; deferred to T12 since it
costs notification actions.

T11 -- the pipeline. Kotlin's unbounded Channel plus blocking-receive writer
becomes a List buffer plus a periodic Timer; Dart's event loop makes a blocking
receive unnecessary and the guarantee is unchanged, since the fix callback only
appends and returns. 24 tests.

Found a real bug in the native app while porting. restoreAfterProcessDeath says
it resumes into a new segment because the dead time is a real gap, but it calls
resumeTrip, which adopts the segment a crash left open. After a pause that is
right; after a crash nothing closed it. Points either side of the dead time then
share a segment, and computeSummary -- the authoritative pass that overwrites the
live estimate on completion -- measures straight through the gap. Reverting the
fix and running the guard shows 111,217 m of phantom distance.

Fixed via TripRepository.resumeIntoNewSegment, which closes the stale segment at
its last recorded point rather than at now, so the dead time is not billed as
ride time either. A deliberate, documented departure from parity: the code
contradicted its own comment and produced silently wrong data.

The first version of that guard passed with the bug still present -- the fixture
used pause(), which closes the segment and stops reproducing the crash. Caught
only by reverting the fix and checking the test failed.

145 tests passing, analyze clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:19:21 -05:00

168 lines
5.3 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// The seam between the recording pipeline and whatever produces GPS fixes.
///
/// This exists for three reasons, in order of importance:
///
/// 1. **Testability.** The pipeline is the riskiest code in the app and must be
/// verifiable without a device, an emulator, or a real ride.
/// 2. **Swappability.** `geolocator` is the free choice. If a real iOS ride shows it
/// losing fixes when the OS suspends a stationary app, moving to
/// `flutter_background_geolocation` becomes one new implementation of this interface
/// rather than a rewrite of the engine.
/// 3. **Platform honesty.** Android and iOS keep a process alive by genuinely different
/// mechanisms. Confining that difference behind this interface keeps it out of the
/// pipeline entirely.
library;
import 'dart:async';
/// A single platform-neutral GPS fix.
///
/// Deliberately **not** `TrackPoint`: a fix has no trip or segment identity. Those ids
/// are stamped on by the recorder at the moment of creation, which is what makes a pause
/// safe — see the recording engine.
///
/// `speedMps` is metres per second, as every platform reports it. Conversion to km/h and
/// the noise-floor sanitisation happen once, in the engine, via the ported `Telemetry`
/// functions.
class LocationFix {
const LocationFix({
required this.timestamp,
required this.latitude,
required this.longitude,
required this.speedMps,
required this.altitudeM,
required this.accuracyM,
required this.bearingDeg,
});
final int timestamp;
final double latitude;
final double longitude;
final double speedMps;
final double altitudeM;
final double accuracyM;
final double bearingDeg;
@override
String toString() =>
'LocationFix($latitude, $longitude, ${speedMps}m/s, ±${accuracyM}m)';
}
/// Why location is unavailable, when it is.
enum LocationFailure {
/// The user denied the permission, or has not granted it yet.
permissionDenied,
/// Denied permanently — only a trip to system settings will fix it.
permissionDeniedForever,
/// Location services are switched off device-wide.
serviceDisabled,
}
class LocationException implements Exception {
const LocationException(this.failure, [this.message]);
final LocationFailure failure;
final String? message;
@override
String toString() => 'LocationException($failure${message == null ? '' : ': $message'})';
}
/// A source of GPS fixes.
///
/// Implementations must guarantee that [fixes] never throws mid-stream for a transient
/// problem — the recorder treats a closed stream as "recording has stopped", which is a
/// user-visible event. Transient errors belong in logs, not in the stream.
abstract class LocationSource {
/// Fixes, at roughly 1–2 Hz while recording.
///
/// Nothing is emitted until [start] has been called.
Stream<LocationFix> get fixes;
/// Begins delivering fixes, requesting permission if needed.
///
/// Throws [LocationException] if permission or the device service is unavailable.
/// On Android this is also where the foreground service is raised; on iOS it is where
/// background updates are enabled.
Future<void> start();
/// Stops delivering fixes and releases whatever the platform was holding — the
/// foreground service and wake lock on Android, background updates on iOS.
///
/// Must be idempotent: the recorder calls it on pause, stop, and discard, and those can
/// arrive in any order after a process restart.
Future<void> stop();
Future<void> dispose();
}
/// An in-memory [LocationSource] for tests.
///
/// Lets the whole recording pipeline — batching, segment stamping, accumulation,
/// persistence, pause boundaries — be exercised on the Dart VM with no device.
class FakeLocationSource implements LocationSource {
final _controller = StreamController<LocationFix>.broadcast();
var _started = false;
var _startCalls = 0;
var _stopCalls = 0;
bool get isRunning => _started;
int get startCalls => _startCalls;
int get stopCalls => _stopCalls;
/// Set to make [start] throw, so permission handling can be tested.
LocationException? failOnStart;
@override
Stream<LocationFix> get fixes => _controller.stream;
@override
Future<void> start() async {
_startCalls++;
final failure = failOnStart;
if (failure != null) throw failure;
_started = true;
}
@override
Future<void> stop() async {
_stopCalls++;
_started = false;
}
@override
Future<void> dispose() => _controller.close();
/// Emits a fix as though the platform had produced it.
///
/// Silently ignored while stopped, mirroring the real thing: a platform that has been
/// told to stop does not keep delivering.
void emit(LocationFix fix) {
if (!_started) return;
_controller.add(fix);
}
/// Convenience for building a plausible fix without spelling out every field.
void emitAt({
required int timestamp,
double latitude = 51.0,
double longitude = -114.0,
double speedMps = 11.111111111111112, // 40 km/h
double altitudeM = 1000.0,
double accuracyM = 5.0,
double bearingDeg = 0.0,
}) =>
emit(LocationFix(
timestamp: timestamp,
latitude: latitude,
longitude: longitude,
speedMps: speedMps,
altitudeM: altitudeM,
accuracyM: accuracyM,
bearingDeg: bearingDeg,
));
}