Use persisted stable cloud ride identity for Firebase and R2
This commit is contained in:
parent
fe8e1c4769
commit
657ee85a4c
|
|
@ -1,7 +1,6 @@
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import '../../core/version/app_identity.dart';
|
import '../../core/version/app_identity.dart';
|
||||||
import '../../database/database.dart';
|
import '../../database/database.dart';
|
||||||
import '../auth/auth_service.dart';
|
import '../auth/auth_service.dart';
|
||||||
|
|
@ -10,9 +9,6 @@ import 'sync_bundle_worker.dart';
|
||||||
import 'sync_models.dart';
|
import 'sync_models.dart';
|
||||||
import 'sync_provider.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 {
|
class FirebaseSyncProvider implements SyncProvider {
|
||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
final AuthService _authService;
|
final AuthService _authService;
|
||||||
|
|
@ -20,25 +16,15 @@ class FirebaseSyncProvider implements SyncProvider {
|
||||||
final R2UploadService _r2UploadService;
|
final R2UploadService _r2UploadService;
|
||||||
final SyncBundleWorker _bundleWorker;
|
final SyncBundleWorker _bundleWorker;
|
||||||
|
|
||||||
FirebaseSyncProvider({
|
FirebaseSyncProvider({required AppDatabase db, required AuthService authService, FirebaseFirestore? firestore, R2UploadService? r2UploadService, SyncBundleWorker? bundleWorker})
|
||||||
required AppDatabase db,
|
: _db = db,
|
||||||
required AuthService authService,
|
|
||||||
FirebaseFirestore? firestore,
|
|
||||||
R2UploadService? r2UploadService,
|
|
||||||
SyncBundleWorker? bundleWorker,
|
|
||||||
}) : _db = db,
|
|
||||||
// ignore: prefer_initializing_formals
|
|
||||||
_authService = authService,
|
_authService = authService,
|
||||||
_firestore = firestore ?? _resolveFirestore(),
|
_firestore = firestore ?? _resolveFirestore(),
|
||||||
_r2UploadService = r2UploadService ?? R2UploadService(),
|
_r2UploadService = r2UploadService ?? R2UploadService(),
|
||||||
_bundleWorker = bundleWorker ?? SyncBundleWorker(db);
|
_bundleWorker = bundleWorker ?? SyncBundleWorker(db);
|
||||||
|
|
||||||
static FirebaseFirestore? _resolveFirestore() {
|
static FirebaseFirestore? _resolveFirestore() {
|
||||||
try {
|
try { if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; } catch (_) {}
|
||||||
if (Firebase.apps.isNotEmpty) {
|
|
||||||
return FirebaseFirestore.instance;
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,70 +33,32 @@ class FirebaseSyncProvider implements SyncProvider {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> isAvailable() async {
|
Future<bool> isAvailable() async {
|
||||||
try {
|
try { return Firebase.apps.isNotEmpty && _firestore != null; } catch (_) { return false; }
|
||||||
if (Firebase.apps.isEmpty) return false;
|
|
||||||
if (_firestore == null) return false;
|
|
||||||
return true;
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> requiresAuthentication() async {
|
Future<bool> requiresAuthentication() async => _authService.currentUser == null;
|
||||||
final user = _authService.currentUser;
|
|
||||||
if (user == null) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SyncResult> uploadRide(SyncItem item) async {
|
Future<SyncResult> uploadRide(SyncItem item) async {
|
||||||
final available = await isAvailable();
|
if (!await isAvailable()) return SyncResult.temporaryFailure('Firebase services are unavailable or unconfigured. Ride remains safely stored locally.');
|
||||||
if (!available) {
|
|
||||||
return SyncResult.temporaryFailure(
|
|
||||||
'Firebase services are unavailable or unconfigured. Ride remains safely stored locally.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final user = _authService.currentUser;
|
final user = _authService.currentUser;
|
||||||
if (user == null) {
|
if (user == null || user.isAnonymous) return SyncResult.authRequired(message: 'Sign in to MotorCloud is required for cloud backup.');
|
||||||
return SyncResult.authRequired(
|
|
||||||
message: 'Sign in to MotorCloud is required for cloud backup.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final idToken = await _authService.getIdToken();
|
final idToken = await _authService.getIdToken();
|
||||||
if (idToken == null || idToken.isEmpty) {
|
if (idToken == null || idToken.isEmpty) return SyncResult.authRequired(message: 'Valid Firebase authentication token required for cloud sync.');
|
||||||
return SyncResult.authRequired(
|
|
||||||
message: 'Valid Firebase authentication token required for cloud sync.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Read authoritative local ride record
|
|
||||||
final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(item.localRideId))).getSingleOrNull();
|
final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(item.localRideId))).getSingleOrNull();
|
||||||
if (ride == null) {
|
if (ride == null) return SyncResult.permanentFailure('Local ride #${item.localRideId} not found in database.');
|
||||||
return SyncResult.permanentFailure(
|
|
||||||
'Local ride #${item.localRideId} not found in database.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Generate deterministic remote document and bundle identifiers
|
// The cloud identity is generated once and then reused forever. Never
|
||||||
final dateStr = DateFormat('yyyyMMdd_HHmmss').format(ride.startTime.toUtc());
|
// derive the remote identity from the local auto-increment database ID.
|
||||||
final remoteRideId = 'ride_${item.localRideId}_$dateStr';
|
final remoteRideId = await _db.ensureCloudRideId(item.localRideId);
|
||||||
final appIdentity = AppIdentity.current();
|
final appIdentity = AppIdentity.current();
|
||||||
|
|
||||||
// 3. Generate compressed forensic bundle in background worker isolate
|
|
||||||
final zipBytes = await _bundleWorker.generateCompressedBundle(item.localRideId);
|
final zipBytes = await _bundleWorker.generateCompressedBundle(item.localRideId);
|
||||||
if (zipBytes == null || zipBytes.isEmpty) {
|
if (zipBytes == null || zipBytes.isEmpty) return SyncResult.permanentFailure('Failed generating compressed telemetry bundle for ride #${item.localRideId}.');
|
||||||
return SyncResult.permanentFailure(
|
|
||||||
'Failed generating compressed telemetry bundle for ride #${item.localRideId}.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final sha256Hash = sha256.convert(zipBytes).toString();
|
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(
|
final r2Result = await _r2UploadService.uploadTelemetryBundle(
|
||||||
idToken: idToken,
|
idToken: idToken,
|
||||||
remoteRideId: remoteRideId,
|
remoteRideId: remoteRideId,
|
||||||
|
|
@ -118,29 +66,21 @@ class FirebaseSyncProvider implements SyncProvider {
|
||||||
sha256Hash: sha256Hash,
|
sha256Hash: sha256Hash,
|
||||||
appVersion: appIdentity.semanticVersion,
|
appVersion: appIdentity.semanticVersion,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!r2Result.success) {
|
if (!r2Result.success) {
|
||||||
if (r2Result.requiresAuth) {
|
if (r2Result.requiresAuth) return SyncResult.authRequired(message: r2Result.errorMessage ?? 'R2 Gateway authentication required');
|
||||||
return SyncResult.authRequired(message: r2Result.errorMessage ?? 'R2 Gateway authentication required');
|
if (r2Result.isRetryable) return SyncResult.temporaryFailure(r2Result.errorMessage ?? 'Temporary R2 upload error');
|
||||||
}
|
|
||||||
if (r2Result.isRetryable) {
|
|
||||||
return SyncResult.temporaryFailure(r2Result.errorMessage ?? 'Temporary R2 upload error');
|
|
||||||
}
|
|
||||||
return SyncResult.permanentFailure(r2Result.errorMessage ?? 'Permanent R2 upload rejection');
|
return SyncResult.permanentFailure(r2Result.errorMessage ?? 'Permanent R2 upload rejection');
|
||||||
}
|
}
|
||||||
|
|
||||||
final objectKey = r2Result.objectKey ?? 'users/${user.uid}/rides/$remoteRideId/telemetry.zip';
|
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 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 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 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>{
|
final firestorePayload = <String, dynamic>{
|
||||||
|
'id': remoteRideId,
|
||||||
|
'ownerId': user.uid,
|
||||||
'localRideId': ride.id,
|
'localRideId': ride.id,
|
||||||
'remoteRideId': remoteRideId,
|
|
||||||
'uid': user.uid,
|
|
||||||
'startTime': ride.startTime.toUtc().toIso8601String(),
|
'startTime': ride.startTime.toUtc().toIso8601String(),
|
||||||
'endTime': ride.endTime?.toUtc().toIso8601String(),
|
'endTime': ride.endTime?.toUtc().toIso8601String(),
|
||||||
'distanceMiles': ride.distanceMiles,
|
'distanceMiles': ride.distanceMiles,
|
||||||
|
|
@ -165,49 +105,22 @@ class FirebaseSyncProvider implements SyncProvider {
|
||||||
'extremeLeanEvents': events.where((e) => e.eventType.contains('Lean') || e.eventType.contains('Corner')).length,
|
'extremeLeanEvents': events.where((e) => e.eventType.contains('Lean') || e.eventType.contains('Corner')).length,
|
||||||
'highSpeedEvents': events.where((e) => e.eventType.contains('Speed')).length,
|
'highSpeedEvents': events.where((e) => e.eventType.contains('Speed')).length,
|
||||||
'totalEventsCount': events.length,
|
'totalEventsCount': events.length,
|
||||||
'performanceRuns': performanceRuns.map((r) => {
|
'performanceRuns': performanceRuns.map((r) => {'type': r.type, 'timeMs': r.timeMs, 'topSpeedReached': r.topSpeedReached, 'timestamp': r.timestamp.toUtc().toIso8601String()}).toList(),
|
||||||
'type': r.type,
|
'telemetryBundle': {'provider': 'cloudflare-r2', 'objectKey': objectKey, 'status': 'uploaded', 'bundleSizeBytes': zipBytes.length, 'sha256': sha256Hash},
|
||||||
'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,
|
'telemetryBundlePath': objectKey,
|
||||||
'bundleSizeBytes': zipBytes.length,
|
'bundleSizeBytes': zipBytes.length,
|
||||||
'syncVersion': 2,
|
'syncVersion': 3,
|
||||||
'uploadedAt': FieldValue.serverTimestamp(),
|
'uploadedAt': FieldValue.serverTimestamp(),
|
||||||
|
'updatedAt': FieldValue.serverTimestamp(),
|
||||||
'software': appIdentity.toJson(),
|
'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);
|
return SyncResult.success(remoteId: remoteRideId);
|
||||||
} on FirebaseException catch (e) {
|
} on FirebaseException catch (e) {
|
||||||
if (e.code == 'permission-denied' || e.code == 'unauthenticated') {
|
if (e.code == 'permission-denied' || e.code == 'unauthenticated') return SyncResult.authRequired(message: 'Firebase authorization error: ${e.message}');
|
||||||
return SyncResult.authRequired(
|
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}');
|
||||||
message: 'Firebase authorization error: ${e.message}',
|
return SyncResult.permanentFailure('Firebase sync error (${e.code}): ${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) {
|
} catch (e) {
|
||||||
return SyncResult.temporaryFailure('Unexpected sync exception: $e');
|
return SyncResult.temporaryFailure('Unexpected sync exception: $e');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue