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 '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<PackageInfo> _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<String> 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<String> 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<String> 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();
}

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,7 +87,9 @@ class EvolutionLandscapeDashboard extends ConsumerWidget {
border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2),
),
clipBehavior: Clip.antiAlias,
child: LayoutBuilder(
child: Stack(
children: [
LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: constraints.maxWidth,
@ -101,6 +103,47 @@ class EvolutionLandscapeDashboard extends ConsumerWidget {
);
},
),
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
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(

View File

@ -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<RideSession> {
// Batching
final List<TelemetrySamplesCompanion> _telemetryBatch = [];
Timer? _batchWriteTimer;
Timer? _watchdogTimer;
DateTime? _lastTelemetryReceivedTime;
// Event Debouncing (Store last trigger time per event type)
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()) {
_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<void> _recoverInterruptedRides() async {
try {
final unclosedRides = await (db.select(db.rides)
@ -151,10 +191,23 @@ class RideStateMachine extends StateNotifier<RideSession> {
}
void setReady() {
state = state.copyWith(state: RideState.ready);
state = state.copyWith(
state: RideState.ready,
healthStatus: RecordingHealthStatus.recordingArmed,
);
}
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 id = await db.into(db.rides).insert(RidesCompanion.insert(
startTime: DateTime.now(),
@ -164,9 +217,11 @@ class RideStateMachine extends StateNotifier<RideSession> {
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<RideSession> {
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<RideSession> {
}
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<RideSession> {
Future<void> 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<RideSession> {
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<RideSession> {
// 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<RideSession> {
}
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<RideSession> {
lateralG: drift.Value(sample.lateralG),
)
);
state = state.copyWith(totalSamplesReceived: state.totalSamplesReceived + 1);
}
Future<void> _flushBatch() async {
@ -321,8 +457,9 @@ class RideStateMachine extends StateNotifier<RideSession> {
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<RideSession> {
void dispose() {
_telemetrySub?.cancel();
_batchWriteTimer?.cancel();
_watchdogTimer?.cancel();
super.dispose();
}
}
@ -517,4 +655,3 @@ final rideStateMachineProvider = StateNotifierProvider<RideStateMachine, RideSes
final audioAlertService = ref.watch(audioAlertServiceProvider);
return RideStateMachine(db, telemetryService, recordsService, audioAlertService, ref);
});

View File

@ -1293,27 +1293,90 @@ class _AppVersionFooterState extends State<_AppVersionFooter> {
_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: InkWell(
onTap: _showIdentityDialog,
borderRadius: BorderRadius.circular(8),
child: FutureBuilder<String>(
future: _versionFuture,
builder: (context, snapshot) {
final text = snapshot.data ?? AppInfo.fallbackVersion;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Column(
children: [
Text(
'Motorcycle Dash v$text',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white38,
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 dataCard = _buildDataManagementCard(context, ref);
final engineeringCard = _buildEngineeringDiagnosticsCard(context, ref);
final versionFooter = _buildVersionFooter();
final versionFooter = _buildVersionFooter(context);
if (isLandscape) {
return SingleChildScrollView(
@ -1200,12 +1200,52 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
);
}
Widget _buildVersionFooter() {
Widget _buildVersionFooter(BuildContext context) {
return FutureBuilder<String>(
future: AppInfo.versionString(),
builder: (context, snapshot) {
final ver = snapshot.data ?? AppInfo.fallbackVersion;
return Center(
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(
@ -1229,8 +1269,23 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
),
],
),
),
),
);
},
);
}
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';
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<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: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<String> 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<String, dynamic> 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<String?> 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)));

View File

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

View File

@ -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<FieldTestEvent> reconstructedEvents;
final List<TelemetrySample> 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<ReplayResult> replaySamples(List<TelemetrySample> samples) async {
Future<ReplayResult> replaySamples(
List<TelemetrySample> samples, {
AppIdentity? softwareIdentity,
ExportCompatibility compatibility = ExportCompatibility.supported,
}) async {
_validator.reset();
final validatedSamples = <TelemetrySample>[];
@ -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<ReplayResult> replaySessionJson(String jsonString) async {
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<TelemetrySample> samples = [];
@ -164,7 +195,11 @@ class FieldTestReplayService {
}
}
return replaySamples(samples);
return replaySamples(
samples,
softwareIdentity: softwareIdentity,
compatibility: compatibility,
);
}
/// 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
# 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

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