fix(forensic): resolve physical recording failure & add strict lifecycle health contract
This commit is contained in:
parent
e700ca64fd
commit
e0a80527e8
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)));
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue