From 8393849f39a5770b1b86d611bc365c566c78e0b2 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 3 Sep 2026 03:03:16 -0400 Subject: [PATCH] feat: restore ride performance data from MotorCloud --- .../sync/cloud_restore_controller.dart | 8 + lib/services/sync/cloud_restore_service.dart | 228 ++++++++++++++-- test/cloud_restore_manual_test.dart | 247 ++++++++++++++++++ 3 files changed, 455 insertions(+), 28 deletions(-) diff --git a/lib/services/sync/cloud_restore_controller.dart b/lib/services/sync/cloud_restore_controller.dart index ee93918..42699d4 100644 --- a/lib/services/sync/cloud_restore_controller.dart +++ b/lib/services/sync/cloud_restore_controller.dart @@ -110,6 +110,14 @@ class CloudRestoreController extends StateNotifier { '${result.telemetrySamplesRestored} telemetry sample(s) restored', ); } + if (result.performanceRunsRestored > 0) { + parts.add( + '${result.performanceRunsRestored} performance run(s) restored', + ); + } + if (result.rideEventsRestored > 0) { + parts.add('${result.rideEventsRestored} ride event(s) restored'); + } if (result.existingRidesMatched > 0 || result.telemetryAlreadyPresent > 0) { final alreadyPresent = result.existingRidesMatched > result.telemetryAlreadyPresent diff --git a/lib/services/sync/cloud_restore_service.dart b/lib/services/sync/cloud_restore_service.dart index e595397..0fca357 100644 --- a/lib/services/sync/cloud_restore_service.dart +++ b/lib/services/sync/cloud_restore_service.dart @@ -17,6 +17,15 @@ class CloudRestoreResult { final int telemetryAlreadyPresent; final int telemetryFailed; final int telemetryAuthFailures; + final int performanceRunsRestored; + final int performanceRunsAlreadyPresent; + final int performanceRunsSkipped; + final int performanceRunsFailed; + final int rideEventsRestored; + final int rideEventsAlreadyPresent; + final int rideEventsSkipped; + final int rideEventsFailed; + final int rideEventsUnavailable; const CloudRestoreResult({ required this.metadata, @@ -26,6 +35,15 @@ class CloudRestoreResult { this.telemetryAlreadyPresent = 0, this.telemetryFailed = 0, this.telemetryAuthFailures = 0, + this.performanceRunsRestored = 0, + this.performanceRunsAlreadyPresent = 0, + this.performanceRunsSkipped = 0, + this.performanceRunsFailed = 0, + this.rideEventsRestored = 0, + this.rideEventsAlreadyPresent = 0, + this.rideEventsSkipped = 0, + this.rideEventsFailed = 0, + this.rideEventsUnavailable = 0, }); int get telemetryPending => telemetryFailed + telemetryAuthFailures; @@ -36,12 +54,32 @@ class CloudRestoreResult { metadata.conflicts + metadata.skipped + telemetryFailed + - telemetryAuthFailures; + telemetryAuthFailures + + performanceRunsFailed + + rideEventsFailed; bool get requiresAuthentication => telemetryAuthFailures > 0; bool get hasAnyRestoredData => metadataRowsAdded > 0 || telemetryRidesRestored > 0 || - telemetrySamplesRestored > 0; + telemetrySamplesRestored > 0 || + performanceRunsRestored > 0 || + rideEventsRestored > 0; +} + +class _ChildRestoreCounts { + final int restored; + final int alreadyPresent; + final int skipped; + final int failed; + final int unavailable; + + const _ChildRestoreCounts({ + this.restored = 0, + this.alreadyPresent = 0, + this.skipped = 0, + this.failed = 0, + this.unavailable = 0, + }); } /// Additive, resumable MotorCloud restore coordinator. @@ -101,6 +139,15 @@ class CloudRestoreService { var telemetryAlreadyPresent = 0; var telemetryFailed = 0; var telemetryAuthFailures = 0; + var performanceRunsRestored = 0; + var performanceRunsAlreadyPresent = 0; + var performanceRunsSkipped = 0; + var performanceRunsFailed = 0; + var rideEventsRestored = 0; + var rideEventsAlreadyPresent = 0; + var rideEventsSkipped = 0; + var rideEventsFailed = 0; + var rideEventsUnavailable = 0; for (final document in documents) { final data = document.data; @@ -126,36 +173,50 @@ class CloudRestoreService { .then((rows) => rows.length); if (telemetryCount > 0) { telemetryAlreadyPresent++; - continue; - } - - final result = await _r2DownloadService.downloadTelemetryBundle( - idToken: idToken, - cloudRideId: cloudRideId, - ); - if (!result.success || result.zipBytes == null) { - if (result.requiresAuth) { - telemetryAuthFailures++; - continue; - } - telemetryFailed++; - continue; - } - - try { - final restoredCount = await _restoreTelemetryCsv( - localRide.id, - result.zipBytes!, + } else { + final result = await _r2DownloadService.downloadTelemetryBundle( + idToken: idToken, + cloudRideId: cloudRideId, ); - if (restoredCount > 0) { - telemetryRidesRestored++; - telemetrySamplesRestored += restoredCount; + if (!result.success || result.zipBytes == null) { + if (result.requiresAuth) { + telemetryAuthFailures++; + } else { + telemetryFailed++; + } } else { - telemetryFailed++; + try { + final restoredCount = await _restoreTelemetryCsv( + localRide.id, + result.zipBytes!, + ); + if (restoredCount > 0) { + telemetryRidesRestored++; + telemetrySamplesRestored += restoredCount; + } else { + telemetryFailed++; + } + } catch (_) { + telemetryFailed++; + } } - } catch (_) { - telemetryFailed++; } + + final performanceCounts = await _restorePerformanceRuns( + localRide.id, + data['performanceRuns'], + ); + performanceRunsRestored += performanceCounts.restored; + performanceRunsAlreadyPresent += performanceCounts.alreadyPresent; + performanceRunsSkipped += performanceCounts.skipped; + performanceRunsFailed += performanceCounts.failed; + + final eventCounts = await _restoreRideEvents(localRide.id, data); + rideEventsRestored += eventCounts.restored; + rideEventsAlreadyPresent += eventCounts.alreadyPresent; + rideEventsSkipped += eventCounts.skipped; + rideEventsFailed += eventCounts.failed; + rideEventsUnavailable += eventCounts.unavailable; } return CloudRestoreResult( @@ -166,9 +227,107 @@ class CloudRestoreService { telemetryAlreadyPresent: telemetryAlreadyPresent, telemetryFailed: telemetryFailed, telemetryAuthFailures: telemetryAuthFailures, + performanceRunsRestored: performanceRunsRestored, + performanceRunsAlreadyPresent: performanceRunsAlreadyPresent, + performanceRunsSkipped: performanceRunsSkipped, + performanceRunsFailed: performanceRunsFailed, + rideEventsRestored: rideEventsRestored, + rideEventsAlreadyPresent: rideEventsAlreadyPresent, + rideEventsSkipped: rideEventsSkipped, + rideEventsFailed: rideEventsFailed, + rideEventsUnavailable: rideEventsUnavailable, ); } + Future<_ChildRestoreCounts> _restorePerformanceRuns( + int localRideId, + Object? rawRuns, + ) async { + if (rawRuns == null) return const _ChildRestoreCounts(); + if (rawRuns is! Iterable) { + return const _ChildRestoreCounts(failed: 1); + } + + var restored = 0; + var alreadyPresent = 0; + var skipped = 0; + var failed = 0; + + for (final rawRun in rawRuns) { + try { + if (rawRun is! Map) { + skipped++; + continue; + } + final type = rawRun['type']?.toString(); + final timeMs = _nullablePlainDouble(rawRun['timeMs']); + final topSpeedReached = _nullablePlainDouble(rawRun['topSpeedReached']); + final timestamp = _readDateTime(rawRun['timestamp']); + if (type == null || + type.isEmpty || + timeMs == null || + topSpeedReached == null || + timestamp == null) { + skipped++; + continue; + } + + final existing = + await (_db.select(_db.performanceRuns) + ..where((run) => run.rideId.equals(localRideId)) + ..where((run) => run.type.equals(type)) + ..where((run) => run.timeMs.equals(timeMs)) + ..where((run) => run.topSpeedReached.equals(topSpeedReached)) + ..where((run) => run.timestamp.equals(timestamp))) + .getSingleOrNull(); + if (existing != null) { + alreadyPresent++; + continue; + } + + await _db + .into(_db.performanceRuns) + .insert( + PerformanceRunsCompanion.insert( + rideId: drift.Value(localRideId), + type: type, + timeMs: timeMs, + topSpeedReached: topSpeedReached, + timestamp: timestamp, + ), + ); + restored++; + } catch (_) { + failed++; + } + } + + return _ChildRestoreCounts( + restored: restored, + alreadyPresent: alreadyPresent, + skipped: skipped, + failed: failed, + ); + } + + Future<_ChildRestoreCounts> _restoreRideEvents( + int localRideId, + Map rideMetadata, + ) async { + final existingEvents = await (_db.select( + _db.rideEvents, + )..where((event) => event.rideId.equals(localRideId))).get(); + if (existingEvents.isNotEmpty) { + return _ChildRestoreCounts(alreadyPresent: existingEvents.length); + } + + final totalEvents = _asInt(rideMetadata['totalEventsCount']) ?? 0; + if (totalEvents > 0) { + return _ChildRestoreCounts(unavailable: totalEvents); + } + return const _ChildRestoreCounts(); + } + Future _restoreTelemetryCsv(int rideId, Uint8List zipBytes) async { final archive = ZipDecoder().decodeBytes(zipBytes, verify: true); final file = archive.findFile('telemetry.csv'); @@ -259,6 +418,19 @@ class CloudRestoreService { return drift.Value(parsed); } + double? _nullablePlainDouble(Object? value) => value is num + ? value.toDouble() + : double.tryParse(value?.toString() ?? ''); + + int? _asInt(Object? value) => + value is num ? value.toInt() : int.tryParse(value?.toString() ?? ''); + + DateTime? _readDateTime(Object? value) { + if (value is DateTime) return value.toUtc(); + final parsed = DateTime.tryParse(value?.toString() ?? ''); + return parsed?.toUtc(); + } + bool _sameStringList(List actual, List expected) { if (actual.length != expected.length) return false; for (var i = 0; i < expected.length; i++) { diff --git a/test/cloud_restore_manual_test.dart b/test/cloud_restore_manual_test.dart index a27ef54..d1fe23d 100644 --- a/test/cloud_restore_manual_test.dart +++ b/test/cloud_restore_manual_test.dart @@ -145,6 +145,13 @@ void main() { ownerId: 'user_a', movingTime: 600, stoppedTime: 45, + performanceRuns: [ + _performanceRun( + type: '0-60', + timeMs: 3210, + topSpeedReached: 62, + ), + ], ), ], }, @@ -163,16 +170,25 @@ void main() { expect(first.metadataRowsAdded, 1); expect(first.telemetryRidesRestored, 1); expect(first.telemetrySamplesRestored, 2); + expect(first.performanceRunsRestored, 1); expect(rides, hasLength(1)); expect(rides.single.cloudRideId, 'ride_clean_install'); expect(rides.single.movingTime, 600); expect(rides.single.stoppedTime, 45); expect(samples, hasLength(2)); + final runs = await db.select(db.performanceRuns).get(); + expect(runs, hasLength(1)); + expect(runs.single.rideId, rides.single.id); + expect(runs.single.type, '0-60'); + expect(runs.single.timeMs, 3210); expect(second.metadataRowsAdded, 0); expect(second.telemetrySamplesRestored, 0); expect(second.telemetryAlreadyPresent, 1); + expect(second.performanceRunsRestored, 0); + expect(second.performanceRunsAlreadyPresent, 1); expect(await db.select(db.rides).get(), hasLength(1)); expect(await db.select(db.telemetrySamples).get(), hasLength(2)); + expect(await db.select(db.performanceRuns).get(), hasLength(1)); }, ); @@ -319,6 +335,218 @@ void main() { }, ); + test( + 'two cloud rides restore performance runs without cross-association', + () async { + final store = _MemoryRideMetadataStore( + byUid: { + 'user_a': [ + _cloudRide( + id: 'ride_child_a', + ownerId: 'user_a', + performanceRuns: [ + _performanceRun( + type: '0-60', + timeMs: 3000, + topSpeedReached: 61, + timestamp: DateTime.utc(2026, 9, 3, 10, 1), + ), + ], + ), + _cloudRide( + id: 'ride_child_b', + ownerId: 'user_a', + performanceRuns: [ + _performanceRun( + type: '0-100', + timeMs: 6400, + topSpeedReached: 102, + timestamp: DateTime.utc(2026, 9, 3, 11, 1), + ), + ], + ), + ], + }, + ); + + final result = await service( + store: store, + client: _downloadClient(_telemetryZip()), + ).restore(); + + final rides = await db.select(db.rides).get(); + final rideA = rides.singleWhere( + (ride) => ride.cloudRideId == 'ride_child_a', + ); + final rideB = rides.singleWhere( + (ride) => ride.cloudRideId == 'ride_child_b', + ); + final rideARuns = await (db.select( + db.performanceRuns, + )..where((run) => run.rideId.equals(rideA.id))).get(); + final rideBRuns = await (db.select( + db.performanceRuns, + )..where((run) => run.rideId.equals(rideB.id))).get(); + + expect(result.performanceRunsRestored, 2); + expect(rideARuns.single.type, '0-60'); + expect(rideBRuns.single.type, '0-100'); + expect(rideARuns.single.rideId, isNot(rideBRuns.single.rideId)); + }, + ); + + test( + 'existing local performance and ride events are not overwritten or duplicated', + () async { + final rideId = await db + .into(db.rides) + .insert( + RidesCompanion.insert( + cloudRideId: const drift.Value('ride_existing_children'), + startTime: DateTime.utc(2026, 9, 3, 10), + ), + ); + await db + .into(db.performanceRuns) + .insert( + PerformanceRunsCompanion.insert( + rideId: drift.Value(rideId), + type: '0-60', + timeMs: 3500, + topSpeedReached: 63, + timestamp: DateTime.utc(2026, 9, 3, 10, 1), + ), + ); + await db + .into(db.rideEvents) + .insert( + RideEventsCompanion.insert( + rideId: rideId, + timestamp: DateTime.utc(2026, 9, 3, 10, 2), + eventType: 'Hard Braking', + value: -0.9, + ), + ); + final store = _MemoryRideMetadataStore( + byUid: { + 'user_a': [ + _cloudRide( + id: 'ride_existing_children', + ownerId: 'user_a', + totalEventsCount: 1, + performanceRuns: [ + _performanceRun( + type: '0-60', + timeMs: 3500, + topSpeedReached: 63, + timestamp: DateTime.utc(2026, 9, 3, 10, 1), + ), + _performanceRun( + type: '0-100', + timeMs: 7000, + topSpeedReached: 101, + timestamp: DateTime.utc(2026, 9, 3, 10, 3), + ), + ], + ), + ], + }, + ); + + final result = await service( + store: store, + client: _downloadClient(_telemetryZip()), + ).restore(); + + final runs = await db.select(db.performanceRuns).get(); + final events = await db.select(db.rideEvents).get(); + expect(result.performanceRunsRestored, 1); + expect(result.performanceRunsAlreadyPresent, 1); + expect(result.rideEventsAlreadyPresent, 1); + expect(runs, hasLength(2)); + expect(events, hasLength(1)); + expect(events.single.eventType, 'Hard Braking'); + }, + ); + + test( + 'malformed performance data does not destroy restored telemetry', + () async { + final store = _MemoryRideMetadataStore( + byUid: { + 'user_a': [ + _cloudRide( + id: 'ride_bad_performance', + ownerId: 'user_a', + performanceRuns: [ + {'type': '0-60', 'timeMs': 'bad'}, + ], + ), + ], + }, + ); + + final result = await service( + store: store, + client: _downloadClient(_telemetryZip(samples: 2)), + ).restore(); + + expect(result.telemetrySamplesRestored, 2); + expect(result.performanceRunsSkipped, 1); + expect(await db.select(db.telemetrySamples).get(), hasLength(2)); + expect(await db.select(db.performanceRuns).get(), isEmpty); + }, + ); + + test( + 'canonical ride events are not fabricated from aggregate cloud counts', + () async { + final store = _MemoryRideMetadataStore( + byUid: { + 'user_a': [ + _cloudRide( + id: 'ride_event_gap', + ownerId: 'user_a', + totalEventsCount: 3, + ), + ], + }, + ); + + final result = await service( + store: store, + client: _downloadClient(_telemetryZip(samples: 1)), + ).restore(); + + expect(result.telemetrySamplesRestored, 1); + expect(result.rideEventsUnavailable, 3); + expect(await db.select(db.rideEvents).get(), isEmpty); + }, + ); + + test( + 'legacy cloud bundle without child data still restores ride and telemetry', + () async { + final store = _MemoryRideMetadataStore( + byUid: { + 'user_a': [_cloudRide(id: 'ride_legacy_bundle', ownerId: 'user_a')], + }, + ); + + final result = await service( + store: store, + client: _downloadClient(_telemetryZip(samples: 1)), + ).restore(); + + expect(result.metadataRowsAdded, 1); + expect(result.telemetrySamplesRestored, 1); + expect(result.performanceRunsRestored, 0); + expect(result.rideEventsRestored, 0); + expect(await db.select(db.rides).get(), hasLength(1)); + expect(await db.select(db.telemetrySamples).get(), hasLength(1)); + }, + ); + test( 'wrong-owner cloud metadata is skipped for the current account', () async { @@ -372,6 +600,8 @@ CloudRideMetadataDocument _cloudRide({ required String ownerId, int movingTime = 120, int stoppedTime = 10, + List>? performanceRuns, + int totalEventsCount = 0, }) { return CloudRideMetadataDocument( id: id, @@ -396,11 +626,28 @@ CloudRideMetadataDocument _cloudRide({ 'motorcycleMake': 'Yamaha', 'motorcycleModel': 'MT-09', 'motorcycleVariant': 'SP', + 'totalEventsCount': totalEventsCount, + 'performanceRuns': ?performanceRuns, 'telemetryBundlePath': 'users/$ownerId/rides/$id/telemetry.zip', }, ); } +Map _performanceRun({ + required String type, + required double timeMs, + required double topSpeedReached, + DateTime? timestamp, +}) { + return { + 'type': type, + 'timeMs': timeMs, + 'topSpeedReached': topSpeedReached, + 'timestamp': (timestamp ?? DateTime.utc(2026, 9, 3, 10, 1)) + .toIso8601String(), + }; +} + MockClient _downloadClient(List zipBytes) { return MockClient((request) async { final rideId = request.url.queryParameters['rideId']!;