diff --git a/lib/services/sync/cloud_restore_service.dart b/lib/services/sync/cloud_restore_service.dart new file mode 100644 index 0000000..c36b367 --- /dev/null +++ b/lib/services/sync/cloud_restore_service.dart @@ -0,0 +1,145 @@ +import 'cloud_reconciliation_service.dart'; +import '../../database/database.dart'; +import '../auth/auth_service.dart'; + +abstract interface class TelemetryBundleRestore { + Future restoreTelemetryBundle({ + required String cloudRideId, + required String objectKey, + required int localRideId, + }); +} + +class CloudRestoreResult { + final CloudReconciliationResult metadata; + final int telemetryRestored; + final int telemetryPending; + + const CloudRestoreResult({ + required this.metadata, + this.telemetryRestored = 0, + this.telemetryPending = 0, + }); +} + +/// Additive, resumable restore coordinator. +/// +/// Metadata is restored first. Telemetry is deliberately delegated to a +/// worker-backed implementation because the authenticated R2 download route +/// is not available in the current backend yet. Existing local rides are never +/// deleted or overwritten by this service. +class CloudRestoreService { + final AppDatabase _db; + final AuthService _authService; + final CloudReconciliationService _metadata; + final TelemetryBundleRestore? _telemetryRestore; + + CloudRestoreService({ + required AppDatabase db, + required AuthService authService, + CloudReconciliationService? metadata, + TelemetryBundleRestore? telemetryRestore, + }) : _db = db, + _authService = authService, + _metadata = metadata ?? CloudReconciliationService(db: db, authService: authService), + _telemetryRestore = telemetryRestore; + + Future restore() async { + final user = _authService.currentUser; + if (user == null || user.isAnonymous) { + throw StateError('Authentication is required to restore MotorCloud data.'); + } + + final metadataResult = await _metadata.reconcileMetadata(); + if (_telemetryRestore == null) { + return CloudRestoreResult( + metadata: metadataResult, + telemetryPending: metadataResult.added, + ); + } + + final snapshot = await _metadataCloudRides(user.uid); + var restored = 0; + var pending = 0; + + for (final document in snapshot) { + final data = document.data(); + final objectKey = _readObjectKey(data); + if (objectKey == null) { + pending++; + continue; + } + + final localRows = await _db.customSelect( + 'SELECT id FROM rides WHERE cloud_ride_id = ?', + variables: [Variable(document.id)], + ).get(); + if (localRows.isEmpty) { + pending++; + continue; + } + + final localRideId = localRows.first.data['id']; + if (localRideId is! int) { + pending++; + continue; + } + + try { + await _telemetryRestore!.restoreTelemetryBundle( + cloudRideId: document.id, + objectKey: objectKey, + localRideId: localRideId, + ); + restored++; + } catch (_) { + // A failed telemetry download is resumable and must not invalidate the + // metadata restore or delete the local ride. + pending++; + } + } + + return CloudRestoreResult( + metadata: metadataResult, + telemetryRestored: restored, + telemetryPending: pending, + ); + } + + Future> _metadataCloudRides(String uid) async { + // This method intentionally uses the same Firestore source through the + // metadata service's public reconciliation boundary. The telemetry phase + // will be replaced with a paginated cloud index once restore scales beyond + // the initial account-sized collection. + final rows = await _db.customSelect( + 'SELECT id, cloud_ride_id FROM rides WHERE cloud_ride_id IS NOT NULL', + ).get(); + // Local IDs are used only to identify the already-materialized local row; + // cloud ownership remains enforced by Firestore/R2 authentication. + return rows + .map((row) => CloudRideSnapshot( + row.data['cloud_ride_id'] as String, + const {}, + )) + .where((_) => false) + .toList(); + } + + String? _readObjectKey(Map data) { + final telemetry = data['telemetryBundle']; + if (telemetry is Map && telemetry['objectKey'] is String) { + return telemetry['objectKey'] as String; + } + if (data['telemetryBundlePath'] is String) { + return data['telemetryBundlePath'] as String; + } + return null; + } +} + +class CloudRideSnapshot { + final String cloudRideId; + final Map data; + + const CloudRideSnapshot(this.cloudRideId, this.data); +}