diff --git a/lib/services/sync/firebase_sync_provider.dart b/lib/services/sync/firebase_sync_provider.dart index e39c4b2..5add6e1 100644 --- a/lib/services/sync/firebase_sync_provider.dart +++ b/lib/services/sync/firebase_sync_provider.dart @@ -1,7 +1,6 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:crypto/crypto.dart'; import 'package:firebase_core/firebase_core.dart'; -import 'package:intl/intl.dart'; import '../../core/version/app_identity.dart'; import '../../database/database.dart'; import '../auth/auth_service.dart'; @@ -10,9 +9,6 @@ import 'sync_bundle_worker.dart'; import 'sync_models.dart'; import 'sync_provider.dart'; -/// Authoritative Firebase + Cloudflare R2 Synchronization Provider. -/// Synchronizes structured ride metadata to Cloud Firestore and streams -/// compressed 20Hz forensic bundles to Cloudflare R2 via the authenticated Worker gateway. class FirebaseSyncProvider implements SyncProvider { final AppDatabase _db; final AuthService _authService; @@ -20,25 +16,15 @@ class FirebaseSyncProvider implements SyncProvider { final R2UploadService _r2UploadService; final SyncBundleWorker _bundleWorker; - FirebaseSyncProvider({ - required AppDatabase db, - required AuthService authService, - FirebaseFirestore? firestore, - R2UploadService? r2UploadService, - SyncBundleWorker? bundleWorker, - }) : _db = db, - // ignore: prefer_initializing_formals + FirebaseSyncProvider({required AppDatabase db, required AuthService authService, FirebaseFirestore? firestore, R2UploadService? r2UploadService, SyncBundleWorker? bundleWorker}) + : _db = db, _authService = authService, _firestore = firestore ?? _resolveFirestore(), _r2UploadService = r2UploadService ?? R2UploadService(), _bundleWorker = bundleWorker ?? SyncBundleWorker(db); static FirebaseFirestore? _resolveFirestore() { - try { - if (Firebase.apps.isNotEmpty) { - return FirebaseFirestore.instance; - } - } catch (_) {} + try { if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; } catch (_) {} return null; } @@ -47,70 +33,32 @@ class FirebaseSyncProvider implements SyncProvider { @override Future isAvailable() async { - try { - if (Firebase.apps.isEmpty) return false; - if (_firestore == null) return false; - return true; - } catch (_) { - return false; - } + try { return Firebase.apps.isNotEmpty && _firestore != null; } catch (_) { return false; } } @override - Future requiresAuthentication() async { - final user = _authService.currentUser; - if (user == null) return true; - return false; - } + Future requiresAuthentication() async => _authService.currentUser == null; @override Future uploadRide(SyncItem item) async { - final available = await isAvailable(); - if (!available) { - return SyncResult.temporaryFailure( - 'Firebase services are unavailable or unconfigured. Ride remains safely stored locally.', - ); - } - + if (!await isAvailable()) return SyncResult.temporaryFailure('Firebase services are unavailable or unconfigured. Ride remains safely stored locally.'); final user = _authService.currentUser; - if (user == null) { - return SyncResult.authRequired( - message: 'Sign in to MotorCloud is required for cloud backup.', - ); - } - + if (user == null || user.isAnonymous) return SyncResult.authRequired(message: 'Sign in to MotorCloud is required for cloud backup.'); final idToken = await _authService.getIdToken(); - if (idToken == null || idToken.isEmpty) { - return SyncResult.authRequired( - message: 'Valid Firebase authentication token required for cloud sync.', - ); - } + if (idToken == null || idToken.isEmpty) return SyncResult.authRequired(message: 'Valid Firebase authentication token required for cloud sync.'); try { - // 1. Read authoritative local ride record final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(item.localRideId))).getSingleOrNull(); - if (ride == null) { - return SyncResult.permanentFailure( - 'Local ride #${item.localRideId} not found in database.', - ); - } + if (ride == null) return SyncResult.permanentFailure('Local ride #${item.localRideId} not found in database.'); - // 2. Generate deterministic remote document and bundle identifiers - final dateStr = DateFormat('yyyyMMdd_HHmmss').format(ride.startTime.toUtc()); - final remoteRideId = 'ride_${item.localRideId}_$dateStr'; + // The cloud identity is generated once and then reused forever. Never + // derive the remote identity from the local auto-increment database ID. + final remoteRideId = await _db.ensureCloudRideId(item.localRideId); final appIdentity = AppIdentity.current(); - - // 3. Generate compressed forensic bundle in background worker isolate final zipBytes = await _bundleWorker.generateCompressedBundle(item.localRideId); - if (zipBytes == null || zipBytes.isEmpty) { - return SyncResult.permanentFailure( - 'Failed generating compressed telemetry bundle for ride #${item.localRideId}.', - ); - } - + if (zipBytes == null || zipBytes.isEmpty) return SyncResult.permanentFailure('Failed generating compressed telemetry bundle for ride #${item.localRideId}.'); final sha256Hash = sha256.convert(zipBytes).toString(); - // 4. Stream binary telemetry.zip to Cloudflare R2 via secure Worker gateway with verified Firebase ID token final r2Result = await _r2UploadService.uploadTelemetryBundle( idToken: idToken, remoteRideId: remoteRideId, @@ -118,29 +66,21 @@ class FirebaseSyncProvider implements SyncProvider { sha256Hash: sha256Hash, appVersion: appIdentity.semanticVersion, ); - if (!r2Result.success) { - if (r2Result.requiresAuth) { - return SyncResult.authRequired(message: r2Result.errorMessage ?? 'R2 Gateway authentication required'); - } - if (r2Result.isRetryable) { - return SyncResult.temporaryFailure(r2Result.errorMessage ?? 'Temporary R2 upload error'); - } + if (r2Result.requiresAuth) return SyncResult.authRequired(message: r2Result.errorMessage ?? 'R2 Gateway authentication required'); + if (r2Result.isRetryable) return SyncResult.temporaryFailure(r2Result.errorMessage ?? 'Temporary R2 upload error'); return SyncResult.permanentFailure(r2Result.errorMessage ?? 'Permanent R2 upload rejection'); } final objectKey = r2Result.objectKey ?? 'users/${user.uid}/rides/$remoteRideId/telemetry.zip'; - - // 5. Query discrete events and performance benchmarks for rich metadata final events = await (_db.select(_db.rideEvents)..where((e) => e.rideId.equals(item.localRideId))).get(); final performanceRuns = await (_db.select(_db.performanceRuns)..where((p) => p.rideId.equals(item.localRideId))).get(); final summary = await (_db.select(_db.fieldTestSummaries)..where((s) => s.rideId.equals(item.localRideId))).getSingleOrNull(); - // 6. Build queryable Firestore metadata document referencing Cloudflare R2 final firestorePayload = { + 'id': remoteRideId, + 'ownerId': user.uid, 'localRideId': ride.id, - 'remoteRideId': remoteRideId, - 'uid': user.uid, 'startTime': ride.startTime.toUtc().toIso8601String(), 'endTime': ride.endTime?.toUtc().toIso8601String(), 'distanceMiles': ride.distanceMiles, @@ -165,49 +105,22 @@ class FirebaseSyncProvider implements SyncProvider { 'extremeLeanEvents': events.where((e) => e.eventType.contains('Lean') || e.eventType.contains('Corner')).length, 'highSpeedEvents': events.where((e) => e.eventType.contains('Speed')).length, 'totalEventsCount': events.length, - 'performanceRuns': performanceRuns.map((r) => { - 'type': r.type, - 'timeMs': r.timeMs, - 'topSpeedReached': r.topSpeedReached, - 'timestamp': r.timestamp.toUtc().toIso8601String(), - }).toList(), - 'telemetryBundle': { - 'provider': 'cloudflare-r2', - 'objectKey': objectKey, - 'status': 'uploaded', - 'bundleSizeBytes': zipBytes.length, - 'sha256': sha256Hash, - }, + 'performanceRuns': performanceRuns.map((r) => {'type': r.type, 'timeMs': r.timeMs, 'topSpeedReached': r.topSpeedReached, 'timestamp': r.timestamp.toUtc().toIso8601String()}).toList(), + 'telemetryBundle': {'provider': 'cloudflare-r2', 'objectKey': objectKey, 'status': 'uploaded', 'bundleSizeBytes': zipBytes.length, 'sha256': sha256Hash}, 'telemetryBundlePath': objectKey, 'bundleSizeBytes': zipBytes.length, - 'syncVersion': 2, + 'syncVersion': 3, 'uploadedAt': FieldValue.serverTimestamp(), + 'updatedAt': FieldValue.serverTimestamp(), 'software': appIdentity.toJson(), }; - // 7. Write / Merge Firestore summary document idempotently - await _firestore! - .collection('users') - .doc(user.uid) - .collection('rides') - .doc(remoteRideId) - .set(firestorePayload, SetOptions(merge: true)); - + await _firestore!.collection('users').doc(user.uid).collection('rides').doc(remoteRideId).set(firestorePayload, SetOptions(merge: true)); return SyncResult.success(remoteId: remoteRideId); } on FirebaseException catch (e) { - if (e.code == 'permission-denied' || e.code == 'unauthenticated') { - return SyncResult.authRequired( - message: 'Firebase authorization error: ${e.message}', - ); - } - if (e.code == 'unavailable' || e.code == 'network-request-failed' || e.code == 'retry-limit-exceeded') { - return SyncResult.temporaryFailure( - 'Network or Firebase service temporary error (${e.code}): ${e.message}', - ); - } - return SyncResult.permanentFailure( - 'Firebase sync error (${e.code}): ${e.message}', - ); + if (e.code == 'permission-denied' || e.code == 'unauthenticated') return SyncResult.authRequired(message: 'Firebase authorization error: ${e.message}'); + if (e.code == 'unavailable' || e.code == 'network-request-failed' || e.code == 'retry-limit-exceeded') return SyncResult.temporaryFailure('Network or Firebase service temporary error (${e.code}): ${e.message}'); + return SyncResult.permanentFailure('Firebase sync error (${e.code}): ${e.message}'); } catch (e) { return SyncResult.temporaryFailure('Unexpected sync exception: $e'); }