273 lines
8.4 KiB
Dart
273 lines
8.4 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;
|
|
|
|
const CloudRestoreResult({
|
|
required this.metadata,
|
|
this.telemetryRidesRestored = 0,
|
|
this.telemetrySamplesRestored = 0,
|
|
this.telemetrySkipped = 0,
|
|
this.telemetryAlreadyPresent = 0,
|
|
this.telemetryFailed = 0,
|
|
this.telemetryAuthFailures = 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;
|
|
bool get requiresAuthentication => telemetryAuthFailures > 0;
|
|
bool get hasAnyRestoredData =>
|
|
metadataRowsAdded > 0 ||
|
|
telemetryRidesRestored > 0 ||
|
|
telemetrySamplesRestored > 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;
|
|
|
|
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++;
|
|
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!,
|
|
);
|
|
if (restoredCount > 0) {
|
|
telemetryRidesRestored++;
|
|
telemetrySamplesRestored += restoredCount;
|
|
} else {
|
|
telemetryFailed++;
|
|
}
|
|
} catch (_) {
|
|
telemetryFailed++;
|
|
}
|
|
}
|
|
|
|
return CloudRestoreResult(
|
|
metadata: metadataResult,
|
|
telemetryRidesRestored: telemetryRidesRestored,
|
|
telemetrySamplesRestored: telemetrySamplesRestored,
|
|
telemetrySkipped: telemetrySkipped,
|
|
telemetryAlreadyPresent: telemetryAlreadyPresent,
|
|
telemetryFailed: telemetryFailed,
|
|
telemetryAuthFailures: telemetryAuthFailures,
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|