MotorSignal-App/lib/services/sync/firebase_sync_provider.dart

216 lines
8.1 KiB
Dart

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';
import 'r2_upload_service.dart';
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;
final FirebaseFirestore? _firestore;
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
_authService = authService,
_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<bool> isAvailable() async {
try {
if (Firebase.apps.isEmpty) return false;
if (_firestore == null) return false;
return true;
} catch (_) {
return false;
}
}
@override
Future<bool> requiresAuthentication() async {
final user = _authService.currentUser;
if (user == null) return true;
return false;
}
@override
Future<SyncResult> uploadRide(SyncItem item) async {
final available = await isAvailable();
if (!available) {
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.',
);
}
final idToken = await _authService.getIdToken();
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.',
);
}
// 2. Generate deterministic remote document and bundle identifiers
final dateStr = DateFormat('yyyyMMdd_HHmmss').format(ride.startTime.toUtc());
final remoteRideId = 'ride_${item.localRideId}_$dateStr';
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}.',
);
}
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,
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 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 = <String, dynamic>{
'localRideId': ride.id,
'remoteRideId': remoteRideId,
'uid': user.uid,
'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': 2,
'uploadedAt': 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));
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');
}
}
}