From e0a80527e85d54e76568b4a8d42483d9a13e0d59 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 28 Aug 2026 12:05:37 -0400 Subject: [PATCH] fix(forensic): resolve physical recording failure & add strict lifecycle health contract --- lib/core/app_info.dart | 44 ++-- lib/core/version/app_identity.dart | 115 +++++++++ .../evolution_landscape_dashboard.dart | 67 +++++- .../widgets/evolution_portrait_dashboard.dart | 6 +- lib/features/rides/ride_state_machine.dart | 169 +++++++++++-- lib/features/settings/settings_screen.dart | 95 ++++++-- .../widgets/evolution_settings_view.dart | 99 ++++++-- lib/models/field_test_event.dart | 146 +++++++----- lib/models/recording_health_status.dart | 114 +++++++++ .../field_test/field_test_export_service.dart | 23 +- .../field_test/field_test_logger.dart | 14 +- .../field_test/field_test_replay_service.dart | 39 ++- pubspec.yaml | 2 +- release_manifest.json | 13 + test/forensic_lifecycle_audit_test.dart | 222 ++++++++++++++++++ test/versioning_test.dart | 191 +++++++++++++++ 16 files changed, 1193 insertions(+), 166 deletions(-) create mode 100644 lib/core/version/app_identity.dart create mode 100644 lib/models/recording_health_status.dart create mode 100644 release_manifest.json create mode 100644 test/forensic_lifecycle_audit_test.dart create mode 100644 test/versioning_test.dart diff --git a/lib/core/app_info.dart b/lib/core/app_info.dart index 87a63b2..faf5a34 100644 --- a/lib/core/app_info.dart +++ b/lib/core/app_info.dart @@ -1,44 +1,28 @@ -import 'package:flutter/foundation.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. -/// -/// **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 { AppInfo._(); - /// Hard-coded fallback. Must mirror the `version:` field in `pubspec.yaml`. - /// Format: `MAJOR.MINOR.PATCH+BUILD`. - static const String fallbackVersion = '1.1.0+12'; + /// Hard-coded fallback mirroring pubspec.yaml and AppIdentity + static const String fallbackVersion = '1.0.0+100'; - /// Cached result so we only hit the platform once. static PackageInfo? _cached; - /// Asynchronously loads the package info, caching the result. static Future _load() async { return _cached ??= await PackageInfo.fromPlatform(); } /// 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 versionString() async { try { final info = await _load(); - final name = info.version.isEmpty ? '0.0.0' : info.version; - final build = info.buildNumber.isEmpty ? '0' : info.buildNumber; + final name = info.version.isEmpty ? AppIdentity.current().semanticVersion : info.version; + final build = info.buildNumber.isEmpty ? '${AppIdentity.current().buildNumber}' : info.buildNumber; return '$name+$build'; - } catch (e, st) { - debugPrint('AppInfo.versionString() fell back to constant: $e\n$st'); - return fallbackVersion; + } catch (_) { + return AppIdentity.current().displayString; } } @@ -46,20 +30,22 @@ class AppInfo { static Future versionName() async { try { final info = await _load(); - return info.version.isEmpty ? '0.0.0' : info.version; + return info.version.isEmpty ? AppIdentity.current().semanticVersion : info.version; } catch (_) { - // Extract the part before the '+' from the fallback. - return fallbackVersion.split('+').first; + return AppIdentity.current().semanticVersion; } } - /// The monotonically increasing `+BUILD` portion (e.g. `1`). + /// The monotonically increasing `+BUILD` portion (e.g. `100`). static Future buildNumber() async { try { final info = await _load(); - return info.buildNumber.isEmpty ? '0' : info.buildNumber; + return info.buildNumber.isEmpty ? '${AppIdentity.current().buildNumber}' : info.buildNumber; } catch (_) { - return fallbackVersion.split('+').last; + return '${AppIdentity.current().buildNumber}'; } } + + /// The immutable application identity + static AppIdentity get identity => AppIdentity.current(); } diff --git a/lib/core/version/app_identity.dart b/lib/core/version/app_identity.dart new file mode 100644 index 0000000..27a8b12 --- /dev/null +++ b/lib/core/version/app_identity.dart @@ -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 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 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 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; + } + } +} diff --git a/lib/features/dashboard/widgets/evolution_landscape_dashboard.dart b/lib/features/dashboard/widgets/evolution_landscape_dashboard.dart index 0197670..bfbadfc 100644 --- a/lib/features/dashboard/widgets/evolution_landscape_dashboard.dart +++ b/lib/features/dashboard/widgets/evolution_landscape_dashboard.dart @@ -87,19 +87,62 @@ class EvolutionLandscapeDashboard extends ConsumerWidget { border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2), ), clipBehavior: Clip.antiAlias, - child: LayoutBuilder( - builder: (context, constraints) { - return SizedBox( - width: constraints.maxWidth, - height: constraints.maxHeight, - child: UnifiedMapView( - key: ValueKey('evo_map_${constraints.maxWidth.toInt()}x${constraints.maxHeight.toInt()}'), - center: AppLatLng(centerLat, centerLng), - zoom: 17.0, - markers: mapMarkers, + child: Stack( + children: [ + LayoutBuilder( + builder: (context, constraints) { + return SizedBox( + width: constraints.maxWidth, + height: constraints.maxHeight, + child: UnifiedMapView( + key: ValueKey('evo_map_${constraints.maxWidth.toInt()}x${constraints.maxHeight.toInt()}'), + 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, + ), + ), ), - ); - }, + ), + ], ), ), ), diff --git a/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart b/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart index 2cbc43b..a2b311d 100644 --- a/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart +++ b/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart @@ -28,7 +28,7 @@ class EvolutionPortraitDashboard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { 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 topSpeed = math.max(session.topSpeedMph, records.topSpeedMph); final totalG = math.sqrt(accelG * accelG + lateralG * lateralG); @@ -316,10 +316,10 @@ class EvolutionPortraitDashboard extends ConsumerWidget { height: 52, child: ElevatedButton( onPressed: () { - if (session.state == RideState.riding || session.state == RideState.ready) { + if (session.state == RideState.riding) { ref.read(rideStateMachineProvider.notifier).finishRide(); } else { - ref.read(rideStateMachineProvider.notifier).setReady(); + ref.read(rideStateMachineProvider.notifier).startRide(); } }, style: ElevatedButton.styleFrom( diff --git a/lib/features/rides/ride_state_machine.dart b/lib/features/rides/ride_state_machine.dart index 910c44c..96ae174 100644 --- a/lib/features/rides/ride_state_machine.dart +++ b/lib/features/rides/ride_state_machine.dart @@ -6,6 +6,7 @@ import 'package:drift/drift.dart' as drift; import 'package:geolocator/geolocator.dart'; import '../../models/telemetry_sample.dart'; import '../../models/field_test_event.dart'; +import '../../models/recording_health_status.dart'; import '../../services/telemetry_service.dart'; import '../../services/audio_alert_service.dart'; import '../records/records_service.dart'; @@ -17,6 +18,7 @@ enum RideState { idle, ready, riding, paused, finished } class RideSession { final int? dbId; final RideState state; + final RecordingHealthStatus healthStatus; final double tripDistanceMiles; final double topSpeedMph; final double maxLeanLeft; @@ -31,10 +33,13 @@ class RideSession { final double elevationGain; final double elevationLoss; final double averageSpeedMph; + final int totalSamplesReceived; + final int totalSamplesPersisted; RideSession({ this.dbId, this.state = RideState.idle, + this.healthStatus = RecordingHealthStatus.recordingArmed, this.tripDistanceMiles = 0.0, this.topSpeedMph = 0.0, this.maxLeanLeft = 0.0, @@ -47,11 +52,14 @@ class RideSession { this.elevationGain = 0.0, this.elevationLoss = 0.0, this.averageSpeedMph = 0.0, + this.totalSamplesReceived = 0, + this.totalSamplesPersisted = 0, }); RideSession copyWith({ int? dbId, RideState? state, + RecordingHealthStatus? healthStatus, double? tripDistanceMiles, double? topSpeedMph, double? maxLeanLeft, @@ -64,10 +72,13 @@ class RideSession { double? elevationGain, double? elevationLoss, double? averageSpeedMph, + int? totalSamplesReceived, + int? totalSamplesPersisted, }) { return RideSession( dbId: dbId ?? this.dbId, state: state ?? this.state, + healthStatus: healthStatus ?? this.healthStatus, tripDistanceMiles: tripDistanceMiles ?? this.tripDistanceMiles, topSpeedMph: topSpeedMph ?? this.topSpeedMph, maxLeanLeft: maxLeanLeft ?? this.maxLeanLeft, @@ -80,6 +91,8 @@ class RideSession { elevationGain: elevationGain ?? this.elevationGain, elevationLoss: elevationLoss ?? this.elevationLoss, averageSpeedMph: averageSpeedMph ?? this.averageSpeedMph, + totalSamplesReceived: totalSamplesReceived ?? this.totalSamplesReceived, + totalSamplesPersisted: totalSamplesPersisted ?? this.totalSamplesPersisted, ); } } @@ -99,6 +112,8 @@ class RideStateMachine extends StateNotifier { // Batching final List _telemetryBatch = []; Timer? _batchWriteTimer; + Timer? _watchdogTimer; + DateTime? _lastTelemetryReceivedTime; // Event Debouncing (Store last trigger time per event type) final Map _lastEventTimes = {}; @@ -107,13 +122,38 @@ class RideStateMachine extends StateNotifier { RideStateMachine(this.db, this.telemetryService, this.recordsService, this.audioAlertService, this.ref) : super(RideSession()) { _telemetrySub = telemetryService.telemetryStream.listen(_onTelemetryUpdate); - // Write to DB every 5 seconds - _batchWriteTimer = Timer.periodic(const Duration(seconds: 5), (_) => _flushBatch()); + // Write to DB every 3 seconds for tight crash-durability + _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 _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 _recoverInterruptedRides() async { try { final unclosedRides = await (db.select(db.rides) @@ -151,10 +191,23 @@ class RideStateMachine extends StateNotifier { } void setReady() { - state = state.copyWith(state: RideState.ready); + state = state.copyWith( + state: RideState.ready, + healthStatus: RecordingHealthStatus.recordingArmed, + ); } Future 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 id = await db.into(db.rides).insert(RidesCompanion.insert( startTime: DateTime.now(), @@ -164,9 +217,11 @@ class RideStateMachine extends StateNotifier { motorcycleModel: drift.Value(defaultBike?.modelName), motorcycleVariant: drift.Value(defaultBike?.variantName), )); + state = state.copyWith( dbId: id, state: RideState.riding, + healthStatus: RecordingHealthStatus.recordingActive, startTime: DateTime.now(), tripDistanceMiles: 0, topSpeedMph: 0, @@ -174,21 +229,48 @@ class RideStateMachine extends StateNotifier { stoppedTimeSeconds: 0, elevationGain: 0, elevationLoss: 0, + totalSamplesReceived: 0, + totalSamplesPersisted: 0, ); _lastSample = null; _accumulatedDistance = 0; - WakelockPlus.enable(); + _lastTelemetryReceivedTime = DateTime.now(); + + try { + WakelockPlus.enable().catchError((_) {}); + } catch (_) {} audioAlertService.speak('Session started'); - // Auto-start Field Test Session + // Auto-start Field Test Session with full forensic logging 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 (_) {} } void pauseRide() { state = state.copyWith(state: RideState.paused); - WakelockPlus.disable(); + try { + WakelockPlus.disable().catchError((_) {}); + } catch (_) {} try { ref.read(fieldTestLoggerProvider).log( eventType: FieldTestEventType.appPaused, @@ -199,9 +281,12 @@ class RideStateMachine extends StateNotifier { } void resumeRide() { - state = state.copyWith(state: RideState.riding); + state = state.copyWith(state: RideState.riding, healthStatus: RecordingHealthStatus.recordingActive); _lastSample = null; // reset to avoid jumping - WakelockPlus.enable(); + _lastTelemetryReceivedTime = DateTime.now(); + try { + WakelockPlus.enable().catchError((_) {}); + } catch (_) {} try { ref.read(fieldTestLoggerProvider).log( eventType: FieldTestEventType.appResumed, @@ -213,7 +298,18 @@ class RideStateMachine extends StateNotifier { Future finishRide() async { 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( state.dbId!, @@ -231,6 +327,16 @@ class RideStateMachine extends StateNotifier { 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 final runStats = PersonalRecords( topSpeedMph: state.topSpeedMph, @@ -249,13 +355,25 @@ class RideStateMachine extends StateNotifier { // Auto-stop Field Test Session if active try { - if (ref.read(fieldTestLoggerProvider).isSessionActive) { - await ref.read(fieldTestLoggerProvider).stopSession(); + final logger = ref.read(fieldTestLoggerProvider); + 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 (_) {} - state = state.copyWith(state: RideState.finished); - WakelockPlus.disable(); + state = state.copyWith( + state: RideState.finished, + healthStatus: RecordingHealthStatus.recordingComplete, + ); + try { + WakelockPlus.disable().catchError((_) {}); + } catch (_) {} audioAlertService.speak('Session ended'); } @@ -264,8 +382,24 @@ class RideStateMachine extends StateNotifier { } 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.healthStatus != RecordingHealthStatus.recordingActive) { + state = state.copyWith(healthStatus: RecordingHealthStatus.recordingActive); + } + _queueForBatch(sample); _processStats(sample); @@ -312,6 +446,8 @@ class RideStateMachine extends StateNotifier { lateralG: drift.Value(sample.lateralG), ) ); + + state = state.copyWith(totalSamplesReceived: state.totalSamplesReceived + 1); } Future _flushBatch() async { @@ -321,8 +457,9 @@ class RideStateMachine extends StateNotifier { await db.batch((batch) { batch.insertAll(db.telemetrySamples, batchToInsert); }); - // Success: clear inserted samples + // Success: clear inserted samples and update persisted count _telemetryBatch.removeRange(0, batchToInsert.length); + state = state.copyWith(totalSamplesPersisted: state.totalSamplesPersisted + batchToInsert.length); } catch (e) { // Keep in queue for retry on next flush interval try { @@ -506,6 +643,7 @@ class RideStateMachine extends StateNotifier { void dispose() { _telemetrySub?.cancel(); _batchWriteTimer?.cancel(); + _watchdogTimer?.cancel(); super.dispose(); } } @@ -517,4 +655,3 @@ final rideStateMachineProvider = StateNotifierProvider { _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 Widget build(BuildContext context) { return Center( - child: FutureBuilder( - future: _versionFuture, - builder: (context, snapshot) { - final text = snapshot.data ?? AppInfo.fallbackVersion; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Text( - 'Motorcycle Dash v$text', - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.white38, - fontSize: 12, - letterSpacing: 0.4, + child: InkWell( + onTap: _showIdentityDialog, + borderRadius: BorderRadius.circular(8), + child: FutureBuilder( + future: _versionFuture, + builder: (context, snapshot) { + final text = snapshot.data ?? AppInfo.fallbackVersion; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: Column( + children: [ + Text( + '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), + ), + ], ), - ), - ); - }, + ); + }, + ), ), ); } diff --git a/lib/features/settings/widgets/evolution_settings_view.dart b/lib/features/settings/widgets/evolution_settings_view.dart index 5101ad4..86f93d0 100644 --- a/lib/features/settings/widgets/evolution_settings_view.dart +++ b/lib/features/settings/widgets/evolution_settings_view.dart @@ -151,7 +151,7 @@ class _EvolutionSettingsViewState extends ConsumerState { final communityCard = _buildCommunityCard(context, ref); final dataCard = _buildDataManagementCard(context, ref); final engineeringCard = _buildEngineeringDiagnosticsCard(context, ref); - final versionFooter = _buildVersionFooter(); + final versionFooter = _buildVersionFooter(context); if (isLandscape) { return SingleChildScrollView( @@ -1200,37 +1200,92 @@ class _EvolutionSettingsViewState extends ConsumerState { ); } - Widget _buildVersionFooter() { + Widget _buildVersionFooter(BuildContext context) { return FutureBuilder( future: AppInfo.versionString(), builder: (context, snapshot) { final ver = snapshot.data ?? AppInfo.fallbackVersion; return Center( - child: Column( - children: [ - Text( - 'MotorSignal v$ver', - style: const TextStyle( - fontFamily: 'Inter', - fontSize: 12, - fontWeight: FontWeight.w700, - color: Colors.white54, - letterSpacing: 1.0, + child: InkWell( + onTap: () { + 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)), + ), + ], ), + ); + }, + 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')), + ], + ), + ); + } } \ No newline at end of file diff --git a/lib/models/field_test_event.dart b/lib/models/field_test_event.dart index 1f27a61..9e93d9b 100644 --- a/lib/models/field_test_event.dart +++ b/lib/models/field_test_event.dart @@ -1,6 +1,27 @@ import 'package:flutter/material.dart'; enum FieldTestEventType { + appLaunched, + preRideCheckStarted, + preRideCheckCompleted, + rideStartRequested, + rideStateChanged, + foregroundServiceStartRequested, + foregroundServiceStarted, + wakelockAcquired, + gpsStreamStarted, + gpsFirstFix, + gpsFixReceived, + imuStreamStarted, + imuFirstSample, + telemetryPipelineStarted, + firstTelemetrySample, + telemetryHeartbeat, + databaseOpened, + databaseWrite, + queueEnqueue, + queueFlush, + queueDrop, rideStarted, rideStopped, gpsStale, @@ -22,8 +43,12 @@ enum FieldTestEventType { performanceRunRejected, appPaused, appResumed, - foregroundServiceStarted, + screenOff, + screenOn, foregroundServiceStopped, + rideFinishRequested, + rideFinalized, + rideSummaryCreated, databaseFlush, databaseQueueWarning, databaseQueueOverflow, @@ -35,15 +60,40 @@ enum FieldTestEventType { sensorError, gpsInitialized, gpsError, + telemetryError, + databaseError, + serviceError, calibrationStarted, calibrationCompleted, roadAlertReceived, ttsAnnounced, audioFailure, - networkError; + networkError, + unhandledException; String get displayName { 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.rideStopped: return 'RIDE_STOPPED'; case FieldTestEventType.gpsStale: return 'GPS_STALE'; @@ -65,8 +115,12 @@ enum FieldTestEventType { case FieldTestEventType.performanceRunRejected: return 'PERFORMANCE_RUN_REJECTED'; case FieldTestEventType.appPaused: return 'APP_PAUSED'; 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.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.databaseQueueWarning: return 'DATABASE_QUEUE_WARNING'; case FieldTestEventType.databaseQueueOverflow: return 'DATABASE_QUEUE_OVERFLOW'; @@ -78,12 +132,16 @@ enum FieldTestEventType { case FieldTestEventType.sensorError: return 'SENSOR_ERROR'; case FieldTestEventType.gpsInitialized: return 'GPS_INITIALIZED'; 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.calibrationCompleted: return 'CALIBRATION_COMPLETED'; case FieldTestEventType.roadAlertReceived: return 'ROAD_ALERT_RECEIVED'; case FieldTestEventType.ttsAnnounced: return 'TTS_ANNOUNCED'; case FieldTestEventType.audioFailure: return 'AUDIO_FAILURE'; case FieldTestEventType.networkError: return 'NETWORK_ERROR'; + case FieldTestEventType.unhandledException: return 'UNHANDLED_EXCEPTION'; } } @@ -116,23 +174,33 @@ enum EventSeverity { } static EventSeverity fromString(String val) { - final clean = val.toLowerCase(); - for (final s in EventSeverity.values) { - if (s.name.toLowerCase() == clean) { - return s; - } + switch (val.toUpperCase()) { + case 'CRITICAL': return EventSeverity.critical; + case 'ERROR': return EventSeverity.error; + case 'WARNING': return EventSeverity.warning; + case 'INFO': + default: return EventSeverity.info; } - return EventSeverity.info; } } class FieldTestEvent { final int? id; + final int? rideId; + final int? sessionId; final DateTime timestamp; final FieldTestEventType eventType; - final int? rideId; final EventSeverity severity; 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 int? gpsAgeMs; final double? speed; @@ -147,11 +215,21 @@ class FieldTestEvent { const FieldTestEvent({ this.id, + this.rideId, + this.sessionId, required this.timestamp, required this.eventType, - this.rideId, required this.severity, required this.message, + this.component, + this.rideState, + this.recordingState, + this.gpsAvailability, + this.imuAvailability, + this.telemetrySampleRate, + this.queueDepth, + this.databaseStatus, + this.foregroundServiceStatus, this.telemetryTimestamp, this.gpsAgeMs, this.speed, @@ -164,50 +242,4 @@ class FieldTestEvent { this.overallConfidence, this.metadata, }); - - Map 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 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?, - ); - } } diff --git a/lib/models/recording_health_status.dart b/lib/models/recording_health_status.dart new file mode 100644 index 0000000..c1c89c0 --- /dev/null +++ b/lib/models/recording_health_status.dart @@ -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; +} diff --git a/lib/services/field_test/field_test_export_service.dart b/lib/services/field_test/field_test_export_service.dart index af028fb..20db919 100644 --- a/lib/services/field_test/field_test_export_service.dart +++ b/lib/services/field_test/field_test_export_service.dart @@ -6,6 +6,7 @@ import 'package:intl/intl.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:archive/archive.dart'; +import '../../core/version/app_identity.dart'; import '../../database/database.dart'; import '../export_service.dart'; @@ -55,7 +56,7 @@ class FieldTestExportService { return buffer.toString(); } - /// Step 15: Complete JSON Forensic Session Export + /// Step 15: Complete JSON Forensic Session Export with Versioning Identity Future generateFieldTestSessionJson(int rideId) async { 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(); @@ -65,9 +66,12 @@ class FieldTestExportService { .get(); final perfRuns = await (_db.select(_db.performanceRuns)..where((p) => p.rideId.equals(rideId))).get(); + final appIdentity = AppIdentity.current(); + final Map exportMap = { 'exportVersion': '1.0.0', 'generatedAt': DateTime.now().toIso8601String(), + 'software': appIdentity.toJson(), 'rideMetadata': { 'id': ride?.id, 'startTime': ride?.startTime.toIso8601String(), @@ -152,7 +156,7 @@ class FieldTestExportService { 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 exportSessionZip(int rideId) async { try { final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(rideId))).getSingleOrNull(); @@ -171,6 +175,8 @@ class FieldTestExportService { final archive = Archive(); const folderName = 'field_test_session'; + final appIdentity = AppIdentity.current(); + // 1. README.txt final warningCount = events.where((e) => e.severity == 'WARNING').length; final errorCount = events.where((e) => e.severity == 'ERROR' || e.severity == 'CRITICAL').length; @@ -178,8 +184,14 @@ class FieldTestExportService { readmeContent.writeln('====================================================='); readmeContent.writeln('MOTORCYCLE DASH — FORENSIC FIELD TEST SESSION REPORT'); readmeContent.writeln('====================================================='); - readmeContent.writeln('Application Version: 1.0.0+1'); - readmeContent.writeln('Database Schema Version: 5'); + readmeContent.writeln('Application Version: ${appIdentity.semanticVersion}'); + 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('Session Start: ${ride.startTime.toIso8601String()}'); readmeContent.writeln('Session End: ${ride.endTime?.toIso8601String() ?? "IN_PROGRESS"}'); @@ -192,7 +204,7 @@ class FieldTestExportService { readmeContent.writeln('Major Errors Count: $errorCount'); readmeContent.writeln('====================================================='); 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(' - telemetry.csv : 20Hz sensor sample trace'); readmeContent.writeln(' - telemetry.gpx : GPS trace with lean/speed extensions'); @@ -230,6 +242,7 @@ class FieldTestExportService { 'stoppedTime': ride.stoppedTime, 'qualityScore': summaryRecord?.rideQualityScore ?? 100, 'qualityRating': summaryRecord?.rideQualityRating ?? 'EXCELLENT', + 'software': appIdentity.toJson(), }); archive.addFile(ArchiveFile('$folderName/ride_summary.json', utf8.encode(rideSummaryJson).length, utf8.encode(rideSummaryJson))); diff --git a/lib/services/field_test/field_test_logger.dart b/lib/services/field_test/field_test_logger.dart index 1480dd2..b1be206 100644 --- a/lib/services/field_test/field_test_logger.dart +++ b/lib/services/field_test/field_test_logger.dart @@ -1,10 +1,11 @@ -import 'dart:async'; +import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:drift/drift.dart' as drift; import 'package:shared_preferences/shared_preferences.dart'; +import '../../core/version/app_identity.dart'; import '../../database/database.dart' hide FieldTestEvent, FieldTestSummary, TelemetrySample; import '../../models/field_test_event.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}) { _activeRideId = rideId; _sessionStartTime = DateTime.now(); + _sessionIdentity = AppIdentity.current(); _timelineBuffer.clear(); _eventBatch.clear(); _resetStateTrackers(); @@ -149,9 +154,12 @@ class FieldTestLogger { log( eventType: FieldTestEventType.rideStarted, severity: EventSeverity.info, - message: 'Field test session started for ride #$rideId', + message: 'Field test session started for ride #$rideId [v${sessionIdentity.displayString}]', rideId: rideId, - metadata: notes != null ? {'notes': notes} : null, + metadata: { + 'software': sessionIdentity.toJson(), + 'notes': ?notes, + }, ); } diff --git a/lib/services/field_test/field_test_replay_service.dart b/lib/services/field_test/field_test_replay_service.dart index 14355d6..4e9d70f 100644 --- a/lib/services/field_test/field_test_replay_service.dart +++ b/lib/services/field_test/field_test_replay_service.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import '../validation/telemetry_validator.dart'; import '../../models/telemetry_sample.dart'; import '../../models/field_test_event.dart'; +import '../../core/version/app_identity.dart'; class ReplayResult { final int totalSamples; @@ -15,6 +16,8 @@ class ReplayResult { final String rideQualityRating; final List reconstructedEvents; final List validatedSamples; + final AppIdentity? replayedSoftwareIdentity; + final ExportCompatibility exportCompatibility; const ReplayResult({ required this.totalSamples, @@ -28,6 +31,8 @@ class ReplayResult { required this.rideQualityRating, required this.reconstructedEvents, required this.validatedSamples, + this.replayedSoftwareIdentity, + this.exportCompatibility = ExportCompatibility.supported, }); } @@ -38,7 +43,11 @@ class FieldTestReplayService { : _validator = validator ?? TelemetryValidator(); /// Deterministically replays telemetry samples through validator and generates forensic quality results - Future replaySamples(List samples) async { + Future replaySamples( + List samples, { + AppIdentity? softwareIdentity, + ExportCompatibility compatibility = ExportCompatibility.supported, + }) async { _validator.reset(); final validatedSamples = []; @@ -138,12 +147,34 @@ class FieldTestReplayService { rideQualityRating: qualityReport.rating, reconstructedEvents: reconstructedEvents, validatedSamples: validatedSamples, + replayedSoftwareIdentity: softwareIdentity, + exportCompatibility: compatibility, ); } /// Replays an exported field_test_session.json content string Future replaySessionJson(String jsonString) async { final Map 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); + } catch (_) {} + } + final List? eventsJson = data['events']; final List samples = []; @@ -164,7 +195,11 @@ class FieldTestReplayService { } } - return replaySamples(samples); + return replaySamples( + samples, + softwareIdentity: softwareIdentity, + compatibility: compatibility, + ); } /// Parses and replays from telemetry.csv diff --git a/pubspec.yaml b/pubspec.yaml index 9e0b6f9..d168e26 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 # 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. -version: 1.1.0+12 +version: 1.0.0+100 environment: sdk: ^3.12.2 diff --git a/release_manifest.json b/release_manifest.json new file mode 100644 index 0000000..5c4a762 --- /dev/null +++ b/release_manifest.json @@ -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" +} diff --git a/test/forensic_lifecycle_audit_test.dart b/test/forensic_lifecycle_audit_test.dart new file mode 100644 index 0000000..61820ee --- /dev/null +++ b/test/forensic_lifecycle_audit_test.dart @@ -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 spokenMessages = []; + @override + Future speak(String text, {bool force = false}) async { + spokenMessages.add(text); + } +} + +class FakeTelemetryService extends TelemetryService { + final StreamController _controller = StreamController.broadcast(); + + FakeTelemetryService( + super.loc, + super.fusion, + super.compass, + super.logger, + super.validator, + ); + + @override + Stream 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 getFilteredPositionStream() => const Stream.empty(); +} + +class _NullSensorFusionService extends Fake implements SensorFusionService { + @override + Stream get leanAngleStream => const Stream.empty(); + @override + Stream get pitchAngleStream => const Stream.empty(); + @override + Stream get gForceStream => const Stream.empty(); +} + +class _NullCompassService extends Fake implements CompassService { + @override + Stream 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); + }); +} diff --git a/test/versioning_test.dart b/test/versioning_test.dart new file mode 100644 index 0000000..2fdb95f --- /dev/null +++ b/test/versioning_test.dart @@ -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 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); + }); + }); +}