import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:crypto/crypto.dart'; import 'package:firebase_core/firebase_core.dart'; import '../../core/version/app_identity.dart'; import '../../database/database.dart'; import '../auth/auth_service.dart'; import 'r2_upload_service.dart'; import 'sync_bundle_worker.dart'; import 'sync_models.dart'; import 'sync_provider.dart'; class FirebaseSyncProvider implements SyncProvider { final AppDatabase _db; final AuthService _authService; final FirebaseFirestore? _firestore; final R2UploadService _r2UploadService; final SyncBundleWorker _bundleWorker; FirebaseSyncProvider({ required AppDatabase db, required this._authService, FirebaseFirestore? firestore, R2UploadService? r2UploadService, SyncBundleWorker? bundleWorker, }) : _db = db, _firestore = firestore ?? _resolveFirestore(), _r2UploadService = r2UploadService ?? R2UploadService(), _bundleWorker = bundleWorker ?? SyncBundleWorker(db); static FirebaseFirestore? _resolveFirestore() { try { if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; } catch (_) {} return null; } @override String get providerId => 'firebase'; @override Future isAvailable() async { return _firestore != null; } @override Future requiresAuthentication() async { final user = _authService.currentUser; return user == null || user.isAnonymous; } @override Future isRideConfirmedInCloud(String remoteRideId) async { final result = await verifyCloudConfirmation(remoteRideId); return result.isConfirmed; } @override Future verifyCloudConfirmation( String remoteRideId, ) async { final user = _authService.currentUser; if (user == null || user.isAnonymous) { return CloudConfirmationResult.unauthorized( remoteRideId: remoteRideId, message: 'Sign in to MotorCloud is required to verify cloud ride status.', ); } if (!await isAvailable()) { return CloudConfirmationResult.temporarilyUnverifiable( remoteRideId: remoteRideId, message: 'Firebase services are unavailable or unconfigured.', ); } // Step 1: Firestore document metadata verification DocumentSnapshot> doc; try { doc = await _firestore! .collection('users') .doc(user.uid) .collection('rides') .doc(remoteRideId) .get(); } on FirebaseException catch (e) { if (e.code == 'permission-denied' || e.code == 'unauthenticated') { return CloudConfirmationResult.unauthorized( remoteRideId: remoteRideId, ownerUid: user.uid, message: 'Firestore access unauthorized (${e.code}): ${e.message}', ); } return CloudConfirmationResult.temporarilyUnverifiable( remoteRideId: remoteRideId, message: 'Firestore temporary lookup error (${e.code}): ${e.message}', ); } catch (e) { return CloudConfirmationResult.temporarilyUnverifiable( remoteRideId: remoteRideId, message: 'Firestore lookup exception: $e', ); } if (!doc.exists) { return CloudConfirmationResult.missing( remoteRideId: remoteRideId, message: 'Firestore ride document not found.', ); } final data = doc.data(); if (data == null) { return CloudConfirmationResult.missing( remoteRideId: remoteRideId, message: 'Firestore ride document contains no data.', ); } // Verify ownership in metadata final ownerId = (data['ownerId'] ?? data['uid'])?.toString(); if (ownerId != null && ownerId != user.uid) { return CloudConfirmationResult.unauthorized( remoteRideId: remoteRideId, ownerUid: ownerId, message: 'Firestore ride owner ($ownerId) does not match authenticated user (${user.uid}).', ); } // Verify remote ID in metadata final storedId = (data['id'] ?? data['remoteRideId'])?.toString(); if (storedId != null && storedId != remoteRideId) { return CloudConfirmationResult.inconsistent( remoteRideId: remoteRideId, ownerUid: user.uid, message: 'Firestore ride id ($storedId) does not match requested ($remoteRideId).', ); } final bundle = data['telemetryBundle']; if (bundle is! Map) { return CloudConfirmationResult.missing( remoteRideId: remoteRideId, message: 'Firestore document missing telemetryBundle metadata.', ); } final bundleStatus = bundle['status']?.toString(); if (bundleStatus != 'uploaded') { return CloudConfirmationResult.missing( remoteRideId: remoteRideId, message: 'Firestore telemetryBundle status is "$bundleStatus", not "uploaded".', ); } final expectedObjectKey = 'users/${user.uid}/rides/$remoteRideId/telemetry.zip'; final objectKey = (bundle['objectKey'] ?? data['telemetryBundlePath']) ?.toString(); if (objectKey == null || objectKey != expectedObjectKey) { return CloudConfirmationResult.inconsistent( remoteRideId: remoteRideId, ownerUid: user.uid, objectKey: objectKey, message: 'telemetryBundle objectKey ($objectKey) does not match expected ($expectedObjectKey).', ); } final expectedSha256 = (bundle['sha256'] ?? data['sha256'])?.toString(); final expectedSize = (bundle['bundleSizeBytes'] ?? data['bundleSizeBytes']) as num?; // Step 2: R2 bundle verification via authenticated lightweight Worker check var idToken = await _authService.getIdToken(); if (idToken == null || idToken.isEmpty) { idToken = await _authService.getIdToken(true); } if (idToken == null || idToken.isEmpty) { return CloudConfirmationResult.unauthorized( remoteRideId: remoteRideId, ownerUid: user.uid, message: 'Valid Firebase ID token required to verify R2 bundle.', ); } final r2Result = await _r2UploadService.verifyTelemetryBundle( idToken: idToken, remoteRideId: remoteRideId, expectedSha256: expectedSha256, expectedSize: expectedSize?.toInt(), ); switch (r2Result.status) { case CloudConfirmationStatus.confirmed: return CloudConfirmationResult.confirmed( remoteRideId: remoteRideId, ownerUid: user.uid, objectKey: r2Result.metadata?.objectKey ?? expectedObjectKey, sizeBytes: r2Result.metadata?.size ?? expectedSize?.toInt(), sha256: r2Result.metadata?.sha256 ?? expectedSha256, message: 'Firestore metadata and R2 bundle verified.', ); case CloudConfirmationStatus.missing: return CloudConfirmationResult.missing( remoteRideId: remoteRideId, message: r2Result.errorMessage ?? 'Telemetry bundle missing from Cloudflare R2.', ); case CloudConfirmationStatus.inconsistent: return CloudConfirmationResult.inconsistent( remoteRideId: remoteRideId, ownerUid: user.uid, objectKey: expectedObjectKey, message: r2Result.errorMessage ?? 'R2 telemetry bundle checksum or metadata inconsistent.', ); case CloudConfirmationStatus.unauthorized: return CloudConfirmationResult.unauthorized( remoteRideId: remoteRideId, ownerUid: user.uid, message: r2Result.errorMessage ?? 'Unauthorized to access R2 telemetry bundle.', ); case CloudConfirmationStatus.temporarilyUnverifiable: return CloudConfirmationResult.temporarilyUnverifiable( remoteRideId: remoteRideId, message: r2Result.errorMessage ?? 'R2 verification temporarily unavailable.', ); } } @override Future uploadRide(SyncItem item) async { final user = _authService.currentUser; if (user == null || user.isAnonymous) { return SyncResult.authRequired( message: 'Sign in to MotorCloud is required for cloud backup.', ); } final ride = await (_db.select( _db.rides, )..where((r) => r.id.equals(item.localRideId))).getSingleOrNull(); if (ride != null && ride.cloudOwnerUid != user.uid) { return SyncResult.permanentFailure( ride.cloudOwnerUid == null ? 'This device-only ride needs explicit approval before MotorCloud backup.' : 'This ride belongs to another MotorCloud account and was not uploaded.', ); } if (!await isAvailable()) { return SyncResult.temporaryFailure( 'Firebase services are unavailable or unconfigured. Ride remains safely stored locally.', ); } if (ride == null) { return SyncResult.permanentFailure( 'Local ride #${item.localRideId} not found in database.', ); } // Refresh the Firebase ID token before a manual upload. This prevents a // recently completed sign-in from being rejected because the cached token // has not yet propagated through the app's auth abstraction. var idToken = await _authService.getIdToken(); if (idToken == null || idToken.isEmpty) { idToken = await _authService.getIdToken(true); } if (idToken == null || idToken.isEmpty) { return SyncResult.authRequired( message: 'Valid Firebase authentication token required for cloud sync.', ); } try { // 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 confirmation = await verifyCloudConfirmation(remoteRideId); if (confirmation.isConfirmed) { return SyncResult.success(remoteId: remoteRideId); } if (confirmation.isInconsistent) { return SyncResult.permanentFailure( 'Cloud data inconsistent for ride $remoteRideId: ${confirmation.message}. Re-upload aborted to prevent overwriting cloud object.', ); } if (confirmation.isUnauthorized) { return SyncResult.authRequired( message: 'Cloud verification unauthorized for ride $remoteRideId: ${confirmation.message}', ); } if (confirmation.isTemporarilyUnverifiable) { return SyncResult.temporaryFailure( 'Cloud confirmation temporarily unavailable for ride $remoteRideId: ${confirmation.message}', ); } final appIdentity = AppIdentity.current(); final zipBytes = await _bundleWorker.generateCompressedBundle( 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(); var r2Result = await _r2UploadService.uploadTelemetryBundle( idToken: idToken, remoteRideId: remoteRideId, zipBytes: zipBytes, sha256Hash: sha256Hash, appVersion: appIdentity.semanticVersion, ); // If the gateway rejects the first token as unauthorized, refresh once // and retry. This specifically handles token rotation/expiry without // turning a valid signed-in session into a false authentication error. if (!r2Result.success && r2Result.requiresAuth) { final refreshedToken = await _authService.getIdToken(true); if (refreshedToken != null && refreshedToken.isNotEmpty && refreshedToken != idToken) { idToken = refreshedToken; r2Result = await _r2UploadService.uploadTelemetryBundle( idToken: idToken, remoteRideId: remoteRideId, zipBytes: zipBytes, 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', ); } return SyncResult.permanentFailure( r2Result.errorMessage ?? 'Permanent R2 upload rejection', ); } final expectedObjectKey = 'users/${user.uid}/rides/$remoteRideId/telemetry.zip'; final objectKey = r2Result.objectKey ?? expectedObjectKey; if (objectKey != expectedObjectKey) { return SyncResult.permanentFailure( 'R2 Worker returned an unexpected object key ($objectKey); expected $expectedObjectKey.', ); } 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(); final firestorePayload = { 'id': remoteRideId, 'ownerId': user.uid, 'localRideId': ride.id, 'startTime': ride.startTime.toUtc().toIso8601String(), 'endTime': ride.endTime?.toUtc().toIso8601String(), 'distanceMiles': ride.distanceMiles, 'topSpeedMph': ride.topSpeedMph, 'maxLeanLeft': ride.maxLeanLeft, 'maxLeanRight': ride.maxLeanRight, 'maxAccelG': ride.maxAccelG, 'maxBrakingG': ride.maxBrakingG, 'averageSpeed': ride.averageSpeed, 'movingTime': ride.movingTime, 'stoppedTime': ride.stoppedTime, 'elevationGain': ride.elevationGain, 'elevationLoss': ride.elevationLoss, 'qualityScore': summary?.rideQualityScore ?? 100, 'qualityRating': summary?.rideQualityRating ?? 'EXCELLENT', 'motorcycleId': ride.motorcycleId, 'motorcycleMake': ride.motorcycleMake, 'motorcycleModel': ride.motorcycleModel, 'motorcycleYear': ride.motorcycleYear, 'motorcycleVariant': ride.motorcycleVariant, 'hardBrakingEvents': events .where((e) => e.eventType.contains('Braking')) .length, '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, }, 'telemetryBundlePath': objectKey, 'bundleSizeBytes': zipBytes.length, 'syncVersion': 3, 'uploadedAt': FieldValue.serverTimestamp(), 'updatedAt': FieldValue.serverTimestamp(), 'software': appIdentity.toJson(), }; 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}', ); } catch (e) { return SyncResult.temporaryFailure('Unexpected sync exception: $e'); } } }