feat: restore ride performance data from MotorCloud

This commit is contained in:
Developer 2026-09-03 03:03:16 -04:00
parent cd5f575f9b
commit 8393849f39
3 changed files with 455 additions and 28 deletions

View File

@ -110,6 +110,14 @@ class CloudRestoreController extends StateNotifier<CloudRestoreUiState> {
'${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

View File

@ -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<String, dynamic> 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<int> _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<String> actual, List<String> expected) {
if (actual.length != expected.length) return false;
for (var i = 0; i < expected.length; i++) {

View File

@ -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<Map<String, dynamic>>? 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<String, dynamic> _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<int> zipBytes) {
return MockClient((request) async {
final rideId = request.url.queryParameters['rideId']!;