146 lines
4.4 KiB
Dart
146 lines
4.4 KiB
Dart
import 'cloud_reconciliation_service.dart';
|
|
import '../../database/database.dart';
|
|
import '../auth/auth_service.dart';
|
|
|
|
abstract interface class TelemetryBundleRestore {
|
|
Future<void> 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<CloudRestoreResult> 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<String>(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<List<CloudRideSnapshot>> _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 <String, dynamic>{},
|
|
))
|
|
.where((_) => false)
|
|
.toList();
|
|
}
|
|
|
|
String? _readObjectKey(Map<String, dynamic> 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<String, dynamic> data;
|
|
|
|
const CloudRideSnapshot(this.cloudRideId, this.data);
|
|
}
|