fix(forensic): resolve physical recording failure & add strict lifecycle health contract

This commit is contained in:
Developer 2026-08-28 12:05:37 -04:00
parent e700ca64fd
commit e0a80527e8
16 changed files with 1193 additions and 166 deletions

View File

@ -1,44 +1,28 @@
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'version/app_identity.dart';
/// Single source of truth for the app's identity shown inside the running app. /// Single source of truth for the app's identity shown inside the running app.
///
/// **Source of truth** for the version numbers is the `version:` field in
/// `pubspec.yaml` (e.g. `1.0.0+1`). At runtime we read that value via
/// `package_info_plus`, which Flutter populates from `pubspec.yaml` *unless*
/// overridden at build time with `--build-name` / `--build-number`.
///
/// If `package_info_plus` cannot resolve the values (e.g. running in a unit
/// test that hasn't initialised the plugin platform), we fall back to the
/// [fallbackVersion] constant. Keep that constant in sync with `pubspec.yaml`
/// when you bump versions; the release process described in
/// `docs/RELEASING.md` owns that update.
class AppInfo { class AppInfo {
AppInfo._(); AppInfo._();
/// Hard-coded fallback. Must mirror the `version:` field in `pubspec.yaml`. /// Hard-coded fallback mirroring pubspec.yaml and AppIdentity
/// Format: `MAJOR.MINOR.PATCH+BUILD`. static const String fallbackVersion = '1.0.0+100';
static const String fallbackVersion = '1.1.0+12';
/// Cached result so we only hit the platform once.
static PackageInfo? _cached; static PackageInfo? _cached;
/// Asynchronously loads the package info, caching the result.
static Future<PackageInfo> _load() async { static Future<PackageInfo> _load() async {
return _cached ??= await PackageInfo.fromPlatform(); return _cached ??= await PackageInfo.fromPlatform();
} }
/// Resolves the build version in the form `MAJOR.MINOR.PATCH+BUILD` /// Resolves the build version in the form `MAJOR.MINOR.PATCH+BUILD`
/// (e.g. `1.0.0+1`). Falls back to [fallbackVersion] on error.
static Future<String> versionString() async { static Future<String> versionString() async {
try { try {
final info = await _load(); final info = await _load();
final name = info.version.isEmpty ? '0.0.0' : info.version; final name = info.version.isEmpty ? AppIdentity.current().semanticVersion : info.version;
final build = info.buildNumber.isEmpty ? '0' : info.buildNumber; final build = info.buildNumber.isEmpty ? '${AppIdentity.current().buildNumber}' : info.buildNumber;
return '$name+$build'; return '$name+$build';
} catch (e, st) { } catch (_) {
debugPrint('AppInfo.versionString() fell back to constant: $e\n$st'); return AppIdentity.current().displayString;
return fallbackVersion;
} }
} }
@ -46,20 +30,22 @@ class AppInfo {
static Future<String> versionName() async { static Future<String> versionName() async {
try { try {
final info = await _load(); final info = await _load();
return info.version.isEmpty ? '0.0.0' : info.version; return info.version.isEmpty ? AppIdentity.current().semanticVersion : info.version;
} catch (_) { } catch (_) {
// Extract the part before the '+' from the fallback. return AppIdentity.current().semanticVersion;
return fallbackVersion.split('+').first;
} }
} }
/// The monotonically increasing `+BUILD` portion (e.g. `1`). /// The monotonically increasing `+BUILD` portion (e.g. `100`).
static Future<String> buildNumber() async { static Future<String> buildNumber() async {
try { try {
final info = await _load(); final info = await _load();
return info.buildNumber.isEmpty ? '0' : info.buildNumber; return info.buildNumber.isEmpty ? '${AppIdentity.current().buildNumber}' : info.buildNumber;
} catch (_) { } catch (_) {
return fallbackVersion.split('+').last; return '${AppIdentity.current().buildNumber}';
} }
} }
/// The immutable application identity
static AppIdentity get identity => AppIdentity.current();
} }

View File

@ -0,0 +1,115 @@
import 'package:flutter/foundation.dart';
enum ExportCompatibility {
supported,
unsupported,
unknown,
}
/// Single immutable authoritative representation of the application and forensic identity.
class AppIdentity {
final String semanticVersion;
final int buildNumber;
final String gitCommit;
final String gitBranch;
final String releaseChannel;
final int telemetrySchemaVersion;
final int exportSchemaVersion;
final String buildTimestamp;
final String flutterVersion;
final String dartVersion;
const AppIdentity({
required this.semanticVersion,
required this.buildNumber,
required this.gitCommit,
required this.gitBranch,
required this.releaseChannel,
required this.telemetrySchemaVersion,
required this.exportSchemaVersion,
required this.buildTimestamp,
this.flutterVersion = '3.x / SDK ^3.12.2',
this.dartVersion = '3.x',
});
/// Authoritative current runtime instance derived from compile-time environment defines with safe fallbacks
static AppIdentity current() {
const semVer = String.fromEnvironment('APP_VERSION', defaultValue: '1.0.0');
const buildNum = int.fromEnvironment('BUILD_NUMBER', defaultValue: 100);
const commit = String.fromEnvironment('GIT_COMMIT', defaultValue: 'e700ca64fdb6365d30a76d81236c912a730bfad2');
const branch = String.fromEnvironment('GIT_BRANCH', defaultValue: 'master');
const defaultChannel = kDebugMode ? 'debug' : (kProfileMode ? 'profile' : 'release');
const channel = String.fromEnvironment('RELEASE_CHANNEL', defaultValue: defaultChannel);
const timestamp = String.fromEnvironment('BUILD_TIMESTAMP', defaultValue: '2026-08-28T05:00:00Z');
return const AppIdentity(
semanticVersion: semVer,
buildNumber: buildNum,
gitCommit: commit,
gitBranch: branch,
releaseChannel: channel,
telemetrySchemaVersion: 5,
exportSchemaVersion: 1,
buildTimestamp: timestamp,
);
}
/// Compact single-line version identifier (e.g. `1.0.0+100 (e700ca64)`)
String get displayString => '$semanticVersion+$buildNumber (${gitCommit.length > 8 ? gitCommit.substring(0, 8) : gitCommit})';
/// Serializes software identity for inclusion in forensic bundles (Phase 5)
Map<String, dynamic> toJson() {
return {
'version': semanticVersion,
'build': buildNumber,
'gitCommit': gitCommit,
'gitBranch': gitBranch,
'releaseChannel': releaseChannel,
'telemetrySchemaVersion': telemetrySchemaVersion,
'exportSchemaVersion': exportSchemaVersion,
'buildTimestamp': buildTimestamp,
};
}
/// Deserializes software identity from an exported forensic session
factory AppIdentity.fromJson(Map<String, dynamic> json) {
return AppIdentity(
semanticVersion: json['version'] as String? ?? '1.0.0',
buildNumber: (json['build'] as num?)?.toInt() ?? 100,
gitCommit: json['gitCommit'] as String? ?? 'unknown',
gitBranch: json['gitBranch'] as String? ?? 'unknown',
releaseChannel: json['releaseChannel'] as String? ?? 'release',
telemetrySchemaVersion: (json['telemetrySchemaVersion'] as num?)?.toInt() ?? 5,
exportSchemaVersion: (json['exportSchemaVersion'] as num?)?.toInt() ?? 1,
buildTimestamp: json['buildTimestamp'] as String? ?? '',
);
}
/// Generates the standard release manifest JSON payload (Phase 10)
Map<String, dynamic> generateReleaseManifest() {
return {
'application': 'MotorSignal',
'version': semanticVersion,
'build': buildNumber,
'gitCommit': gitCommit,
'gitBranch': gitBranch,
'releaseChannel': releaseChannel,
'telemetrySchema': telemetrySchemaVersion,
'exportSchema': exportSchemaVersion,
'buildTimestamp': buildTimestamp,
'flutterVersion': flutterVersion,
'dartVersion': dartVersion,
};
}
/// Checks if an exported bundle schema and version can be interpreted by the replay engine (Phase 7)
static ExportCompatibility checkExportCompatibility(int schemaVersion) {
if (schemaVersion == 1) {
return ExportCompatibility.supported;
} else if (schemaVersion > 1) {
return ExportCompatibility.unsupported;
} else {
return ExportCompatibility.unknown;
}
}
}

View File

@ -87,19 +87,62 @@ class EvolutionLandscapeDashboard extends ConsumerWidget {
border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2), border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2),
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: LayoutBuilder( child: Stack(
builder: (context, constraints) { children: [
return SizedBox( LayoutBuilder(
width: constraints.maxWidth, builder: (context, constraints) {
height: constraints.maxHeight, return SizedBox(
child: UnifiedMapView( width: constraints.maxWidth,
key: ValueKey('evo_map_${constraints.maxWidth.toInt()}x${constraints.maxHeight.toInt()}'), height: constraints.maxHeight,
center: AppLatLng(centerLat, centerLng), child: UnifiedMapView(
zoom: 17.0, key: ValueKey('evo_map_${constraints.maxWidth.toInt()}x${constraints.maxHeight.toInt()}'),
markers: mapMarkers, center: AppLatLng(centerLat, centerLng),
zoom: 17.0,
markers: mapMarkers,
),
);
},
),
Positioned(
bottom: 12,
left: 12,
child: ElevatedButton.icon(
onPressed: () {
if (session.state == RideState.riding) {
ref.read(rideStateMachineProvider.notifier).finishRide();
} else {
ref.read(rideStateMachineProvider.notifier).startRide();
}
},
style: ElevatedButton.styleFrom(
backgroundColor: session.state == RideState.riding ? const Color(0xFF3D0000) : const Color(0xFF00252A),
foregroundColor: session.state == RideState.riding ? const Color(0xFFFF2A2A) : const Color(0xFF00E5FF),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: session.state == RideState.riding ? const Color(0xFFFF2A2A) : const Color(0xFF00E5FF),
width: 1.5,
),
),
elevation: 4,
),
icon: Icon(
session.state == RideState.riding ? Icons.stop_circle : Icons.play_circle,
size: 18,
color: session.state == RideState.riding ? const Color(0xFFFF2A2A) : const Color(0xFF00E5FF),
),
label: Text(
session.state == RideState.riding ? 'STOP SESSION' : 'START SESSION',
style: const TextStyle(
fontFamily: 'Sora',
fontSize: 12,
fontWeight: FontWeight.w800,
letterSpacing: 0.1,
),
),
), ),
); ),
}, ],
), ),
), ),
), ),

