fix: make recording health evidence-based and notification truthful

This commit is contained in:
Developer 2026-09-10 17:13:27 -04:00
parent 8681e6f259
commit ec759ea3c5
5 changed files with 974 additions and 28 deletions

View File

@ -20,6 +20,15 @@ class RideSession {
final int? dbId; final int? dbId;
final RideState state; final RideState state;
final RecordingHealthStatus healthStatus; final RecordingHealthStatus healthStatus;
/// RID-3C-1: the ONE authoritative in-memory recording-health evidence model.
///
/// Ephemeral runtime state only - never persisted. `RideSession` has no JSON or
/// Drift serialization, so exposing the contract here needs no schema change,
/// migration, persistence change or cloud schema change, and creates no second
/// health provider, StateNotifier, singleton or duplicate model.
final RecordingHealthContract health;
final double tripDistanceMiles; final double tripDistanceMiles;
final double topSpeedMph; final double topSpeedMph;
final double maxLeanLeft; final double maxLeanLeft;
@ -41,6 +50,7 @@ class RideSession {
this.dbId, this.dbId,
this.state = RideState.idle, this.state = RideState.idle,
this.healthStatus = RecordingHealthStatus.recordingArmed, this.healthStatus = RecordingHealthStatus.recordingArmed,
this.health = const RecordingHealthContract(),
this.tripDistanceMiles = 0.0, this.tripDistanceMiles = 0.0,
this.topSpeedMph = 0.0, this.topSpeedMph = 0.0,
this.maxLeanLeft = 0.0, this.maxLeanLeft = 0.0,
@ -61,6 +71,7 @@ class RideSession {
int? dbId, int? dbId,
RideState? state, RideState? state,
RecordingHealthStatus? healthStatus, RecordingHealthStatus? healthStatus,
RecordingHealthContract? health,
double? tripDistanceMiles, double? tripDistanceMiles,
double? topSpeedMph, double? topSpeedMph,
double? maxLeanLeft, double? maxLeanLeft,
@ -80,6 +91,7 @@ class RideSession {
dbId: dbId ?? this.dbId, dbId: dbId ?? this.dbId,
state: state ?? this.state, state: state ?? this.state,
healthStatus: healthStatus ?? this.healthStatus, healthStatus: healthStatus ?? this.healthStatus,
health: health ?? this.health,
tripDistanceMiles: tripDistanceMiles ?? this.tripDistanceMiles, tripDistanceMiles: tripDistanceMiles ?? this.tripDistanceMiles,
topSpeedMph: topSpeedMph ?? this.topSpeedMph, topSpeedMph: topSpeedMph ?? this.topSpeedMph,
maxLeanLeft: maxLeanLeft ?? this.maxLeanLeft, maxLeanLeft: maxLeanLeft ?? this.maxLeanLeft,
@ -182,26 +194,165 @@ class RideStateMachine extends StateNotifier<RideSession> {
_recoverInterruptedRides(); _recoverInterruptedRides();
} }
// ---------------------------------------------------------------------------
// RID-3C-1: truthful, evidence-based recording health.
//
// Evidence markers are MONOTONIC values taken from TelemetryService's own
// monotonic domain (TelemetrySample.monotonicTimestampMs, sourced from a
// Stopwatch), so a user or system wall-clock jump can never make stale evidence
// look fresh. Wall-clock DateTimes are retained only as contract metadata. No
// new timing subsystem is introduced.
//
// Presence - never magnitude - proves a sensor sample arrived, so a genuine
// zero-valued lean, pitch or g-force still counts as real evidence.
// ---------------------------------------------------------------------------
int? _latestObservedMonotonicMs;
int? _lastGpsEvidenceMonotonicMs;
int? _lastImuEvidenceMonotonicMs;
int? _lastDbCommitMonotonicMs;
bool _dbPersistenceFailed = false;
/// Telemetry evidence time, advanced ONLY for samples accepted for the active
/// ride. Deliberately separate from `_lastTelemetryReceivedTime`, which the
/// existing watchdog updates before RID-3B-1's acceptance cutoff and which must
/// not be moved: a post-cutoff sample may not refresh health evidence.
DateTime? _lastAcceptedTelemetryTime;
void _resetHealthEvidence() {
_latestObservedMonotonicMs = null;
_lastGpsEvidenceMonotonicMs = null;
_lastImuEvidenceMonotonicMs = null;
_lastDbCommitMonotonicMs = null;
_dbPersistenceFailed = false;
_lastAcceptedTelemetryTime = null;
}
/// Monotonic age in ms of a marker, or null when it cannot be established.
/// Null ALWAYS means "not fresh" - this model fails closed.
int? _monotonicAgeMs(int? markerMonotonicMs) {
final reference = _latestObservedMonotonicMs;
if (reference == null || markerMonotonicMs == null) return null;
final age = reference - markerMonotonicMs;
// A negative age is not physically meaningful here; fail closed rather than
// reporting it as fresh.
return age < 0 ? null : age;
}
bool _isFresh(int? ageMs, int boundMs) => ageMs != null && ageMs <= boundMs;
/// Records what an ACCEPTED telemetry sample actually proves. Called only after
/// RID-3B-1's acceptance cutoff gate, so post-cutoff samples are excluded.
void _recordTelemetryEvidence(TelemetrySample sample) {
final monotonicMs = sample.monotonicTimestampMs;
if (monotonicMs == null) return;
final latest = _latestObservedMonotonicMs;
if (latest == null || monotonicMs > latest) {
_latestObservedMonotonicMs = monotonicMs;
}
// Real GPS fix evidence: the fix timestamp is present.
if (sample.gpsTimestamp != null) _lastGpsEvidenceMonotonicMs = monotonicMs;
// Real IMU sample evidence: the IMU timestamp is present.
if (sample.imuTimestamp != null) _lastImuEvidenceMonotonicMs = monotonicMs;
_lastAcceptedTelemetryTime = DateTime.now();
}
/// Recomputes the ONE authoritative RecordingHealthContract from real evidence
/// and maps it to RecordingHealthStatus. Invoked only from the canonical
/// telemetry-acceptance and watchdog paths - never from a bare timer tick, a UI
/// frame, or a service-presence assumption.
///
/// RID-3B-1 priority is preserved: `resolveStatus` treats recordingFinalizing,
/// recordingComplete and recordingFailed as authoritative lifecycle overrides,
/// so ordinary health evaluation can never return a finishing or finished ride
/// to recordingActive.
void _evaluateRecordingHealth() {
final now = DateTime.now();
const sensorBound = RecordingHealthContract.evidenceStaleAfterMs;
// A. Active, valid ride/session identity.
final hasValidSession =
state.dbId != null && state.state == RideState.riding;
// C. Telemetry evidence, using the SAME 4 s bound as the existing canonical
// watchdog so contract and status agree by construction. This wall-clock term
// is also what prevents monotonic freeze-blindness: when samples stop, the
// monotonic reference stops advancing, but this term still goes stale.
final accepted = _lastAcceptedTelemetryTime;
final telemetryAgeMs =
accepted == null ? null : now.difference(accepted).inMilliseconds;
// Fail closed on a backwards clock jump: negative age is not freshness.
final telemetryActive = telemetryAgeMs != null &&
telemetryAgeMs >= 0 &&
telemetryAgeMs <= sensorBound;
// B. GPS and D. IMU evidence: monotonic, presence-proven.
final gpsOperational =
_isFresh(_monotonicAgeMs(_lastGpsEvidenceMonotonicMs), sensorBound);
final sensorActive =
_isFresh(_monotonicAgeMs(_lastImuEvidenceMonotonicMs), sensorBound);
// E. Database evidence: advances ONLY after a real successful telemetry
// commit (the RID-3B-2 commit point). A scheduled flush is not evidence, and
// a failed pre-commit batch does not advance it.
final dbOperational = !_dbPersistenceFailed &&
_isFresh(_monotonicAgeMs(_lastDbCommitMonotonicMs),
RecordingHealthContract.databaseWriteStaleAfterMs);
final previous = state.health;
final health = previous.copyWith(
isForegroundServiceAlive: gpsOperational,
isTelemetryPipelineActive: telemetryActive,
isAtLeastOneSensorActive: sensorActive,
isDatabasePersistenceOperational: dbOperational,
isValidSessionId: hasValidSession,
lastGPSFixTime: previous.lastGPSFixTime,
lastTelemetryTime: accepted ?? previous.lastTelemetryTime,
lastIMUSampleTime: previous.lastIMUSampleTime,
lastDatabaseWriteTime: previous.lastDatabaseWriteTime,
);
state = state.copyWith(
health: health,
healthStatus: health.resolveStatus(lifecycle: state.healthStatus),
);
}
void _checkWatchdog() { void _checkWatchdog() {
if (state.state != RideState.riding) return; if (state.state != RideState.riding) return;
final now = DateTime.now(); final now = DateTime.now();
if (_lastTelemetryReceivedTime != null) { // Existing canonical stall detection: the 4-second bound and the telemetryGap
final elapsedSec = now.difference(_lastTelemetryReceivedTime!).inSeconds; // diagnostic are unchanged. RID-3C-1 adds NO second watchdog; it makes this
if (elapsedSec > 4) { // one the source of heartbeat and consecutive-miss evidence for the contract.
// Telemetry pipeline has stalled final elapsedSec = _lastTelemetryReceivedTime == null
if (state.healthStatus != RecordingHealthStatus.recordingDegraded) { ? null
state = state.copyWith(healthStatus: RecordingHealthStatus.recordingDegraded); : now.difference(_lastTelemetryReceivedTime!).inSeconds;
try { final stalled = elapsedSec != null && elapsedSec > 4;
ref.read(fieldTestLoggerProvider).log(
eventType: FieldTestEventType.telemetryGap, final previousMisses = state.health.consecutiveWatchdogMisses;
severity: EventSeverity.warning, final misses = stalled ? previousMisses + 1 : 0;
message: 'Watchdog: Telemetry pipeline stalled ($elapsedSec seconds without samples)',
); if (stalled && previousMisses == 0) {
} catch (_) {} // First detection of this stall episode: logged once, as before.
} try {
} ref.read(fieldTestLoggerProvider).log(
eventType: FieldTestEventType.telemetryGap,
severity: EventSeverity.warning,
message: 'Watchdog: Telemetry pipeline stalled ($elapsedSec seconds without samples)',
);
} catch (_) {}
} }
state = state.copyWith(
health: state.health.copyWith(
lastHeartbeatTime: now,
consecutiveWatchdogMisses: misses,
),
);
// The watchdog evaluates the contract instead of maintaining a contradictory
// parallel truth, so RecordingHealthStatus and RecordingHealthContract agree.
_evaluateRecordingHealth();
} }
Future<void> _recoverInterruptedRides() async { Future<void> _recoverInterruptedRides() async {
@ -292,10 +443,16 @@ class RideStateMachine extends StateNotifier<RideSession> {
motorcycleVariant: defaultBike?.variantName, motorcycleVariant: defaultBike?.variantName,
); );
// RID-3C-1 (warm-up): a freshly created ride has NO evidence yet, so it must
// not claim recordingActive. Ride creation is never blocked waiting for GPS,
// IMU or the 3-second batch; health warms up automatically as real evidence
// arrives and _evaluateRecordingHealth promotes the status.
_resetHealthEvidence();
state = state.copyWith( state = state.copyWith(
dbId: id, dbId: id,
state: RideState.riding, state: RideState.riding,
healthStatus: RecordingHealthStatus.recordingActive, healthStatus: RecordingHealthStatus.recordingArmed,
health: const RecordingHealthContract(),
startTime: startTime, startTime: startTime,
tripDistanceMiles: 0, tripDistanceMiles: 0,
topSpeedMph: 0, topSpeedMph: 0,
@ -317,6 +474,12 @@ class RideStateMachine extends StateNotifier<RideSession> {
_stoppedTimeAccumulator = 0.0; _stoppedTimeAccumulator = 0.0;
_lastTelemetryReceivedTime = DateTime.now(); _lastTelemetryReceivedTime = DateTime.now();
// RID-3C-1: publish truthful warm-up health immediately, so the contract
// reflects the real session identity even before the first sample arrives.
// All evidence is still absent, so this resolves to recordingArmed and can
// never produce a false recordingActive.
_evaluateRecordingHealth();
try { try {
WakelockPlus.enable().catchError((_) {}); WakelockPlus.enable().catchError((_) {});
} catch (_) {} } catch (_) {}
@ -362,9 +525,14 @@ class RideStateMachine extends StateNotifier<RideSession> {
} }
void resumeRide() { void resumeRide() {
state = state.copyWith(state: RideState.riding, healthStatus: RecordingHealthStatus.recordingActive); // RID-3C-1: resuming does not by itself prove recording health. Evidence ages
// out monotonically across the pause, so the next accepted sample
// re-establishes truthfully whether the ride is active or degraded.
state = state.copyWith(state: RideState.riding, healthStatus: RecordingHealthStatus.recordingArmed);
_lastSample = null; // reset to avoid jumping _lastSample = null; // reset to avoid jumping
_lastTelemetryReceivedTime = DateTime.now(); _lastTelemetryReceivedTime = DateTime.now();
// RID-3C-1: re-publish truthful health for the resumed session.
_evaluateRecordingHealth();
try { try {
WakelockPlus.enable().catchError((_) {}); WakelockPlus.enable().catchError((_) {});
} catch (_) {} } catch (_) {}
@ -526,12 +694,18 @@ class RideStateMachine extends StateNotifier<RideSession> {
// every subsequent ride continue to record normally. // every subsequent ride continue to record normally.
if (_telemetryAcceptanceClosed) return; if (_telemetryAcceptanceClosed) return;
// RID-3B-1: incoming telemetry must not clobber recordingFinalizing back to // RID-3C-1: an accepted sample is EVIDENCE, not a health verdict. Record only
// recordingActive while finalization is in progress. Minimum private // what it actually proves - a real GPS fix timestamp, a real IMU sample
// condition: the existing RID-2 in-flight finish flag. // timestamp, monotonic timing - and let the contract decide the status.
if (!_isFinishing && //
state.healthStatus != RecordingHealthStatus.recordingActive) { // RID-3B-1 is preserved twice over: this runs AFTER the acceptance cutoff gate
state = state.copyWith(healthStatus: RecordingHealthStatus.recordingActive); // above, so a post-cutoff sample can never refresh health evidence, and the
// existing RID-2 `_isFinishing` guard together with `resolveStatus`'s
// lifecycle overrides still prevent recordingFinalizing from being clobbered
// back to recordingActive.
_recordTelemetryEvidence(sample);
if (!_isFinishing) {
_evaluateRecordingHealth();
} }
_queueForBatch(sample); _queueForBatch(sample);
@ -699,6 +873,10 @@ class RideStateMachine extends StateNotifier<RideSession> {
// arrived during the await, so a later legitimate flush can retry them. // arrived during the await, so a later legitimate flush can retry them.
// No automatic retry worker is added. // No automatic retry worker is added.
_telemetryBatch.insertAll(0, batchToInsert); _telemetryBatch.insertAll(0, batchToInsert);
// RID-3C-1: a pre-commit failure means this telemetry is NOT durable, so
// database health evidence must not advance and is marked non-operational
// until a real commit succeeds.
_dbPersistenceFailed = true;
// Keep in queue for retry on next flush interval. A notifier that has // Keep in queue for retry on next flush interval. A notifier that has
// begun disposal must not touch ref, so the diagnostic is skipped in that // begun disposal must not touch ref, so the diagnostic is skipped in that
// case; it remains bounded either way. // case; it remains bounded either way.
@ -733,7 +911,16 @@ class RideStateMachine extends StateNotifier<RideSession> {
// consistent, and no database counter or schema field is added. // consistent, and no database counter or schema field is added.
if (mounted) { if (mounted) {
try { try {
state = state.copyWith(totalSamplesPersisted: state.totalSamplesPersisted + batchToInsert.length); // RID-3C-1: database health evidence advances ONLY here, strictly after
// the RID-3B-2 commit point. A scheduled flush is not evidence, and a
// failed pre-commit batch never reaches this block.
_lastDbCommitMonotonicMs = _latestObservedMonotonicMs;
_dbPersistenceFailed = false;
state = state.copyWith(
totalSamplesPersisted: state.totalSamplesPersisted + batchToInsert.length,
health: state.health.copyWith(lastDatabaseWriteTime: DateTime.now()),
);
_evaluateRecordingHealth();
} catch (_) { } catch (_) {
// Post-commit bookkeeping failure must never be classified as a // Post-commit bookkeeping failure must never be classified as a
// persistence failure and must never restore the committed snapshot. // persistence failure and must never restore the committed snapshot.

View File

@ -39,6 +39,21 @@ enum RecordingHealthStatus {
/// Strict Recording Health Contract state tracker (Phase 5) /// Strict Recording Health Contract state tracker (Phase 5)
class RecordingHealthContract { class RecordingHealthContract {
final RecordingHealthStatus status; final RecordingHealthStatus status;
/// RID-3C-1 SEMANTICS - READ BEFORE USE.
///
/// There is NO Dart API that truthfully reports "the native
/// `GeolocatorLocationService` is alive". `geolocator_android` exposes no
/// method-channel query for its foreground service, and its
/// `ForegroundNotificationOptions` carry no state. This field is therefore an
/// EVIDENCE-DERIVED OPERATIONAL PROXY meaning exactly:
///
/// "foreground location delivery appears operational for the active ride"
///
/// It is set from real, recent GPS fix evidence flowing through the existing
/// location pipeline, and is NOT set merely because a
/// `ForegroundNotificationConfig` exists, permission was granted,
/// `LocationService` was constructed, or `getPositionStream` was called.
/// Native service observability is deliberately not fabricated.
final bool isForegroundServiceAlive; final bool isForegroundServiceAlive;
final bool isTelemetryPipelineActive; final bool isTelemetryPipelineActive;
final bool isAtLeastOneSensorActive; final bool isAtLeastOneSensorActive;
@ -111,4 +126,92 @@ class RecordingHealthContract {
isAtLeastOneSensorActive && isAtLeastOneSensorActive &&
isDatabasePersistenceOperational && isDatabasePersistenceOperational &&
isValidSessionId; isValidSessionId;
// ---------------------------------------------------------------------------
// RID-3C-1: freshness bounds and status mapping.
//
// Every bound is derived from an EXISTING production cadence or watchdog
// constant so the contract and the canonical watchdog cannot disagree. Each
// exists exactly once, is named, and is covered by deterministic boundary
// tests. No bound is copied from design notes without a production basis.
// ---------------------------------------------------------------------------
/// Evidence staleness bound for the sensor domains (GPS, telemetry, IMU).
///
/// Reuses the EXISTING `_checkWatchdog` pipeline-stall threshold of 4 seconds
/// (`elapsedSec > 4`) so `RecordingHealthStatus` and this contract agree by
/// construction rather than by coincidence. Against production cadence this is
/// ~40 consecutive missed GPS fixes at the 10 Hz target and ~200 missed IMU
/// samples at 50 Hz, i.e. far beyond normal jitter, while `LocationService`'s
/// 1 s watchdog already actively re-polls after 2 s without a fix.
static const int evidenceStaleAfterMs = 4000;
/// Database persistence staleness bound.
///
/// 3x the EXISTING 3-second `_batchWriteTimer` crash-durability cadence. Two
/// consecutive missed batches are tolerated because a single late batch is
/// ordinary jitter; the third absence means telemetry is genuinely not being
/// committed.
static const int databaseWriteStaleAfterMs = 9000;
/// Consecutive watchdog misses tolerated before health is not reportable.
///
/// The EXISTING watchdog cadence is 2 seconds, so 2 misses is ~4 seconds and
/// matches [evidenceStaleAfterMs].
static const int watchdogMissLimit = 2;
/// True while the canonical watchdog has not accumulated a stall.
bool get isWatchdogHealthy => consecutiveWatchdogMisses < watchdogMissLimit;
/// True when no evidence of any kind has been observed yet for this session.
///
/// Distinguishes "ride created, evidence still warming up" from "evidence was
/// established and has since gone stale", so warm-up maps to
/// [RecordingHealthStatus.recordingArmed] instead of falsely implying a fault.
bool get isEvidenceWarmUp =>
lastGPSFixTime == null &&
lastTelemetryTime == null &&
lastIMUSampleTime == null &&
lastDatabaseWriteTime == null;
/// RID-3C-1: the ONE deterministic contract -> status mapping.
///
/// [lifecycle] is the status the ride lifecycle itself owns (finalizing,
/// complete, failed). Those are authoritative overrides: ordinary telemetry
/// health evaluation must never return a finalizing or completed ride to
/// `recordingActive`, and `recordingComplete` stays complete.
///
/// Guarantee: `canReportRecording == true` MAY show `recordingActive`;
/// `canReportRecording == false` MUST NOT show `recordingActive`.
RecordingHealthStatus resolveStatus({
required RecordingHealthStatus lifecycle,
}) {
switch (lifecycle) {
case RecordingHealthStatus.recordingFinalizing:
return RecordingHealthStatus.recordingFinalizing;
case RecordingHealthStatus.recordingComplete:
return RecordingHealthStatus.recordingComplete;
case RecordingHealthStatus.recordingFailed:
return RecordingHealthStatus.recordingFailed;
case RecordingHealthStatus.recordingArmed:
case RecordingHealthStatus.recordingStarting:
case RecordingHealthStatus.recordingActive:
case RecordingHealthStatus.recordingDegraded:
break;
}
// No active ride identity: nothing may claim recording, however healthy the
// location stack or sensors look. Service presence alone is never recording.
if (!isValidSessionId) return RecordingHealthStatus.recordingArmed;
if (canReportRecording && isWatchdogHealthy) {
return RecordingHealthStatus.recordingActive;
}
// Evidence has not begun arriving yet: warm-up, not a fault.
if (isEvidenceWarmUp) return RecordingHealthStatus.recordingArmed;
return RecordingHealthStatus.recordingDegraded;
}
} }

View File

@ -121,8 +121,26 @@ class LocationService {
intervalDuration: const Duration(milliseconds: 100), intervalDuration: const Duration(milliseconds: 100),
forceLocationManager: false, // Use Google Fused Location Provider for best accuracy & responsiveness forceLocationManager: false, // Use Google Fused Location Provider for best accuracy & responsiveness
foregroundNotificationConfig: const ForegroundNotificationConfig( foregroundNotificationConfig: const ForegroundNotificationConfig(
notificationText: 'MotorSignal is recording ride telemetry', // RID-3C-1: permanently truthful wording.
notificationTitle: 'MotorSignal Active Ride', //
// The geolocator foreground service is APP-LIFETIME by design (see
// RID-3C-1 binding decision 2): it starts when the location stack is
// created and is deliberately NOT ride-scoped, because ride-scoping
// would introduce Android 12+ background-FGS-start failures and
// service/wake-lock churn - especially with motion auto-start.
//
// The previous copy claimed an "Active Ride" and "recording ride
// telemetry" while merely idling on the dashboard, which was false.
// This text must describe the app-lifetime location CAPABILITY only,
// never current ride state. It is static by design: it must not be
// updated dynamically, and the stream must not be resubscribed just
// to change it.
//
// Must never contain: "Active Ride", "currently recording", a ride
// name, route, coordinates, speed, distance, telemetry values, or
// account identity.
notificationText: 'Location active for ride recording',
notificationTitle: 'MotorSignal',
enableWakeLock: true, enableWakeLock: true,
), ),
); );

View File

@ -117,7 +117,7 @@ void main() {
await db.close(); await db.close();
}); });
test('Physical Failure Regression: Starting ride creates DB session, active health, and logs lifecycle', () async { test('Physical Failure Regression: Starting ride creates DB session, warm-up health, and logs lifecycle', () async {
final container = ProviderContainer( final container = ProviderContainer(
overrides: [ overrides: [
sharedPreferencesProvider.overrideWithValue(prefs), sharedPreferencesProvider.overrideWithValue(prefs),
@ -141,7 +141,13 @@ void main() {
session = container.read(rideStateMachineProvider); session = container.read(rideStateMachineProvider);
expect(session.state, equals(RideState.riding)); expect(session.state, equals(RideState.riding));
expect(session.dbId, isNotNull); expect(session.dbId, isNotNull);
expect(session.healthStatus, equals(RecordingHealthStatus.recordingActive)); // RID-3C-1: a freshly created ride has NO evidence yet, so it must report
// warm-up health instead of falsely claiming an active recording. Health
// promotes automatically once real GPS / IMU / telemetry / DB-commit evidence
// arrives; see test/rid3c1_truthful_recording_health_test.dart (T1, T2).
expect(session.healthStatus, equals(RecordingHealthStatus.recordingArmed));
expect(session.health.canReportRecording, isFalse);
expect(session.health.isValidSessionId, isTrue);
// Emit 10 telemetry samples // Emit 10 telemetry samples
for (int i = 0; i < 10; i++) { for (int i = 0; i < 10; i++) {

View File

@ -0,0 +1,632 @@
// RID-3C-1 permanent regression suite: truthful, evidence-based recording health.
//
// Locks: no false "recording active" without real evidence; GPS / telemetry /
// IMU / database / watchdog evidence domains; monotonic freshness; warm-up,
// degradation and recovery; RID-3B-1 finalizing priority; post-cutoff samples
// cannot refresh health; and the permanently truthful foreground notification.
//
// Evidence is controlled through TelemetrySample.monotonicTimestampMs, so
// staleness is deterministic and no test relies on a sleep-only race except where
// the production wall-clock telemetry bound is itself the subject (T4/T9).
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:drift/native.dart';
import 'package:drift/drift.dart' as drift;
import 'package:motorcycle_dash/database/database.dart'
hide TelemetrySample, FieldTestSummary, FieldTestEvent;
import 'package:motorcycle_dash/models/telemetry_sample.dart';
import 'package:motorcycle_dash/models/field_test_event.dart';
import 'package:motorcycle_dash/models/recording_health_status.dart';
import 'package:motorcycle_dash/services/telemetry_service.dart';
import 'package:motorcycle_dash/services/location_service.dart';
import 'package:motorcycle_dash/services/sensor_fusion_service.dart';
import 'package:motorcycle_dash/services/compass_service.dart';
import 'package:motorcycle_dash/services/audio_alert_service.dart';
import 'package:motorcycle_dash/services/field_test/field_test_logger.dart';
import 'package:motorcycle_dash/services/validation/telemetry_validator.dart';
import 'package:motorcycle_dash/features/rides/ride_state_machine.dart';
import 'package:motorcycle_dash/features/records/records_service.dart';
class _Rid3c1AppDatabase extends AppDatabase {
_Rid3c1AppDatabase() : super.forTesting(NativeDatabase.memory());
int batchInvocations = 0;
bool gateBatch = false;
final List<Completer<void>> batchGates = [];
int batchFailuresRemaining = 0;
@override
Future<void> batch(
FutureOr<void> Function(drift.Batch batch) runInBatch) async {
batchInvocations++;
if (gateBatch) {
final gate = Completer<void>();
batchGates.add(gate);
await gate.future;
}
if (batchFailuresRemaining > 0) {
batchFailuresRemaining--;
throw StateError('RID-3C-1 simulated pre-commit persistence failure');
}
return super.batch(runInBatch);
}
}
class _Rid3c1FieldTestLogger extends FieldTestLogger {
_Rid3c1FieldTestLogger(super.db);
final List<FieldTestEventType> eventTypes = [];
int countOf(FieldTestEventType type) =>
eventTypes.where((t) => t == type).length;
/// Neutralised so db.batch accounting refers only to telemetry persistence.
@override
Future<void> flush() async {}
@override
void log({
required FieldTestEventType eventType,
required EventSeverity severity,
required String message,
int? rideId,
DateTime? timestamp,
DateTime? telemetryTimestamp,
int? gpsAgeMs,
double? speed,
double? latitude,
double? longitude,
double? gpsAccuracy,
double? leanAngle,
double? longitudinalG,
double? lateralG,
double? overallConfidence,
Map<String, dynamic>? metadata,
}) {
eventTypes.add(eventType);
super.log(
eventType: eventType,
severity: severity,
message: message,
rideId: rideId,
timestamp: timestamp,
telemetryTimestamp: telemetryTimestamp,
gpsAgeMs: gpsAgeMs,
speed: speed,
latitude: latitude,
longitude: longitude,
gpsAccuracy: gpsAccuracy,
leanAngle: leanAngle,
longitudinalG: longitudinalG,
lateralG: lateralG,
overallConfidence: overallConfidence,
metadata: metadata,
);
}
}
class _Rid3c1AudioAlertService extends Fake implements AudioAlertService {
@override
Future<void> speak(String text, {bool force = false}) async {}
}
class _Rid3c1TelemetryService extends TelemetryService {
_Rid3c1TelemetryService(super.loc, super.fusion, super.compass, super.logger,
super.validator);
final StreamController<TelemetrySample> _controller =
StreamController<TelemetrySample>.broadcast();
@override
Stream<TelemetrySample> get telemetryStream => _controller.stream;
void emitSample(TelemetrySample sample) => _controller.add(sample);
@override
void dispose() => _controller.close();
}
class _NullLocationService extends Fake implements LocationService {
@override
void Function(Position position, bool accepted, String? rejectionReason)?
onGpsDiagnostic;
@override
Stream<Position> getFilteredPositionStream() => const Stream.empty();
}
class _NullSensorFusionService extends Fake implements SensorFusionService {
@override
Stream<double> get leanAngleStream => const Stream.empty();
@override
Stream<double> get pitchAngleStream => const Stream.empty();
@override
Stream<GForceData> get gForceStream => const Stream.empty();
@override
double get latestRollRateDegPerSec => 0.0;
@override
double get latestRawAccelX => 0.0;
@override
double get latestRawAccelY => 0.0;
@override
double get latestRawAccelZ => 0.0;
@override
double get latestRawGyroX => 0.0;
@override
double get latestRawGyroY => 0.0;
@override
double get latestRawGyroZ => 0.0;
}
class _NullCompassService extends Fake implements CompassService {
@override
Stream<double> get headingStream => const Stream.empty();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
/// The production batch-write timer is a real 3-second Timer.periodic; waiting
/// just over one interval makes the DB-commit evidence deterministic without
/// mocking the frozen crash-durability cadence.
const tickWait = Duration(milliseconds: 3300);
/// Production wall-clock bounds under test. Referenced from the contract so a
/// threshold change is caught by these tests rather than silently drifting.
const evidenceStale = RecordingHealthContract.evidenceStaleAfterMs;
const watchdogLimit = RecordingHealthContract.watchdogMissLimit;
late _Rid3c1AppDatabase db;
late _Rid3c1FieldTestLogger logger;
late _Rid3c1TelemetryService telemetry;
late SharedPreferences prefs;
final sampleBase = DateTime(2026, 9, 10, 12);
var identity = 0;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
db = _Rid3c1AppDatabase();
logger = _Rid3c1FieldTestLogger(db);
telemetry = _Rid3c1TelemetryService(
_NullLocationService(),
_NullSensorFusionService(),
_NullCompassService(),
logger,
TelemetryValidator());
identity = 0;
});
tearDown(() async {
for (final gate in db.batchGates) {
if (!gate.isCompleted) gate.complete();
}
db.batchGates.clear();
db.gateBatch = false;
db.batchFailuresRemaining = 0;
telemetry.dispose();
logger.dispose();
await db.close();
});
ProviderContainer buildContainer({bool registerTeardown = true}) {
final container = ProviderContainer(overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
databaseProvider.overrideWithValue(db),
telemetryServiceProvider.overrideWithValue(telemetry),
audioAlertServiceProvider.overrideWithValue(_Rid3c1AudioAlertService()),
fieldTestLoggerProvider.overrideWithValue(logger),
]);
if (registerTeardown) addTearDown(container.dispose);
return container;
}
RideSession sessionOf(ProviderContainer c) => c.read(rideStateMachineProvider);
Future<void> startRide(ProviderContainer c) =>
c.read(rideStateMachineProvider.notifier).startRide();
/// Emits samples carrying REAL evidence markers.
///
/// [monotonicMs] is the sample's position in TelemetryService's monotonic
/// domain, which is how staleness is controlled deterministically. GPS and IMU
/// evidence are PRESENCE signals (`gpsTimestamp` / `imuTimestamp`), never
/// magnitudes: the lean/pitch/g values below are deliberately 0.0 to prove a
/// genuine zero-valued sensor sample still counts as real evidence.
void emitEvidence({
required int monotonicMs,
int count = 4,
bool withGps = true,
bool withImu = true,
int stepMs = 50,
}) {
for (var i = 0; i < count; i++) {
final index = identity++;
final ts = sampleBase.add(Duration(milliseconds: index * 50));
telemetry.emitSample(TelemetrySample(
timestamp: ts,
sequenceNumber: index,
monotonicTimestampMs: monotonicMs + (i * stepMs),
latitude: 34.0 + (index * 0.0001),
longitude: -85.0,
speed: 42.0,
heading: 90.0,
gpsAccuracy: 3.0,
gpsTimestamp: withGps ? ts : null,
imuTimestamp: withImu ? ts : null,
leanAngle: 0.0,
pitchAngle: 0.0,
longitudinalG: 0.0,
lateralG: 0.0,
));
}
}
/// Drives the pipeline to a fully evidenced, healthy active ride: valid session,
/// fresh GPS, fresh telemetry, fresh real IMU evidence, and one REAL successful
/// telemetry commit at the RID-3B-2 commit point.
Future<void> reachHealthyActive(ProviderContainer c) async {
await startRide(c);
emitEvidence(monotonicMs: 1000);
await pumpEventQueue(times: 30);
await Future<void>.delayed(tickWait);
await pumpEventQueue(times: 40);
// Re-establish GPS/IMU freshness relative to the newest monotonic reference.
emitEvidence(monotonicMs: 1000 + tickWait.inMilliseconds);
await pumpEventQueue(times: 30);
}
group('RID-3C-1 truthful recording health', () {
test('T1 no false recordingActive at ride creation before evidence',
() async {
final c = buildContainer();
await startRide(c);
final s = sessionOf(c);
expect(s.state, RideState.riding,
reason: 'ride creation is never blocked on GPS/IMU/DB warm-up');
expect(s.dbId, isNotNull);
expect(s.health.canReportRecording, isFalse);
expect(s.healthStatus, isNot(RecordingHealthStatus.recordingActive));
expect(s.healthStatus, RecordingHealthStatus.recordingArmed,
reason: 'warm-up, not a fault');
expect(s.health.isEvidenceWarmUp, isTrue);
expect(s.health.isValidSessionId, isTrue);
});
test('T2 complete real evidence becomes recordingActive', () async {
final c = buildContainer();
await reachHealthyActive(c);
final s = sessionOf(c);
expect(s.health.isValidSessionId, isTrue);
expect(s.health.isForegroundServiceAlive, isTrue,
reason: 'real recent GPS fix evidence, not a service assumption');
expect(s.health.isTelemetryPipelineActive, isTrue);
expect(s.health.isAtLeastOneSensorActive, isTrue,
reason: 'real IMU sample present although lean/pitch/g were 0.0');
expect(s.health.isDatabasePersistenceOperational, isTrue,
reason: 'a real telemetry commit occurred');
expect(s.health.lastDatabaseWriteTime, isNotNull);
expect(s.health.consecutiveWatchdogMisses, lessThan(watchdogLimit));
expect(s.health.canReportRecording, isTrue);
expect(s.healthStatus, RecordingHealthStatus.recordingActive);
expect(db.batchInvocations, greaterThanOrEqualTo(1));
});
test('T3 stale GPS evidence alone degrades an active ride', () async {
final c = buildContainer();
await reachHealthyActive(c);
expect(sessionOf(c).healthStatus, RecordingHealthStatus.recordingActive);
// Advance monotonic time past the GPS bound but still inside the DB bound,
// and stop supplying GPS fixes. Only the GPS domain goes stale.
final advanced = 1000 + tickWait.inMilliseconds + evidenceStale + 1000;
emitEvidence(monotonicMs: advanced, withGps: false);
await pumpEventQueue(times: 30);
final s = sessionOf(c);
expect(s.health.isForegroundServiceAlive, isFalse);
expect(s.health.isTelemetryPipelineActive, isTrue);
expect(s.health.isAtLeastOneSensorActive, isTrue);
expect(s.health.isDatabasePersistenceOperational, isTrue);
expect(s.health.canReportRecording, isFalse);
expect(s.healthStatus, RecordingHealthStatus.recordingDegraded);
expect(s.state, RideState.riding,
reason: 'degradation must never end the ride');
});
test('T7 real sensor evidence required; zero magnitude is not missing',
() async {
final c = buildContainer();
await reachHealthyActive(c);
expect(sessionOf(c).health.isAtLeastOneSensorActive, isTrue,
reason: 'lean/pitch/g were all 0.0 yet the sample is real evidence');
final advanced = 1000 + tickWait.inMilliseconds + evidenceStale + 1000;
emitEvidence(monotonicMs: advanced, withImu: false);
await pumpEventQueue(times: 30);
final s = sessionOf(c);
expect(s.health.isAtLeastOneSensorActive, isFalse);
expect(s.health.isForegroundServiceAlive, isTrue,
reason: 'GPS still fresh: only the IMU domain was expired');
expect(s.health.canReportRecording, isFalse);
expect(s.healthStatus, isNot(RecordingHealthStatus.recordingActive));
});
test('T8 no valid active session means no recording claim', () async {
final c = buildContainer();
final s = sessionOf(c);
expect(s.state, RideState.idle);
expect(s.dbId, isNull);
expect(s.health.isValidSessionId, isFalse);
expect(s.health.canReportRecording, isFalse);
expect(s.healthStatus, isNot(RecordingHealthStatus.recordingActive));
});
test('T14 location stack available without a ride is never recording',
() async {
final c = buildContainer();
// Full-evidence telemetry flows but no ride exists, so the acceptance path
// records nothing: service and sensor presence alone claim nothing.
emitEvidence(monotonicMs: 5000, count: 10);
await pumpEventQueue(times: 40);
final s = sessionOf(c);
expect(s.state, isNot(RideState.riding));
expect(s.health.isValidSessionId, isFalse);
expect(s.health.canReportRecording, isFalse);
expect(s.healthStatus, isNot(RecordingHealthStatus.recordingActive));
expect(s.health.isEvidenceWarmUp, isTrue);
});
test('T13 foreground notification copy is truthful and non-sensitive',
() async {
// The notification strings are compile-time constants inside a private
// method of LocationService with no behavioural seam, so this is a
// deliberate source-text assertion on the extracted literals only.
final source =
await File('lib/services/location_service.dart').readAsString();
final title = RegExp(r"notificationTitle:\s*'([^']*)'")
.firstMatch(source)
?.group(1);
final body = RegExp(r"notificationText:\s*'([^']*)'")
.firstMatch(source)
?.group(1);
expect(title, isNotNull, reason: 'production notification title exists');
expect(body, isNotNull, reason: 'production notification body exists');
expect(title, 'MotorSignal');
expect(body, 'Location active for ride recording');
for (final value in [title!, body!]) {
final lower = value.toLowerCase();
expect(lower, isNot(contains('active ride')),
reason: 'must never imply a ride is active while idle');
expect(lower, isNot(contains('currently recording')));
expect(lower, isNot(contains('recording ride telemetry')));
for (final banned in [
'latitude',
'longitude',
'coordinate',
'speed',
'distance',
'route',
'uid',
'token',
'@',
]) {
expect(lower, isNot(contains(banned)),
reason: 'no sensitive ride or account data in notification copy');
}
}
});
test('T4 stale telemetry evidence degrades an active ride', () async {
final c = buildContainer();
await reachHealthyActive(c);
expect(sessionOf(c).healthStatus, RecordingHealthStatus.recordingActive);
// Telemetry freshness is the one wall-clock term, deliberately sharing the
// existing watchdog bound: it is what prevents monotonic freeze-blindness
// when samples stop arriving and the monotonic reference stops advancing.
await Future<void>.delayed(Duration(milliseconds: evidenceStale + 1200));
await pumpEventQueue(times: 20);
final s = sessionOf(c);
expect(s.health.isTelemetryPipelineActive, isFalse);
expect(s.health.canReportRecording, isFalse);
expect(s.healthStatus, RecordingHealthStatus.recordingDegraded);
expect(s.state, RideState.riding,
reason: 'degradation never ends the ride or fabricates a failure');
});
test('T9 canonical watchdog misses degrade health', () async {
final c = buildContainer();
await reachHealthyActive(c);
expect(sessionOf(c).health.consecutiveWatchdogMisses, 0);
// Long enough for the existing 2 s watchdog to accumulate >= limit misses
// after the 4 s stall bound is crossed, with margin for tick phase offset.
// No second watchdog is created.
await Future<void>.delayed(Duration(milliseconds: evidenceStale + 7000));
await pumpEventQueue(times: 20);
final s = sessionOf(c);
expect(s.health.lastHeartbeatTime, isNotNull,
reason: 'the canonical watchdog supplies heartbeat evidence');
expect(s.health.consecutiveWatchdogMisses,
greaterThanOrEqualTo(watchdogLimit));
expect(s.health.isWatchdogHealthy, isFalse);
expect(s.health.canReportRecording, isFalse);
expect(s.healthStatus, RecordingHealthStatus.recordingDegraded);
expect(logger.countOf(FieldTestEventType.telemetryGap),
greaterThanOrEqualTo(1),
reason: 'the existing stall diagnostic is preserved');
});
test('T5 pre-commit DB failure does not advance database evidence',
() async {
final c = buildContainer();
await startRide(c);
emitEvidence(monotonicMs: 1000);
await pumpEventQueue(times: 30);
db.batchFailuresRemaining = 1;
await Future<void>.delayed(tickWait);
await pumpEventQueue(times: 40);
final rows = await db.select(db.telemetrySamples).get();
final s = sessionOf(c);
expect(db.batchInvocations, 1);
expect(rows, isEmpty, reason: 'the failed attempt committed nothing');
expect(s.health.isDatabasePersistenceOperational, isFalse,
reason: 'a failed pre-commit batch must not advance DB health');
expect(s.health.lastDatabaseWriteTime, isNull);
expect(s.health.canReportRecording, isFalse);
expect(s.healthStatus, isNot(RecordingHealthStatus.recordingActive),
reason: 'no false recording claim without durable telemetry');
});
test('T6 a successful retry restores database evidence', () async {
final c = buildContainer();
await startRide(c);
emitEvidence(monotonicMs: 1000);
await pumpEventQueue(times: 30);
db.batchFailuresRemaining = 1;
await Future<void>.delayed(tickWait);
await pumpEventQueue(times: 40);
expect(sessionOf(c).health.isDatabasePersistenceOperational, isFalse);
// RID-3A front-restoration retries the same snapshot; this time it commits.
final second = 1000 + tickWait.inMilliseconds;
emitEvidence(monotonicMs: second);
await pumpEventQueue(times: 30);
await Future<void>.delayed(tickWait);
await pumpEventQueue(times: 40);
final rows = await db.select(db.telemetrySamples).get();
expect(rows, isNotEmpty, reason: 'the retry really committed');
// Refresh telemetry/GPS/IMU freshness relative to the newest monotonic
// reference so recovery is attributed to evidence, not to timing luck.
emitEvidence(monotonicMs: second + tickWait.inMilliseconds);
await pumpEventQueue(times: 30);
final s = sessionOf(c);
expect(s.health.isDatabasePersistenceOperational, isTrue,
reason: 'a successful retry restores DB-health evidence');
expect(s.health.lastDatabaseWriteTime, isNotNull);
expect(s.health.isTelemetryPipelineActive, isTrue);
expect(s.health.isForegroundServiceAlive, isTrue);
expect(s.health.isAtLeastOneSensorActive, isTrue);
expect(s.health.canReportRecording, isTrue,
reason: 'active ONLY because every domain is fresh again');
expect(s.healthStatus, RecordingHealthStatus.recordingActive);
});
test('T10 health recovers to active without creating a new ride', () async {
final c = buildContainer();
await reachHealthyActive(c);
final rideId = sessionOf(c).dbId;
expect(sessionOf(c).healthStatus, RecordingHealthStatus.recordingActive);
// Expire only GPS while keeping the DB commit inside its 9 s bound, so the
// recovery step is attributable to GPS evidence alone.
final advanced = 1000 + tickWait.inMilliseconds + evidenceStale + 200;
emitEvidence(monotonicMs: advanced, withGps: false);
await pumpEventQueue(times: 30);
expect(sessionOf(c).healthStatus, RecordingHealthStatus.recordingDegraded);
// GPS evidence returns: recovery needs no new ride, no dbId change and no
// service or TelemetryService restart.
emitEvidence(monotonicMs: advanced + 300);
await pumpEventQueue(times: 30);
final s = sessionOf(c);
expect(s.health.canReportRecording, isTrue);
expect(s.healthStatus, RecordingHealthStatus.recordingActive);
expect(s.dbId, rideId, reason: 'same ride identity throughout');
expect(s.state, RideState.riding);
});
test('T11 finalizing/complete/failed cannot be clobbered by health '
'evaluation', () {
// Deterministic contract-level proof of RID-3B-1 priority: the lifecycle
// status owned by finalization is authoritative over evidence mapping.
const healthy = RecordingHealthContract(
isForegroundServiceAlive: true,
isTelemetryPipelineActive: true,
isAtLeastOneSensorActive: true,
isDatabasePersistenceOperational: true,
isValidSessionId: true,
);
expect(healthy.canReportRecording, isTrue);
expect(
healthy.resolveStatus(
lifecycle: RecordingHealthStatus.recordingFinalizing),
RecordingHealthStatus.recordingFinalizing);
expect(
healthy.resolveStatus(
lifecycle: RecordingHealthStatus.recordingComplete),
RecordingHealthStatus.recordingComplete);
expect(
healthy.resolveStatus(
lifecycle: RecordingHealthStatus.recordingFailed),
RecordingHealthStatus.recordingFailed);
// A ride with a session but incomplete evidence may NEVER map to active.
final incomplete = RecordingHealthContract(
isValidSessionId: true,
lastTelemetryTime: DateTime(2026, 9, 10, 12),
);
expect(incomplete.canReportRecording, isFalse);
expect(incomplete.isEvidenceWarmUp, isFalse);
expect(
incomplete.resolveStatus(
lifecycle: RecordingHealthStatus.recordingActive),
RecordingHealthStatus.recordingDegraded,
reason: 'canReportRecording == false MUST NOT show recordingActive');
// Watchdog misses alone withhold an active claim WITHOUT weakening the
// frozen five-way conjunction.
final missed = healthy.copyWith(
consecutiveWatchdogMisses: RecordingHealthContract.watchdogMissLimit,
);
expect(missed.canReportRecording, isTrue,
reason: 'the frozen conjunction is preserved exactly');
expect(missed.isWatchdogHealthy, isFalse);
expect(
missed.resolveStatus(
lifecycle: RecordingHealthStatus.recordingActive),
isNot(RecordingHealthStatus.recordingActive));
});
test('T12 late telemetry cannot refresh health evidence', () async {
final c = buildContainer();
await reachHealthyActive(c);
final notifier = c.read(rideStateMachineProvider.notifier);
await notifier.finishRide();
await pumpEventQueue(times: 40);
expect(sessionOf(c).healthStatus,
RecordingHealthStatus.recordingComplete);
final before = sessionOf(c).health;
// Late samples are rejected by RID-3B-1's acceptance cutoff and by the
// riding-state guard, and resolveStatus keeps complete authoritative.
emitEvidence(monotonicMs: 999000, count: 10);
await pumpEventQueue(times: 40);
final after = sessionOf(c).health;
expect(sessionOf(c).healthStatus,
RecordingHealthStatus.recordingComplete,
reason: 'complete stays complete');
expect(after.lastTelemetryTime, before.lastTelemetryTime,
reason: 'late telemetry must not refresh health evidence');
expect(after.isTelemetryPipelineActive, before.isTelemetryPipelineActive);
expect(after.isForegroundServiceAlive, before.isForegroundServiceAlive);
expect(after.isAtLeastOneSensorActive, before.isAtLeastOneSensorActive);
expect(sessionOf(c).state, RideState.finished);
});
});
}