MotorSignal-App/lib/services/sync/cloud_restore_service.dart

445 lines
14 KiB
Dart

import 'dart:convert';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:drift/drift.dart' as drift;
import 'cloud_reconciliation_service.dart';
import '../../database/database.dart';
import '../auth/auth_service.dart';
import 'r2_download_service.dart';
class CloudRestoreResult {
final CloudReconciliationResult metadata;
final int telemetryRidesRestored;
final int telemetrySamplesRestored;
final int telemetrySkipped;
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,
this.telemetryRidesRestored = 0,
this.telemetrySamplesRestored = 0,
this.telemetrySkipped = 0,
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;
int get cloudRidesDiscovered => metadata.discovered;
int get metadataRowsAdded => metadata.added;
int get existingRidesMatched => metadata.unchanged;
int get failures =>
metadata.conflicts +
metadata.skipped +
telemetryFailed +
telemetryAuthFailures +
performanceRunsFailed +
rideEventsFailed;
bool get requiresAuthentication => telemetryAuthFailures > 0;
bool get hasAnyRestoredData =>
metadataRowsAdded > 0 ||
telemetryRidesRestored > 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.
///
/// Metadata is reconciled first. Missing telemetry is then downloaded through
/// the authenticated Worker, SHA-256 verified, unpacked, validated, and added
/// to the local ride without replacing existing telemetry.
class CloudRestoreService {
final AppDatabase _db;
final AuthService _authService;
final CloudReconciliationService _metadata;
final R2DownloadService _r2DownloadService;
final CloudRideMetadataStore _metadataStore;
CloudRestoreService({
required AppDatabase db,
required AuthService authService,
CloudReconciliationService? metadata,
R2DownloadService? r2DownloadService,
CloudRideMetadataStore? metadataStore,
}) : _db = db,
_authService = authService,
_metadata =
metadata ??
CloudReconciliationService(
db: db,
authService: authService,
metadataStore: metadataStore,
),
_r2DownloadService = r2DownloadService ?? R2DownloadService(),
_metadataStore = metadataStore ?? FirestoreCloudRideMetadataStore();
Future<CloudRestoreResult> restore() async {
final user = _authService.currentUser;
if (user == null || user.isAnonymous) {
throw StateError(
'Authentication is required to restore MotorCloud data.',
);
}
var idToken = await _authService.getIdToken();
if (idToken == null || idToken.isEmpty) {
idToken = await _authService.getIdToken(true);
}
if (idToken == null || idToken.isEmpty) {
throw StateError(
'A valid Firebase authentication token is required to restore MotorCloud data.',
);
}
final metadataResult = await _metadata.reconcileMetadata();
final documents = await _metadataStore.listRideDocuments(user.uid);
var telemetryRidesRestored = 0;
var telemetrySamplesRestored = 0;
var telemetrySkipped = 0;
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;
final cloudRideId = document.id;
if (!_isValidCloudRideId(cloudRideId) || data['ownerId'] != user.uid) {
telemetrySkipped++;
continue;
}
final localRide =
await (_db.select(_db.rides)
..where((ride) => ride.cloudRideId.equals(cloudRideId)))
.getSingleOrNull();
if (localRide == null) {
telemetryFailed++;
continue;
}
final telemetryCount =
await (_db.select(_db.telemetrySamples)
..where((sample) => sample.rideId.equals(localRide.id)))
.get()
.then((rows) => rows.length);
if (telemetryCount > 0) {
telemetryAlreadyPresent++;
} else {
final result = await _r2DownloadService.downloadTelemetryBundle(
idToken: idToken,
cloudRideId: cloudRideId,
);
if (!result.success || result.zipBytes == null) {
if (result.requiresAuth) {
telemetryAuthFailures++;
} else {
telemetryFailed++;
}
} else {
try {
final restoredCount = await _restoreTelemetryCsv(
localRide.id,
result.zipBytes!,
);
if (restoredCount > 0) {
telemetryRidesRestored++;
telemetrySamplesRestored += restoredCount;
} else {
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(
metadata: metadataResult,
telemetryRidesRestored: telemetryRidesRestored,
telemetrySamplesRestored: telemetrySamplesRestored,
telemetrySkipped: telemetrySkipped,
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');
if (file == null) {
throw const FormatException(
'Telemetry bundle does not contain telemetry.csv.',
);
}
final csv = utf8.decode(file.content as List<int>, allowMalformed: false);
final lines = const LineSplitter().convert(csv);
if (lines.length < 2) return 0;
const expected = <String>[
'timestamp',
'latitude',
'longitude',
'altitude',
'speed',
'heading',
'gpsAccuracy',
'leanAngle',
'pitchAngle',
'accelX',
'accelY',
'accelZ',
'longitudinalG',
'lateralG',
];
final header = lines.first.split(',');
if (header.length != expected.length ||
!_sameStringList(header, expected)) {
throw const FormatException('Unsupported telemetry.csv schema.');
}
final companions = <TelemetrySamplesCompanion>[];
for (final line in lines.skip(1)) {
if (line.trim().isEmpty) continue;
final values = line.split(',');
if (values.length != expected.length) {
throw const FormatException('Malformed telemetry.csv row.');
}
final timestamp = DateTime.tryParse(values[0]);
if (timestamp == null) {
throw const FormatException('Telemetry row has an invalid timestamp.');
}
companions.add(
TelemetrySamplesCompanion.insert(
rideId: rideId,
timestamp: timestamp.toUtc(),
latitude: _nullableDouble(values[1]),
longitude: _nullableDouble(values[2]),
altitude: _nullableDouble(values[3]),
speed: _nullableDouble(values[4]),
heading: _nullableDouble(values[5]),
gpsAccuracy: _nullableDouble(values[6]),
leanAngle: _nullableDouble(values[7]),
pitchAngle: _nullableDouble(values[8]),
accelX: _nullableDouble(values[9]),
accelY: _nullableDouble(values[10]),
accelZ: _nullableDouble(values[11]),
longitudinalG: _nullableDouble(values[12]),
lateralG: _nullableDouble(values[13]),
),
);
}
if (companions.isEmpty) return 0;
await _db.transaction(() async {
for (final companion in companions) {
await _db.into(_db.telemetrySamples).insert(companion);
}
});
return companions.length;
}
drift.Value<double> _nullableDouble(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) return const drift.Value.absent();
final parsed = double.tryParse(trimmed);
if (parsed == null) {
throw const FormatException(
'Telemetry row contains an invalid numeric value.',
);
}
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++) {
if (actual[i] != expected[i]) return false;
}
return true;
}
bool _isValidCloudRideId(String id) =>
RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(id);
}