View File

@ -28,7 +28,7 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final records = ref.watch(recordsServiceProvider); final records = ref.watch(recordsServiceProvider);
final isRecording = session.state == RideState.riding || session.state == RideState.ready; final isRecording = session.state == RideState.riding;
final speed = currentSpeedMph.clamp(0.0, 220.0); final speed = currentSpeedMph.clamp(0.0, 220.0);
final topSpeed = math.max(session.topSpeedMph, records.topSpeedMph); final topSpeed = math.max(session.topSpeedMph, records.topSpeedMph);
final totalG = math.sqrt(accelG * accelG + lateralG * lateralG); final totalG = math.sqrt(accelG * accelG + lateralG * lateralG);
@ -316,10 +316,10 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
height: 52, height: 52,
child: ElevatedButton( child: ElevatedButton(
onPressed: () { onPressed: () {
if (session.state == RideState.riding || session.state == RideState.ready) { if (session.state == RideState.riding) {
ref.read(rideStateMachineProvider.notifier).finishRide(); ref.read(rideStateMachineProvider.notifier).finishRide();
} else { } else {
ref.read(rideStateMachineProvider.notifier).setReady(); ref.read(rideStateMachineProvider.notifier).startRide();
} }
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(

View File

@ -6,6 +6,7 @@ import 'package:drift/drift.dart' as drift;
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import '../../models/telemetry_sample.dart'; import '../../models/telemetry_sample.dart';
import '../../models/field_test_event.dart'; import '../../models/field_test_event.dart';
import '../../models/recording_health_status.dart';
import '../../services/telemetry_service.dart'; import '../../services/telemetry_service.dart';
import '../../services/audio_alert_service.dart'; import '../../services/audio_alert_service.dart';
import '../records/records_service.dart'; import '../records/records_service.dart';
@ -17,6 +18,7 @@ enum RideState { idle, ready, riding, paused, finished }
class RideSession { class RideSession {
final int? dbId; final int? dbId;
final RideState state; final RideState state;
final RecordingHealthStatus healthStatus;
final double tripDistanceMiles; final double tripDistanceMiles;
final double topSpeedMph; final double topSpeedMph;
final double maxLeanLeft; final double maxLeanLeft;
@ -31,10 +33,13 @@ class RideSession {
final double elevationGain; final double elevationGain;
final double elevationLoss; final double elevationLoss;
final double averageSpeedMph; final double averageSpeedMph;
final int totalSamplesReceived;
final int totalSamplesPersisted;
RideSession({ RideSession({
this.dbId, this.dbId,
this.state = RideState.idle, this.state = RideState.idle,
this.healthStatus = RecordingHealthStatus.recordingArmed,
this.tripDistanceMiles = 0.0, this.tripDistanceMiles = 0.0,
this.topSpeedMph = 0.0, this.topSpeedMph = 0.0,
this.maxLeanLeft = 0.0, this.maxLeanLeft = 0.0,
@ -47,11 +52,14 @@ class RideSession {
this.elevationGain = 0.0, this.elevationGain = 0.0,
this.elevationLoss = 0.0, this.elevationLoss = 0.0,
this.averageSpeedMph = 0.0, this.averageSpeedMph = 0.0,
this.totalSamplesReceived = 0,
this.totalSamplesPersisted = 0,
}); });
RideSession copyWith({ RideSession copyWith({
int? dbId, int? dbId,
RideState? state, RideState? state,
RecordingHealthStatus? healthStatus,
double? tripDistanceMiles, double? tripDistanceMiles,
double? topSpeedMph, double? topSpeedMph,
double? maxLeanLeft, double? maxLeanLeft,
@ -64,10 +72,13 @@ class RideSession {
double? elevationGain, double? elevationGain,
double? elevationLoss, double? elevationLoss,
double? averageSpeedMph, double? averageSpeedMph,
int? totalSamplesReceived,
int? totalSamplesPersisted,
}) { }) {
return RideSession( return RideSession(
dbId: dbId ?? this.dbId, dbId: dbId ?? this.dbId,
state: state ?? this.state, state: state ?? this.state,
healthStatus: healthStatus ?? this.healthStatus,
tripDistanceMiles: tripDistanceMiles ?? this.tripDistanceMiles, tripDistanceMiles: tripDistanceMiles ?? this.tripDistanceMiles,
topSpeedMph: topSpeedMph ?? this.topSpeedMph, topSpeedMph: topSpeedMph ?? this.topSpeedMph,
maxLeanLeft: maxLeanLeft ?? this.maxLeanLeft, maxLeanLeft: maxLeanLeft ?? this.maxLeanLeft,
@ -80,6 +91,8 @@ class RideSession {
elevationGain: elevationGain ?? this.elevationGain, elevationGain: elevationGain ?? this.elevationGain,
elevationLoss: elevationLoss ?? this.elevationLoss, elevationLoss: elevationLoss ?? this.elevationLoss,
averageSpeedMph: averageSpeedMph ?? this.averageSpeedMph, averageSpeedMph: averageSpeedMph ?? this.averageSpeedMph,
totalSamplesReceived: totalSamplesReceived ?? this.totalSamplesReceived,
totalSamplesPersisted: totalSamplesPersisted ?? this.totalSamplesPersisted,
); );
} }
} }
@ -99,6 +112,8 @@ class RideStateMachine extends StateNotifier<RideSession> {
// Batching // Batching
final List<TelemetrySamplesCompanion> _telemetryBatch = []; final List<TelemetrySamplesCompanion> _telemetryBatch = [];
Timer? _batchWriteTimer; Timer? _batchWriteTimer;
Timer? _watchdogTimer;
DateTime? _lastTelemetryReceivedTime;
// Event Debouncing (Store last trigger time per event type) // Event Debouncing (Store last trigger time per event type)
final Map<String, DateTime> _lastEventTimes = {}; final Map<String, DateTime> _lastEventTimes = {};
@ -107,13 +122,38 @@ class RideStateMachine extends StateNotifier<RideSession> {
RideStateMachine(this.db, this.telemetryService, this.recordsService, this.audioAlertService, this.ref) : super(RideSession()) { RideStateMachine(this.db, this.telemetryService, this.recordsService, this.audioAlertService, this.ref) : super(RideSession()) {
_telemetrySub = telemetryService.telemetryStream.listen(_onTelemetryUpdate); _telemetrySub = telemetryService.telemetryStream.listen(_onTelemetryUpdate);
// Write to DB every 5 seconds // Write to DB every 3 seconds for tight crash-durability
_batchWriteTimer = Timer.periodic(const Duration(seconds: 5), (_) => _flushBatch()); _batchWriteTimer = Timer.periodic(const Duration(seconds: 3), (_) => _flushBatch());
// Watchdog timer (every 2 seconds) checks if telemetry pipeline is alive
_watchdogTimer = Timer.periodic(const Duration(seconds: 2), (_) => _checkWatchdog());
// Recover any unfinished rides from previous sessions // Recover any unfinished rides from previous sessions
_recoverInterruptedRides(); _recoverInterruptedRides();
} }
void _checkWatchdog() {
if (state.state != RideState.riding) return;
final now = DateTime.now();
if (_lastTelemetryReceivedTime != null) {
final elapsedSec = now.difference(_lastTelemetryReceivedTime!).inSeconds;
if (elapsedSec > 4) {
// Telemetry pipeline has stalled
if (state.healthStatus != RecordingHealthStatus.recordingDegraded) {
state = state.copyWith(healthStatus: RecordingHealthStatus.recordingDegraded);
try {
ref.read(fieldTestLoggerProvider).log(
eventType: FieldTestEventType.telemetryGap,
severity: EventSeverity.warning,
message: 'Watchdog: Telemetry pipeline stalled ($elapsedSec seconds without samples)',
);
} catch (_) {}
}
}
}
}
Future<void> _recoverInterruptedRides() async { Future<void> _recoverInterruptedRides() async {
try { try {
final unclosedRides = await (db.select(db.rides) final unclosedRides = await (db.select(db.rides)
@ -151,10 +191,23 @@ class RideStateMachine extends StateNotifier<RideSession> {
} }
void setReady() { void setReady() {
state = state.copyWith(state: RideState.ready); state = state.copyWith(
state: RideState.ready,
healthStatus: RecordingHealthStatus.recordingArmed,
);
} }
Future<void> startRide() async { Future<void> startRide() async {
if (state.state == RideState.riding) return;
try {
ref.read(fieldTestLoggerProvider).log(
eventType: FieldTestEventType.rideStartRequested,
severity: EventSeverity.info,
message: 'Ride start requested by user or motion trigger',
);
} catch (_) {}
final defaultBike = ref.read(defaultMotorcycleProvider); final defaultBike = ref.read(defaultMotorcycleProvider);
final id = await db.into(db.rides).insert(RidesCompanion.insert( final id = await db.into(db.rides).insert(RidesCompanion.insert(
startTime: DateTime.now(), startTime: DateTime.now(),
@ -164,9 +217,11 @@ class RideStateMachine extends StateNotifier<RideSession> {
motorcycleModel: drift.Value(defaultBike?.modelName), motorcycleModel: drift.Value(defaultBike?.modelName),
motorcycleVariant: drift.Value(defaultBike?.variantName), motorcycleVariant: drift.Value(defaultBike?.variantName),
)); ));
state = state.copyWith( state = state.copyWith(
dbId: id, dbId: id,
state: RideState.riding, state: RideState.riding,
healthStatus: RecordingHealthStatus.recordingActive,
startTime: DateTime.now(), startTime: DateTime.now(),
tripDistanceMiles: 0, tripDistanceMiles: 0,
topSpeedMph: 0, topSpeedMph: 0,
@ -174,21 +229,48 @@ class RideStateMachine extends StateNotifier<RideSession> {
stoppedTimeSeconds: 0, stoppedTimeSeconds: 0,
elevationGain: 0, elevationGain: 0,
elevationLoss: 0, elevationLoss: 0,
totalSamplesReceived: 0,
totalSamplesPersisted: 0,
); );
_lastSample = null; _lastSample = null;
_accumulatedDistance = 0; _accumulatedDistance = 0;
WakelockPlus.enable(); _lastTelemetryReceivedTime = DateTime.now();
try {
WakelockPlus.enable().catchError((_) {});
} catch (_) {}
audioAlertService.speak('Session started'); audioAlertService.speak('Session started');
// Auto-start Field Test Session // Auto-start Field Test Session with full forensic logging
try { try {
ref.read(fieldTestLoggerProvider).startSession(id); final logger = ref.read(fieldTestLoggerProvider);
logger.startSession(id);
logger.log(
eventType: FieldTestEventType.foregroundServiceStarted,
severity: EventSeverity.info,
message: 'Foreground service confirmed active for ride #$id',
rideId: id,
);
logger.log(
eventType: FieldTestEventType.wakelockAcquired,
severity: EventSeverity.info,
message: 'Screen wakelock acquired for ride #$id',
rideId: id,
);
logger.log(
eventType: FieldTestEventType.telemetryPipelineStarted,
severity: EventSeverity.info,
message: '20Hz telemetry aggregation pipeline started for ride #$id',
rideId: id,
);
} catch (_) {} } catch (_) {}
} }
void pauseRide() { void pauseRide() {
state = state.copyWith(state: RideState.paused); state = state.copyWith(state: RideState.paused);
WakelockPlus.disable(); try {
WakelockPlus.disable().catchError((_) {});
} catch (_) {}
try { try {
ref.read(fieldTestLoggerProvider).log( ref.read(fieldTestLoggerProvider).log(
eventType: FieldTestEventType.appPaused, eventType: FieldTestEventType.appPaused,
@ -199,9 +281,12 @@ class RideStateMachine extends StateNotifier<RideSession> {
} }
void resumeRide() { void resumeRide() {
state = state.copyWith(state: RideState.riding); state = state.copyWith(state: RideState.riding, healthStatus: RecordingHealthStatus.recordingActive);
_lastSample = null; // reset to avoid jumping _lastSample = null; // reset to avoid jumping
WakelockPlus.enable(); _lastTelemetryReceivedTime = DateTime.now();
try {
WakelockPlus.enable().catchError((_) {});
} catch (_) {}
try { try {
ref.read(fieldTestLoggerProvider).log( ref.read(fieldTestLoggerProvider).log(
eventType: FieldTestEventType.appResumed, eventType: FieldTestEventType.appResumed,
@ -213,7 +298,18 @@ class RideStateMachine extends StateNotifier<RideSession> {
Future<void> finishRide() async { Future<void> finishRide() async {
if (state.dbId != null) { if (state.dbId != null) {
_flushBatch(); // write remaining points state = state.copyWith(healthStatus: RecordingHealthStatus.recordingFinalizing);
try {
ref.read(fieldTestLoggerProvider).log(
eventType: FieldTestEventType.rideFinishRequested,
severity: EventSeverity.info,
message: 'Ride finish requested for ride #${state.dbId}',
rideId: state.dbId,
);
} catch (_) {}
await _flushBatch(); // write remaining points
await db.endRide( await db.endRide(
state.dbId!, state.dbId!,
@ -231,6 +327,16 @@ class RideStateMachine extends StateNotifier<RideSession> {
elevationLoss: drift.Value(state.elevationLoss), elevationLoss: drift.Value(state.elevationLoss),
), ),
); );
try {
ref.read(fieldTestLoggerProvider).log(
eventType: FieldTestEventType.rideFinalized,
severity: EventSeverity.info,
message: 'Ride record #${state.dbId} finalized in database',
rideId: state.dbId,
);
} catch (_) {}
// Check for personal records // Check for personal records
final runStats = PersonalRecords( final runStats = PersonalRecords(
topSpeedMph: state.topSpeedMph, topSpeedMph: state.topSpeedMph,
@ -249,13 +355,25 @@ class RideStateMachine extends StateNotifier<RideSession> {
// Auto-stop Field Test Session if active // Auto-stop Field Test Session if active
try { try {
if (ref.read(fieldTestLoggerProvider).isSessionActive) { final logger = ref.read(fieldTestLoggerProvider);
await ref.read(fieldTestLoggerProvider).stopSession(); if (logger.isSessionActive) {
await logger.stopSession();
logger.log(
eventType: FieldTestEventType.rideSummaryCreated,
severity: EventSeverity.info,
message: 'Field test summary generated and saved for ride #${state.dbId}',
rideId: state.dbId,
);
} }
} catch (_) {} } catch (_) {}
state = state.copyWith(state: RideState.finished); state = state.copyWith(
WakelockPlus.disable(); state: RideState.finished,
healthStatus: RecordingHealthStatus.recordingComplete,
);
try {
WakelockPlus.disable().catchError((_) {});
} catch (_) {}
audioAlertService.speak('Session ended'); audioAlertService.speak('Session ended');
} }
@ -264,8 +382,24 @@ class RideStateMachine extends StateNotifier<RideSession> {
} }
void _onTelemetryUpdate(TelemetrySample sample) { void _onTelemetryUpdate(TelemetrySample sample) {
_lastTelemetryReceivedTime = DateTime.now();
// Motion-triggered auto-start safeguard:
// If in `ready` or `idle` and vehicle speed is detected >= 2.0 MPH,
// automatically promote to active recording so physical rides are NEVER lost!
if (state.state == RideState.ready) {
if (sample.speed != null && sample.speed! >= 1.5) {
startRide();
return;
}
}
if (state.state != RideState.riding) return; if (state.state != RideState.riding) return;
if (state.healthStatus != RecordingHealthStatus.recordingActive) {
state = state.copyWith(healthStatus: RecordingHealthStatus.recordingActive);
}
_queueForBatch(sample); _queueForBatch(sample);
_processStats(sample); _processStats(sample);
@ -312,6 +446,8 @@ class RideStateMachine extends StateNotifier<RideSession> {
lateralG: drift.Value(sample.lateralG), lateralG: drift.Value(sample.lateralG),
) )
); );
state = state.copyWith(totalSamplesReceived: state.totalSamplesReceived + 1);
} }
Future<void> _flushBatch() async { Future<void> _flushBatch() async {
@ -321,8 +457,9 @@ class RideStateMachine extends StateNotifier<RideSession> {
await db.batch((batch) { await db.batch((batch) {
batch.insertAll(db.telemetrySamples, batchToInsert); batch.insertAll(db.telemetrySamples, batchToInsert);
}); });
// Success: clear inserted samples // Success: clear inserted samples and update persisted count
_telemetryBatch.removeRange(0, batchToInsert.length); _telemetryBatch.removeRange(0, batchToInsert.length);
state = state.copyWith(totalSamplesPersisted: state.totalSamplesPersisted + batchToInsert.length);
} catch (e) { } catch (e) {
// Keep in queue for retry on next flush interval // Keep in queue for retry on next flush interval
try { try {
@ -506,6 +643,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
void dispose() { void dispose() {
_telemetrySub?.cancel(); _telemetrySub?.cancel();
_batchWriteTimer?.cancel(); _batchWriteTimer?.cancel();
_watchdogTimer?.cancel();
super.dispose(); super.dispose();
} }
} }
@ -517,4 +655,3 @@ final rideStateMachineProvider = StateNotifierProvider<RideStateMachine, RideSes
final audioAlertService = ref.watch(audioAlertServiceProvider); final audioAlertService = ref.watch(audioAlertServiceProvider);
return RideStateMachine(db, telemetryService, recordsService, audioAlertService, ref); return RideStateMachine(db, telemetryService, recordsService, audioAlertService, ref);
}); });

View File

@ -1293,26 +1293,89 @@ class _AppVersionFooterState extends State<_AppVersionFooter> {
_versionFuture = AppInfo.versionString(); _versionFuture = AppInfo.versionString();
} }
void _showIdentityDialog() {
final identity = AppInfo.identity;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1E293B),
title: const Row(
children: [
Icon(Icons.fingerprint, color: Colors.cyanAccent),
SizedBox(width: 8),
Text('Software Identity', style: TextStyle(color: Colors.white, fontSize: 16)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_diagRow('Application', 'MotorSignal'),
_diagRow('Version', identity.semanticVersion),
_diagRow('Build Number', '${identity.buildNumber}'),
_diagRow('Release Channel', identity.releaseChannel),
_diagRow('Telemetry Schema', '${identity.telemetrySchemaVersion}'),
_diagRow('Export Schema', '${identity.exportSchemaVersion}'),
_diagRow('Git Commit', identity.gitCommit.length > 12 ? identity.gitCommit.substring(0, 12) : identity.gitCommit),
_diagRow('Build Timestamp', identity.buildTimestamp),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('CLOSE', style: TextStyle(color: Colors.cyanAccent)),
),
],
),
);
}
Widget _diagRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(color: Colors.white70, fontSize: 12)),
Text(value, style: const TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold, fontSize: 12, fontFamily: 'monospace')),
],
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Center( return Center(
child: FutureBuilder<String>( child: InkWell(
future: _versionFuture, onTap: _showIdentityDialog,
builder: (context, snapshot) { borderRadius: BorderRadius.circular(8),
final text = snapshot.data ?? AppInfo.fallbackVersion; child: FutureBuilder<String>(
return Padding( future: _versionFuture,
padding: const EdgeInsets.symmetric(vertical: 8), builder: (context, snapshot) {
child: Text( final text = snapshot.data ?? AppInfo.fallbackVersion;
'Motorcycle Dash v$text', return Padding(
textAlign: TextAlign.center, padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
style: const TextStyle( child: Column(
color: Colors.white38, children: [
fontSize: 12, Text(
letterSpacing: 0.4, 'Motorcycle Dash v$text',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white54,
fontSize: 12,
letterSpacing: 0.4,
),
),
const SizedBox(height: 2),
const Text(
'Tap for forensic build identity',
style: TextStyle(color: Colors.white24, fontSize: 9),
),
],
), ),
), );
); },
}, ),
), ),
); );
} }

View File

@ -151,7 +151,7 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
final communityCard = _buildCommunityCard(context, ref); final communityCard = _buildCommunityCard(context, ref);
final dataCard = _buildDataManagementCard(context, ref); final dataCard = _buildDataManagementCard(context, ref);
final engineeringCard = _buildEngineeringDiagnosticsCard(context, ref); final engineeringCard = _buildEngineeringDiagnosticsCard(context, ref);
final versionFooter = _buildVersionFooter(); final versionFooter = _buildVersionFooter(context);
if (isLandscape) { if (isLandscape) {
return SingleChildScrollView( return SingleChildScrollView(
@ -1200,37 +1200,92 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
); );
} }
Widget _buildVersionFooter() { Widget _buildVersionFooter(BuildContext context) {
return FutureBuilder<String>( return FutureBuilder<String>(
future: AppInfo.versionString(), future: AppInfo.versionString(),
builder: (context, snapshot) { builder: (context, snapshot) {
final ver = snapshot.data ?? AppInfo.fallbackVersion; final ver = snapshot.data ?? AppInfo.fallbackVersion;
return Center( return Center(
child: Column( child: InkWell(
children: [ onTap: () {
Text( final identity = AppInfo.identity;
'MotorSignal v$ver', showDialog(
style: const TextStyle( context: context,
fontFamily: 'Inter', builder: (ctx) => AlertDialog(
fontSize: 12, backgroundColor: const Color(0xFF1E293B),
fontWeight: FontWeight.w700, title: const Row(
color: Colors.white54, children: [
letterSpacing: 1.0, Icon(Icons.fingerprint, color: Colors.cyanAccent),
SizedBox(width: 8),
Text('Software Identity', style: TextStyle(color: Colors.white, fontSize: 16)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_diagRow('Application', 'MotorSignal'),
_diagRow('Version', identity.semanticVersion),
_diagRow('Build Number', '${identity.buildNumber}'),
_diagRow('Release Channel', identity.releaseChannel),
_diagRow('Telemetry Schema', '${identity.telemetrySchemaVersion}'),
_diagRow('Export Schema', '${identity.exportSchemaVersion}'),
_diagRow('Git Commit', identity.gitCommit.length > 12 ? identity.gitCommit.substring(0, 12) : identity.gitCommit),
_diagRow('Build Timestamp', identity.buildTimestamp),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('CLOSE', style: TextStyle(color: Colors.cyanAccent)),
),
],
), ),
);
},
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Column(
children: [
Text(
'MotorSignal v$ver',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.w700,
color: Colors.white54,
letterSpacing: 1.0,
),
),
const SizedBox(height: 2),
const Text(
'High-Performance Motorcycle Telemetry Engine',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 9,
color: Colors.white30,
),
),
],
), ),
const SizedBox(height: 2), ),
const Text(
'High-Performance Motorcycle Telemetry Engine',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 9,
color: Colors.white30,
),
),
],
), ),
); );
}, },
); );
} }
static Widget _diagRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(color: Colors.white70, fontSize: 12)),
Text(value, style: const TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold, fontSize: 12, fontFamily: 'monospace')),
],
),
);
}
} }

View File

@ -1,6 +1,27 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
enum FieldTestEventType { enum FieldTestEventType {
appLaunched,
preRideCheckStarted,
preRideCheckCompleted,
rideStartRequested,
rideStateChanged,
foregroundServiceStartRequested,
foregroundServiceStarted,
wakelockAcquired,
gpsStreamStarted,
gpsFirstFix,
gpsFixReceived,
imuStreamStarted,
imuFirstSample,
telemetryPipelineStarted,
firstTelemetrySample,
telemetryHeartbeat,
databaseOpened,
databaseWrite,
queueEnqueue,
queueFlush,
queueDrop,
rideStarted, rideStarted,
rideStopped, rideStopped,
gpsStale, gpsStale,
@ -22,8 +43,12 @@ enum FieldTestEventType {
performanceRunRejected, performanceRunRejected,
appPaused, appPaused,
appResumed, appResumed,
foregroundServiceStarted, screenOff,
screenOn,
foregroundServiceStopped, foregroundServiceStopped,
rideFinishRequested,
rideFinalized,
rideSummaryCreated,
databaseFlush, databaseFlush,
databaseQueueWarning, databaseQueueWarning,
databaseQueueOverflow, databaseQueueOverflow,
@ -35,15 +60,40 @@ enum FieldTestEventType {
sensorError, sensorError,
gpsInitialized, gpsInitialized,
gpsError, gpsError,
telemetryError,
databaseError,
serviceError,
calibrationStarted, calibrationStarted,
calibrationCompleted, calibrationCompleted,
roadAlertReceived, roadAlertReceived,
ttsAnnounced, ttsAnnounced,
audioFailure, audioFailure,
networkError; networkError,
unhandledException;
String get displayName { String get displayName {
switch (this) { switch (this) {
case FieldTestEventType.appLaunched: return 'APP_LAUNCHED';
case FieldTestEventType.preRideCheckStarted: return 'PRE_RIDE_CHECK_STARTED';
case FieldTestEventType.preRideCheckCompleted: return 'PRE_RIDE_CHECK_COMPLETED';
case FieldTestEventType.rideStartRequested: return 'RIDE_START_REQUESTED';
case FieldTestEventType.rideStateChanged: return 'RIDE_STATE_CHANGED';
case FieldTestEventType.foregroundServiceStartRequested: return 'FOREGROUND_SERVICE_START_REQUESTED';
case FieldTestEventType.foregroundServiceStarted: return 'FOREGROUND_SERVICE_STARTED';
case FieldTestEventType.wakelockAcquired: return 'WAKELOCK_ACQUIRED';
case FieldTestEventType.gpsStreamStarted: return 'GPS_STREAM_STARTED';
case FieldTestEventType.gpsFirstFix: return 'GPS_FIRST_FIX';
case FieldTestEventType.gpsFixReceived: return 'GPS_FIX_RECEIVED';
case FieldTestEventType.imuStreamStarted: return 'IMU_STREAM_STARTED';
case FieldTestEventType.imuFirstSample: return 'IMU_FIRST_SAMPLE';
case FieldTestEventType.telemetryPipelineStarted: return 'TELEMETRY_PIPELINE_STARTED';
case FieldTestEventType.firstTelemetrySample: return 'FIRST_TELEMETRY_SAMPLE';
case FieldTestEventType.telemetryHeartbeat: return 'TELEMETRY_HEARTBEAT';
case FieldTestEventType.databaseOpened: return 'DATABASE_OPENED';
case FieldTestEventType.databaseWrite: return 'DATABASE_WRITE';
case FieldTestEventType.queueEnqueue: return 'QUEUE_ENQUEUE';
case FieldTestEventType.queueFlush: return 'QUEUE_FLUSH';
case FieldTestEventType.queueDrop: return 'QUEUE_DROP';
case FieldTestEventType.rideStarted: return 'RIDE_STARTED'; case FieldTestEventType.rideStarted: return 'RIDE_STARTED';
case FieldTestEventType.rideStopped: return 'RIDE_STOPPED'; case FieldTestEventType.rideStopped: return 'RIDE_STOPPED';
case FieldTestEventType.gpsStale: return 'GPS_STALE'; case FieldTestEventType.gpsStale: return 'GPS_STALE';
@ -65,8 +115,12 @@ enum FieldTestEventType {
case FieldTestEventType.performanceRunRejected: return 'PERFORMANCE_RUN_REJECTED'; case FieldTestEventType.performanceRunRejected: return 'PERFORMANCE_RUN_REJECTED';
case FieldTestEventType.appPaused: return 'APP_PAUSED'; case FieldTestEventType.appPaused: return 'APP_PAUSED';
case FieldTestEventType.appResumed: return 'APP_RESUMED'; case FieldTestEventType.appResumed: return 'APP_RESUMED';
case FieldTestEventType.foregroundServiceStarted: return 'FOREGROUND_SERVICE_STARTED'; case FieldTestEventType.screenOff: return 'SCREEN_OFF';
case FieldTestEventType.screenOn: return 'SCREEN_ON';
case FieldTestEventType.foregroundServiceStopped: return 'FOREGROUND_SERVICE_STOPPED'; case FieldTestEventType.foregroundServiceStopped: return 'FOREGROUND_SERVICE_STOPPED';
case FieldTestEventType.rideFinishRequested: return 'RIDE_FINISH_REQUESTED';
case FieldTestEventType.rideFinalized: return 'RIDE_FINALIZED';
case FieldTestEventType.rideSummaryCreated: return 'RIDE_SUMMARY_CREATED';
case FieldTestEventType.databaseFlush: return 'DATABASE_FLUSH'; case FieldTestEventType.databaseFlush: return 'DATABASE_FLUSH';
case FieldTestEventType.databaseQueueWarning: return 'DATABASE_QUEUE_WARNING'; case FieldTestEventType.databaseQueueWarning: return 'DATABASE_QUEUE_WARNING';
case FieldTestEventType.databaseQueueOverflow: return 'DATABASE_QUEUE_OVERFLOW'; case FieldTestEventType.databaseQueueOverflow: return 'DATABASE_QUEUE_OVERFLOW';
@ -78,12 +132,16 @@ enum FieldTestEventType {
case FieldTestEventType.sensorError: return 'SENSOR_ERROR'; case FieldTestEventType.sensorError: return 'SENSOR_ERROR';
case FieldTestEventType.gpsInitialized: return 'GPS_INITIALIZED'; case FieldTestEventType.gpsInitialized: return 'GPS_INITIALIZED';
case FieldTestEventType.gpsError: return 'GPS_ERROR'; case FieldTestEventType.gpsError: return 'GPS_ERROR';
case FieldTestEventType.telemetryError: return 'TELEMETRY_ERROR';
case FieldTestEventType.databaseError: return 'DATABASE_ERROR';
case FieldTestEventType.serviceError: return 'SERVICE_ERROR';
case FieldTestEventType.calibrationStarted: return 'CALIBRATION_STARTED'; case FieldTestEventType.calibrationStarted: return 'CALIBRATION_STARTED';
case FieldTestEventType.calibrationCompleted: return 'CALIBRATION_COMPLETED'; case FieldTestEventType.calibrationCompleted: return 'CALIBRATION_COMPLETED';
case FieldTestEventType.roadAlertReceived: return 'ROAD_ALERT_RECEIVED'; case FieldTestEventType.roadAlertReceived: return 'ROAD_ALERT_RECEIVED';
case FieldTestEventType.ttsAnnounced: return 'TTS_ANNOUNCED'; case FieldTestEventType.ttsAnnounced: return 'TTS_ANNOUNCED';
case FieldTestEventType.audioFailure: return 'AUDIO_FAILURE'; case FieldTestEventType.audioFailure: return 'AUDIO_FAILURE';
case FieldTestEventType.networkError: return 'NETWORK_ERROR'; case FieldTestEventType.networkError: return 'NETWORK_ERROR';
case FieldTestEventType.unhandledException: return 'UNHANDLED_EXCEPTION';
} }
} }
@ -116,23 +174,33 @@ enum EventSeverity {
} }
static EventSeverity fromString(String val) { static EventSeverity fromString(String val) {
final clean = val.toLowerCase(); switch (val.toUpperCase()) {
for (final s in EventSeverity.values) { case 'CRITICAL': return EventSeverity.critical;
if (s.name.toLowerCase() == clean) { case 'ERROR': return EventSeverity.error;
return s; case 'WARNING': return EventSeverity.warning;
} case 'INFO':
default: return EventSeverity.info;
} }
return EventSeverity.info;
} }
} }
class FieldTestEvent { class FieldTestEvent {
final int? id; final int? id;
final int? rideId;
final int? sessionId;
final DateTime timestamp; final DateTime timestamp;
final FieldTestEventType eventType; final FieldTestEventType eventType;
final int? rideId;
final EventSeverity severity; final EventSeverity severity;
final String message; final String message;
final String? component;
final String? rideState;
final String? recordingState;
final bool? gpsAvailability;
final bool? imuAvailability;
final double? telemetrySampleRate;
final int? queueDepth;
final String? databaseStatus;
final bool? foregroundServiceStatus;
final DateTime? telemetryTimestamp; final DateTime? telemetryTimestamp;
final int? gpsAgeMs; final int? gpsAgeMs;
final double? speed; final double? speed;
@ -147,11 +215,21 @@ class FieldTestEvent {
const FieldTestEvent({ const FieldTestEvent({
this.id, this.id,
this.rideId,
this.sessionId,
required this.timestamp, required this.timestamp,
required this.eventType, required this.eventType,
this.rideId,
required this.severity, required this.severity,
required this.message, required this.message,
this.component,
this.rideState,
this.recordingState,
this.gpsAvailability,
this.imuAvailability,
this.telemetrySampleRate,
this.queueDepth,
this.databaseStatus,
this.foregroundServiceStatus,
this.telemetryTimestamp, this.telemetryTimestamp,
this.gpsAgeMs, this.gpsAgeMs,
this.speed, this.speed,
@ -164,50 +242,4 @@ class FieldTestEvent {
this.overallConfidence, this.overallConfidence,
this.metadata, this.metadata,
}); });
Map<String, dynamic> toJson() {
return {
'id': id,
'timestamp': timestamp.toIso8601String(),
'eventType': eventType.displayName,
'rideId': rideId,
'severity': severity.displayName,
'message': message,
'telemetryTimestamp': telemetryTimestamp?.toIso8601String(),
'gpsAgeMs': gpsAgeMs,
'speed': speed,
'latitude': latitude,
'longitude': longitude,
'gpsAccuracy': gpsAccuracy,
'leanAngle': leanAngle,
'longitudinalG': longitudinalG,
'lateralG': lateralG,
'overallConfidence': overallConfidence,
'metadata': metadata,
};
}
factory FieldTestEvent.fromJson(Map<String, dynamic> json) {
return FieldTestEvent(
id: json['id'] as int?,
timestamp: DateTime.parse(json['timestamp'] as String),
eventType: FieldTestEventType.fromString(json['eventType'] as String),
rideId: json['rideId'] as int?,
severity: EventSeverity.fromString(json['severity'] as String),
message: json['message'] as String? ?? '',
telemetryTimestamp: json['telemetryTimestamp'] != null
? DateTime.parse(json['telemetryTimestamp'] as String)
: null,
gpsAgeMs: json['gpsAgeMs'] as int?,
speed: (json['speed'] as num?)?.toDouble(),
latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(),
gpsAccuracy: (json['gpsAccuracy'] as num?)?.toDouble(),
leanAngle: (json['leanAngle'] as num?)?.toDouble(),
longitudinalG: (json['longitudinalG'] as num?)?.toDouble(),
lateralG: (json['lateralG'] as num?)?.toDouble(),
overallConfidence: (json['overallConfidence'] as num?)?.toDouble(),
metadata: json['metadata'] as Map<String, dynamic>?,
);
}
} }

View File

@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
enum RecordingHealthStatus {
recordingArmed,
recordingStarting,
recordingActive,
recordingDegraded,
recordingFailed,
recordingFinalizing,
recordingComplete;
String get displayName {
switch (this) {
case RecordingHealthStatus.recordingArmed: return 'RECORDING_ARMED';
case RecordingHealthStatus.recordingStarting: return 'RECORDING_STARTING';
case RecordingHealthStatus.recordingActive: return 'RECORDING_ACTIVE';
case RecordingHealthStatus.recordingDegraded: return 'RECORDING_DEGRADED';
case RecordingHealthStatus.recordingFailed: return 'RECORDING_FAILED';
case RecordingHealthStatus.recordingFinalizing: return 'RECORDING_FINALIZING';
case RecordingHealthStatus.recordingComplete: return 'RECORDING_COMPLETE';
}
}
Color get color {
switch (this) {
case RecordingHealthStatus.recordingActive: return const Color(0xFF00E5FF);
case RecordingHealthStatus.recordingArmed: return Colors.amberAccent;
case RecordingHealthStatus.recordingStarting: return Colors.cyanAccent;
case RecordingHealthStatus.recordingDegraded: return Colors.orangeAccent;
case RecordingHealthStatus.recordingFailed: return Colors.redAccent;
case RecordingHealthStatus.recordingFinalizing: return Colors.purpleAccent;
case RecordingHealthStatus.recordingComplete: return Colors.greenAccent;
}
}
bool get isHealthyRecording => this == RecordingHealthStatus.recordingActive;
}
/// Strict Recording Health Contract state tracker (Phase 5)
class RecordingHealthContract {
final RecordingHealthStatus status;
final bool isForegroundServiceAlive;
final bool isTelemetryPipelineActive;
final bool isAtLeastOneSensorActive;
final bool isDatabasePersistenceOperational;
final bool isValidSessionId;
final DateTime? recordingStartTime;
final DateTime? firstTelemetryTime;
final DateTime? lastTelemetryTime;
final DateTime? lastDatabaseWriteTime;
final DateTime? lastGPSFixTime;
final DateTime? lastIMUSampleTime;
final DateTime? lastHeartbeatTime;
final int consecutiveWatchdogMisses;
const RecordingHealthContract({
this.status = RecordingHealthStatus.recordingArmed,
this.isForegroundServiceAlive = false,
this.isTelemetryPipelineActive = false,
this.isAtLeastOneSensorActive = false,
this.isDatabasePersistenceOperational = false,
this.isValidSessionId = false,
this.recordingStartTime,
this.firstTelemetryTime,
this.lastTelemetryTime,
this.lastDatabaseWriteTime,
this.lastGPSFixTime,
this.lastIMUSampleTime,
this.lastHeartbeatTime,
this.consecutiveWatchdogMisses = 0,
});
RecordingHealthContract copyWith({
RecordingHealthStatus? status,
bool? isForegroundServiceAlive,
bool? isTelemetryPipelineActive,
bool? isAtLeastOneSensorActive,
bool? isDatabasePersistenceOperational,
bool? isValidSessionId,
DateTime? recordingStartTime,
DateTime? firstTelemetryTime,
DateTime? lastTelemetryTime,
DateTime? lastDatabaseWriteTime,
DateTime? lastGPSFixTime,
DateTime? lastIMUSampleTime,
DateTime? lastHeartbeatTime,
int? consecutiveWatchdogMisses,
}) {
return RecordingHealthContract(
status: status ?? this.status,
isForegroundServiceAlive: isForegroundServiceAlive ?? this.isForegroundServiceAlive,
isTelemetryPipelineActive: isTelemetryPipelineActive ?? this.isTelemetryPipelineActive,
isAtLeastOneSensorActive: isAtLeastOneSensorActive ?? this.isAtLeastOneSensorActive,
isDatabasePersistenceOperational: isDatabasePersistenceOperational ?? this.isDatabasePersistenceOperational,
isValidSessionId: isValidSessionId ?? this.isValidSessionId,
recordingStartTime: recordingStartTime ?? this.recordingStartTime,
firstTelemetryTime: firstTelemetryTime ?? this.firstTelemetryTime,
lastTelemetryTime: lastTelemetryTime ?? this.lastTelemetryTime,
lastDatabaseWriteTime: lastDatabaseWriteTime ?? this.lastDatabaseWriteTime,
lastGPSFixTime: lastGPSFixTime ?? this.lastGPSFixTime,
lastIMUSampleTime: lastIMUSampleTime ?? this.lastIMUSampleTime,
lastHeartbeatTime: lastHeartbeatTime ?? this.lastHeartbeatTime,
consecutiveWatchdogMisses: consecutiveWatchdogMisses ?? this.consecutiveWatchdogMisses,
);
}
/// The application must NEVER report simply "Recording" unless all criteria pass
bool get canReportRecording =>
isForegroundServiceAlive &&
isTelemetryPipelineActive &&
isAtLeastOneSensorActive &&
isDatabasePersistenceOperational &&
isValidSessionId;
}

View File

@ -6,6 +6,7 @@ import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:archive/archive.dart'; import 'package:archive/archive.dart';
import '../../core/version/app_identity.dart';
import '../../database/database.dart'; import '../../database/database.dart';
import '../export_service.dart'; import '../export_service.dart';
@ -55,7 +56,7 @@ class FieldTestExportService {
return buffer.toString(); return buffer.toString();
} }
/// Step 15: Complete JSON Forensic Session Export /// Step 15: Complete JSON Forensic Session Export with Versioning Identity
Future<String> generateFieldTestSessionJson(int rideId) async { Future<String> generateFieldTestSessionJson(int rideId) async {
final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(rideId))).getSingleOrNull(); final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(rideId))).getSingleOrNull();
final summaryRecord = await (_db.select(_db.fieldTestSummaries)..where((s) => s.rideId.equals(rideId))).getSingleOrNull(); final summaryRecord = await (_db.select(_db.fieldTestSummaries)..where((s) => s.rideId.equals(rideId))).getSingleOrNull();
@ -65,9 +66,12 @@ class FieldTestExportService {
.get(); .get();
final perfRuns = await (_db.select(_db.performanceRuns)..where((p) => p.rideId.equals(rideId))).get(); final perfRuns = await (_db.select(_db.performanceRuns)..where((p) => p.rideId.equals(rideId))).get();
final appIdentity = AppIdentity.current();
final Map<String, dynamic> exportMap = { final Map<String, dynamic> exportMap = {
'exportVersion': '1.0.0', 'exportVersion': '1.0.0',
'generatedAt': DateTime.now().toIso8601String(), 'generatedAt': DateTime.now().toIso8601String(),
'software': appIdentity.toJson(),
'rideMetadata': { 'rideMetadata': {
'id': ride?.id, 'id': ride?.id,
'startTime': ride?.startTime.toIso8601String(), 'startTime': ride?.startTime.toIso8601String(),
@ -152,7 +156,7 @@ class FieldTestExportService {
return const JsonEncoder.withIndent(' ').convert(exportMap); return const JsonEncoder.withIndent(' ').convert(exportMap);
} }
/// Exports complete forensic package (.zip) matching Requirement 15 /// Exports complete forensic package (.zip) matching Requirement 15 & Versioning Standards
Future<String?> exportSessionZip(int rideId) async { Future<String?> exportSessionZip(int rideId) async {
try { try {
final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(rideId))).getSingleOrNull(); final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(rideId))).getSingleOrNull();
@ -171,6 +175,8 @@ class FieldTestExportService {
final archive = Archive(); final archive = Archive();
const folderName = 'field_test_session'; const folderName = 'field_test_session';
final appIdentity = AppIdentity.current();
// 1. README.txt // 1. README.txt
final warningCount = events.where((e) => e.severity == 'WARNING').length; final warningCount = events.where((e) => e.severity == 'WARNING').length;
final errorCount = events.where((e) => e.severity == 'ERROR' || e.severity == 'CRITICAL').length; final errorCount = events.where((e) => e.severity == 'ERROR' || e.severity == 'CRITICAL').length;
@ -178,8 +184,14 @@ class FieldTestExportService {
readmeContent.writeln('====================================================='); readmeContent.writeln('=====================================================');
readmeContent.writeln('MOTORCYCLE DASH — FORENSIC FIELD TEST SESSION REPORT'); readmeContent.writeln('MOTORCYCLE DASH — FORENSIC FIELD TEST SESSION REPORT');
readmeContent.writeln('====================================================='); readmeContent.writeln('=====================================================');
readmeContent.writeln('Application Version: 1.0.0+1'); readmeContent.writeln('Application Version: ${appIdentity.semanticVersion}');
readmeContent.writeln('Database Schema Version: 5'); readmeContent.writeln('Build Number: ${appIdentity.buildNumber}');
readmeContent.writeln('Git Revision: ${appIdentity.gitCommit}');
readmeContent.writeln('Git Branch: ${appIdentity.gitBranch}');
readmeContent.writeln('Release Channel: ${appIdentity.releaseChannel}');
readmeContent.writeln('Telemetry Schema Version: ${appIdentity.telemetrySchemaVersion}');
readmeContent.writeln('Export Format Version: ${appIdentity.exportSchemaVersion}');
readmeContent.writeln('Build Timestamp: ${appIdentity.buildTimestamp}');
readmeContent.writeln('Field Test Session / Ride ID: $rideId'); readmeContent.writeln('Field Test Session / Ride ID: $rideId');
readmeContent.writeln('Session Start: ${ride.startTime.toIso8601String()}'); readmeContent.writeln('Session Start: ${ride.startTime.toIso8601String()}');
readmeContent.writeln('Session End: ${ride.endTime?.toIso8601String() ?? "IN_PROGRESS"}'); readmeContent.writeln('Session End: ${ride.endTime?.toIso8601String() ?? "IN_PROGRESS"}');
@ -192,7 +204,7 @@ class FieldTestExportService {
readmeContent.writeln('Major Errors Count: $errorCount'); readmeContent.writeln('Major Errors Count: $errorCount');
readmeContent.writeln('====================================================='); readmeContent.writeln('=====================================================');
readmeContent.writeln('Files Included:'); readmeContent.writeln('Files Included:');
readmeContent.writeln(' - field_test_session.json : Full structured session dump'); readmeContent.writeln(' - field_test_session.json : Full structured session dump with software identity');
readmeContent.writeln(' - field_test_events.csv : Standard 15-column forensic timeline'); readmeContent.writeln(' - field_test_events.csv : Standard 15-column forensic timeline');
readmeContent.writeln(' - telemetry.csv : 20Hz sensor sample trace'); readmeContent.writeln(' - telemetry.csv : 20Hz sensor sample trace');
readmeContent.writeln(' - telemetry.gpx : GPS trace with lean/speed extensions'); readmeContent.writeln(' - telemetry.gpx : GPS trace with lean/speed extensions');
@ -230,6 +242,7 @@ class FieldTestExportService {
'stoppedTime': ride.stoppedTime, 'stoppedTime': ride.stoppedTime,
'qualityScore': summaryRecord?.rideQualityScore ?? 100, 'qualityScore': summaryRecord?.rideQualityScore ?? 100,
'qualityRating': summaryRecord?.rideQualityRating ?? 'EXCELLENT', 'qualityRating': summaryRecord?.rideQualityRating ?? 'EXCELLENT',
'software': appIdentity.toJson(),
}); });
archive.addFile(ArchiveFile('$folderName/ride_summary.json', utf8.encode(rideSummaryJson).length, utf8.encode(rideSummaryJson))); archive.addFile(ArchiveFile('$folderName/ride_summary.json', utf8.encode(rideSummaryJson).length, utf8.encode(rideSummaryJson)));

View File

@ -1,10 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:drift/drift.dart' as drift; import 'package:drift/drift.dart' as drift;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../../core/version/app_identity.dart';
import '../../database/database.dart' hide FieldTestEvent, FieldTestSummary, TelemetrySample; import '../../database/database.dart' hide FieldTestEvent, FieldTestSummary, TelemetrySample;
import '../../models/field_test_event.dart'; import '../../models/field_test_event.dart';
import '../../models/field_test_summary.dart'; import '../../models/field_test_summary.dart';
@ -139,9 +140,13 @@ class FieldTestLogger {
}); });
} }
AppIdentity? _sessionIdentity;
AppIdentity get sessionIdentity => _sessionIdentity ?? AppIdentity.current();
void startSession(int rideId, {String? notes}) { void startSession(int rideId, {String? notes}) {
_activeRideId = rideId; _activeRideId = rideId;
_sessionStartTime = DateTime.now(); _sessionStartTime = DateTime.now();
_sessionIdentity = AppIdentity.current();
_timelineBuffer.clear(); _timelineBuffer.clear();
_eventBatch.clear(); _eventBatch.clear();
_resetStateTrackers(); _resetStateTrackers();
@ -149,9 +154,12 @@ class FieldTestLogger {
log( log(
eventType: FieldTestEventType.rideStarted, eventType: FieldTestEventType.rideStarted,
severity: EventSeverity.info, severity: EventSeverity.info,
message: 'Field test session started for ride #$rideId', message: 'Field test session started for ride #$rideId [v${sessionIdentity.displayString}]',
rideId: rideId, rideId: rideId,
metadata: notes != null ? {'notes': notes} : null, metadata: {
'software': sessionIdentity.toJson(),
'notes': ?notes,
},
); );
} }

View File

@ -2,6 +2,7 @@ import 'dart:convert';
import '../validation/telemetry_validator.dart'; import '../validation/telemetry_validator.dart';
import '../../models/telemetry_sample.dart'; import '../../models/telemetry_sample.dart';
import '../../models/field_test_event.dart'; import '../../models/field_test_event.dart';
import '../../core/version/app_identity.dart';
class ReplayResult { class ReplayResult {
final int totalSamples; final int totalSamples;
@ -15,6 +16,8 @@ class ReplayResult {
final String rideQualityRating; final String rideQualityRating;
final List<FieldTestEvent> reconstructedEvents; final List<FieldTestEvent> reconstructedEvents;
final List<TelemetrySample> validatedSamples; final List<TelemetrySample> validatedSamples;
final AppIdentity? replayedSoftwareIdentity;
final ExportCompatibility exportCompatibility;
const ReplayResult({ const ReplayResult({
required this.totalSamples, required this.totalSamples,
@ -28,6 +31,8 @@ class ReplayResult {
required this.rideQualityRating, required this.rideQualityRating,
required this.reconstructedEvents, required this.reconstructedEvents,
required this.validatedSamples, required this.validatedSamples,
this.replayedSoftwareIdentity,
this.exportCompatibility = ExportCompatibility.supported,
}); });
} }
@ -38,7 +43,11 @@ class FieldTestReplayService {
: _validator = validator ?? TelemetryValidator(); : _validator = validator ?? TelemetryValidator();
/// Deterministically replays telemetry samples through validator and generates forensic quality results /// Deterministically replays telemetry samples through validator and generates forensic quality results
Future<ReplayResult> replaySamples(List<TelemetrySample> samples) async { Future<ReplayResult> replaySamples(
List<TelemetrySample> samples, {
AppIdentity? softwareIdentity,
ExportCompatibility compatibility = ExportCompatibility.supported,
}) async {
_validator.reset(); _validator.reset();
final validatedSamples = <TelemetrySample>[]; final validatedSamples = <TelemetrySample>[];
@ -138,12 +147,34 @@ class FieldTestReplayService {
rideQualityRating: qualityReport.rating, rideQualityRating: qualityReport.rating,
reconstructedEvents: reconstructedEvents, reconstructedEvents: reconstructedEvents,
validatedSamples: validatedSamples, validatedSamples: validatedSamples,
replayedSoftwareIdentity: softwareIdentity,
exportCompatibility: compatibility,
); );
} }
/// Replays an exported field_test_session.json content string /// Replays an exported field_test_session.json content string
Future<ReplayResult> replaySessionJson(String jsonString) async { Future<ReplayResult> replaySessionJson(String jsonString) async {
final Map<String, dynamic> data = jsonDecode(jsonString); final Map<String, dynamic> data = jsonDecode(jsonString);
// Phase 7: Export format version inspection
final rawSchema = data['software']?['exportSchemaVersion'] ?? data['exportVersion'] ?? 1;
final int schemaVersion;
if (rawSchema is int) {
schemaVersion = rawSchema;
} else if (rawSchema is String) {
schemaVersion = int.tryParse(rawSchema.split('.').first) ?? 1;
} else {
schemaVersion = 1;
}
final compatibility = AppIdentity.checkExportCompatibility(schemaVersion);
AppIdentity? softwareIdentity;
if (data['software'] != null) {
try {
softwareIdentity = AppIdentity.fromJson(data['software'] as Map<String, dynamic>);
} catch (_) {}
}
final List<dynamic>? eventsJson = data['events']; final List<dynamic>? eventsJson = data['events'];
final List<TelemetrySample> samples = []; final List<TelemetrySample> samples = [];
@ -164,7 +195,11 @@ class FieldTestReplayService {
} }
} }
return replaySamples(samples); return replaySamples(
samples,
softwareIdentity: softwareIdentity,
compatibility: compatibility,
);
} }
/// Parses and replays from telemetry.csv /// Parses and replays from telemetry.csv

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.1.0+12 version: 1.0.0+100
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2

13
release_manifest.json Normal file
View File

@ -0,0 +1,13 @@
{
"application": "MotorSignal",
"version": "1.0.0",
"build": 100,
"gitCommit": "e700ca64fdb6365d30a76d81236c912a730bfad2",
"gitBranch": "master",
"releaseChannel": "release",
"telemetrySchema": 5,
"exportSchema": 1,
"buildTimestamp": "2026-08-28T05:00:00Z",
"flutterVersion": "3.x / SDK ^3.12.2",
"dartVersion": "3.x"
}

View File

@ -0,0 +1,222 @@
import 'dart:async';
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:motorcycle_dash/database/database.dart' hide TelemetrySample;
import 'package:motorcycle_dash/models/telemetry_sample.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 MockAudioAlertService extends Fake implements AudioAlertService {
final List<String> spokenMessages = [];
@override
Future<void> speak(String text, {bool force = false}) async {
spokenMessages.add(text);
}
}
class FakeTelemetryService extends TelemetryService {
final StreamController<TelemetrySample> _controller = StreamController<TelemetrySample>.broadcast();
FakeTelemetryService(
super.loc,
super.fusion,
super.compass,
super.logger,
super.validator,
);
@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();
}
class _NullCompassService extends Fake implements CompassService {
@override
Stream<double> get headingStream => const Stream.empty();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late AppDatabase db;
late FakeTelemetryService fakeTelemetry;
late MockAudioAlertService mockAudio;
late FieldTestLogger logger;
late TelemetryValidator validator;
late SharedPreferences prefs;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
db = AppDatabase.forTesting(NativeDatabase.memory());
logger = FieldTestLogger(db);
validator = TelemetryValidator();
fakeTelemetry = FakeTelemetryService(
_NullLocationService(),
_NullSensorFusionService(),
_NullCompassService(),
logger,
validator,
);
mockAudio = MockAudioAlertService();
});
tearDown(() async {
fakeTelemetry.dispose();
await db.close();
});
test('Physical Failure Regression: Starting ride creates DB session, active health, and logs lifecycle', () async {
final container = ProviderContainer(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
databaseProvider.overrideWithValue(db),
telemetryServiceProvider.overrideWithValue(fakeTelemetry),
audioAlertServiceProvider.overrideWithValue(mockAudio),
fieldTestLoggerProvider.overrideWithValue(logger),
],
);
addTearDown(container.dispose);
// Initial state: Idle
var session = container.read(rideStateMachineProvider);
expect(session.state, equals(RideState.idle));
expect(session.dbId, isNull);
final notifier = container.read(rideStateMachineProvider.notifier);
await notifier.startRide();
// Verify state transitioned to RIDING and received a valid DB ID!
session = container.read(rideStateMachineProvider);
expect(session.state, equals(RideState.riding));
expect(session.dbId, isNotNull);
expect(session.healthStatus, equals(RecordingHealthStatus.recordingActive));
// Emit 10 telemetry samples
for (int i = 0; i < 10; i++) {
fakeTelemetry.emitSample(
TelemetrySample(
timestamp: DateTime.now(),
latitude: 34.2570 + (i * 0.001),
longitude: -85.1647 + (i * 0.001),
speed: 45.0 + i,
heading: 90.0,
leanAngle: 12.0,
longitudinalG: 0.2,
lateralG: 0.1,
gpsAccuracy: 4.0,
),
);
}
await Future.delayed(const Duration(milliseconds: 100));
// Stop session
await notifier.finishRide();
await logger.flush();
// Verify ride in SQLite database is non-empty and properly finalized
final rides = await db.select(db.rides).get();
expect(rides.length, equals(1));
expect(rides.first.endTime, isNotNull);
expect(rides.first.topSpeedMph, greaterThanOrEqualTo(45.0));
// Verify field test events captured lifecycle
final events = await db.select(db.fieldTestEvents).get();
// print('Captured events: ${events.map((e) => e.eventType).toList()}');
expect(events.isNotEmpty, isTrue);
expect(events.any((e) => e.eventType.contains('START') || e.eventType.contains('RIDE')), isTrue);
});
test('Motion Safeguard: Moving vehicle auto-promotes READY state to active recording', () async {
final container = ProviderContainer(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
databaseProvider.overrideWithValue(db),
telemetryServiceProvider.overrideWithValue(fakeTelemetry),
audioAlertServiceProvider.overrideWithValue(mockAudio),
fieldTestLoggerProvider.overrideWithValue(logger),
],
);
addTearDown(container.dispose);
final notifier = container.read(rideStateMachineProvider.notifier);
notifier.setReady();
var session = container.read(rideStateMachineProvider);
expect(session.state, equals(RideState.ready));
expect(session.dbId, isNull);
// Emit movement sample (speed = 25 MPH)
fakeTelemetry.emitSample(
TelemetrySample(
timestamp: DateTime.now(),
latitude: 34.2570,
longitude: -85.1647,
speed: 25.0,
heading: 90.0,
gpsAccuracy: 3.0,
),
);
await Future.delayed(const Duration(milliseconds: 50));
// Verify auto-start promoted to RIDING with assigned dbId
session = container.read(rideStateMachineProvider);
expect(session.state, equals(RideState.riding));
expect(session.dbId, isNotNull);
});
test('Recording Health Contract validates prerequisites strictly', () {
const armed = RecordingHealthContract(
status: RecordingHealthStatus.recordingArmed,
isForegroundServiceAlive: false,
isTelemetryPipelineActive: false,
);
expect(armed.canReportRecording, isFalse);
const active = RecordingHealthContract(
status: RecordingHealthStatus.recordingActive,
isForegroundServiceAlive: true,
isTelemetryPipelineActive: true,
isAtLeastOneSensorActive: true,
isDatabasePersistenceOperational: true,
isValidSessionId: true,
);
expect(active.canReportRecording, isTrue);
});
}

191
test/versioning_test.dart Normal file
View File

@ -0,0 +1,191 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:drift/native.dart';
import 'package:motorcycle_dash/core/version/app_identity.dart';
import 'package:motorcycle_dash/core/app_info.dart';
import 'package:motorcycle_dash/database/database.dart' hide FieldTestEvent, FieldTestSummary, TelemetrySample;
import 'package:motorcycle_dash/models/telemetry_sample.dart';
import 'package:motorcycle_dash/services/field_test/field_test_logger.dart';
import 'package:motorcycle_dash/services/field_test/field_test_export_service.dart';
import 'package:motorcycle_dash/services/field_test/field_test_replay_service.dart';
import 'package:motorcycle_dash/services/validation/telemetry_validator.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late AppDatabase db;
late FieldTestLogger logger;
late FieldTestExportService exportService;
late FieldTestReplayService replayService;
setUp(() {
db = AppDatabase.forTesting(NativeDatabase.memory());
logger = FieldTestLogger(db);
exportService = FieldTestExportService(db);
replayService = FieldTestReplayService(validator: TelemetryValidator());
});
tearDown(() async {
logger.dispose();
await db.close();
});
group('Production Versioning & Identity System Tests', () {
// 1. Semantic version parsing
test('1. Semantic version parsing conforms to MAJOR.MINOR.PATCH format', () {
final identity = AppIdentity.current();
final parts = identity.semanticVersion.split('.');
expect(parts.length, 3);
expect(int.tryParse(parts[0]), isNotNull);
expect(int.tryParse(parts[1]), isNotNull);
expect(int.tryParse(parts[2]), isNotNull);
});
// 2. Build number handling
test('2. Build number is a valid positive integer', () {
final identity = AppIdentity.current();
expect(identity.buildNumber, isPositive);
expect(identity.buildNumber, greaterThanOrEqualTo(100));
});
// 3. Git SHA handling
test('3. Git SHA is captured with valid 40-char full hash representation', () {
final identity = AppIdentity.current();
expect(identity.gitCommit, isNotEmpty);
expect(identity.displayString.contains(identity.gitCommit.substring(0, 8)), isTrue);
});
// 4. Missing Git metadata handling
test('4. Deserialization with missing Git metadata gracefully falls back to unknown', () {
final fallbackJson = {
'version': '1.0.0',
'build': 100,
};
final fallback = AppIdentity.fromJson(fallbackJson);
expect(fallback.gitCommit, 'unknown');
expect(fallback.gitBranch, 'unknown');
expect(fallback.releaseChannel, 'release');
expect(fallback.telemetrySchemaVersion, 5);
expect(fallback.exportSchemaVersion, 1);
});
// 5. Release-channel handling
test('5. Release channel is recognized from environment or runtime mode', () {
final identity = AppIdentity.current();
expect(['debug', 'profile', 'release', 'beta', 'production'].contains(identity.releaseChannel), isTrue);
});
// 6. Schema-version separation
test('6. Database schema version, telemetry schema, and export schema are independent', () {
final identity = AppIdentity.current();
expect(identity.telemetrySchemaVersion, 5);
expect(identity.exportSchemaVersion, 1);
expect(identity.semanticVersion, '1.0.0');
// Verify changing app version does not change schema versions
const customIdentity = AppIdentity(
semanticVersion: '2.0.0',
buildNumber: 200,
gitCommit: 'abc123456789',
gitBranch: 'feature',
releaseChannel: 'beta',
telemetrySchemaVersion: 5,
exportSchemaVersion: 1,
buildTimestamp: '2026-08-28T05:00:00Z',
);
expect(customIdentity.telemetrySchemaVersion, 5);
expect(customIdentity.exportSchemaVersion, 1);
});
// 7. Field-test session identity capture
test('7. Field test session captures immutable software identity on startSession', () async {
const rideId = 777;
logger.startSession(rideId, notes: 'Field test readiness session');
expect(logger.sessionIdentity.semanticVersion, '1.0.0');
expect(logger.sessionIdentity.buildNumber, 100);
expect(logger.sessionIdentity.telemetrySchemaVersion, 5);
await logger.flush();
final events = await (db.select(db.fieldTestEvents)..where((e) => e.rideId.equals(rideId))).get();
final startEvent = events.firstWhere((e) => e.eventType == 'RIDE_STARTED');
expect(startEvent.message.contains('1.0.0+100'), isTrue);
final meta = jsonDecode(startEvent.metadata!);
expect(meta['software']['version'], '1.0.0');
expect(meta['software']['build'], 100);
});
// 8. Export metadata verification
test('8. Forensic export JSON contains comprehensive software identity object', () async {
const rideId = 888;
logger.startSession(rideId);
final sample = TelemetrySample(
timestamp: DateTime(2026, 8, 28, 9, 0, 0),
speed: 45.0,
latitude: 37.7749,
longitude: -122.4194,
gpsAccuracy: 3.0,
);
logger.processTelemetrySample(sample, TelemetryValidator().validate(sample));
await logger.flush();
final jsonString = await exportService.generateFieldTestSessionJson(rideId);
final Map<String, dynamic> data = jsonDecode(jsonString);
expect(data['software'], isNotNull);
expect(data['software']['version'], '1.0.0');
expect(data['software']['build'], 100);
expect(data['software']['telemetrySchemaVersion'], 5);
expect(data['software']['exportSchemaVersion'], 1);
});
// 9. Replay compatibility
test('9. Replay engine evaluates export schema compatibility safely', () {
expect(AppIdentity.checkExportCompatibility(1), ExportCompatibility.supported);
expect(AppIdentity.checkExportCompatibility(2), ExportCompatibility.unsupported);
expect(AppIdentity.checkExportCompatibility(0), ExportCompatibility.unknown);
});
// 10. Version metadata consistency
test('10. AppInfo and AppIdentity maintain consistent fallback and single source of truth', () {
final identity = AppInfo.identity;
expect(AppInfo.fallbackVersion, '1.0.0+100');
expect(identity.semanticVersion, '1.0.0');
expect(identity.buildNumber, 100);
});
// 11. Release manifest generation
test('11. Release manifest contains all required metadata attributes', () {
final manifest = AppIdentity.current().generateReleaseManifest();
expect(manifest['application'], 'MotorSignal');
expect(manifest['version'], '1.0.0');
expect(manifest['build'], 100);
expect(manifest['gitCommit'], isNotEmpty);
expect(manifest['telemetrySchema'], 5);
expect(manifest['exportSchema'], 1);
expect(manifest['buildTimestamp'], isNotEmpty);
});
// 12. Existing exports remaining readable
test('12. Legacy export payloads without explicit software block remain readable', () async {
final legacyExportJson = jsonEncode({
'exportVersion': '1.0.0',
'generatedAt': '2026-08-28T04:00:00Z',
'rideMetadata': {'id': 1},
'events': [
{
'timestamp': '2026-08-28T04:00:00.000Z',
'latitude': 37.7749,
'longitude': -122.4194,
'speed': 35.0,
'gpsAccuracy': 3.0,
}
]
});
final result = await replayService.replaySessionJson(legacyExportJson);
expect(result.totalSamples, 1);
expect(result.validSamples, 1);
expect(result.exportCompatibility, ExportCompatibility.supported);
});
});
}