523 lines
19 KiB
Dart
523 lines
19 KiB
Dart
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<bool> isAvailable() async {
|
|
return _firestore != null;
|
|
}
|
|
|
|
@override
|
|
Future<bool> requiresAuthentication() async {
|
|
final user = _authService.currentUser;
|
|
return user == null || user.isAnonymous;
|
|
}
|
|
|
|
@override
|
|
Future<bool> isRideConfirmedInCloud(String remoteRideId) async {
|
|
final result = await verifyCloudConfirmation(remoteRideId);
|
|
return result.isConfirmed || result.isLegacyUnverified;
|
|
}
|
|
|
|
@override
|
|
Future<CloudConfirmationResult> 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<Map<String, dynamic>> 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 syncVersion = (data['syncVersion'] as num?)?.toInt() ?? 1;
|
|
final expectedSha256 = (bundle['sha256'] ?? data['sha256'])?.toString();
|
|
final expectedSize =
|
|
(bundle['bundleSizeBytes'] ?? data['bundleSizeBytes']) as num?;
|
|
|
|
// For modern bundles (syncVersion >= 3), require positive size and non-empty sha256
|
|
if (syncVersion >= 3) {
|
|
if (expectedSha256 == null || expectedSha256.trim().isEmpty) {
|
|
return CloudConfirmationResult.inconsistent(
|
|
remoteRideId: remoteRideId,
|
|
ownerUid: user.uid,
|
|
objectKey: expectedObjectKey,
|
|
message:
|
|
'Modern bundle (syncVersion >= 3) missing required sha256 metadata.',
|
|
);
|
|
}
|
|
if (expectedSize == null || expectedSize <= 0) {
|
|
return CloudConfirmationResult.inconsistent(
|
|
remoteRideId: remoteRideId,
|
|
ownerUid: user.uid,
|
|
objectKey: expectedObjectKey,
|
|
message:
|
|
'Modern bundle (syncVersion >= 3) has invalid bundleSizeBytes ($expectedSize).',
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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.',
|
|
);
|
|
}
|
|
|
|
var r2Result = await _r2UploadService.verifyTelemetryBundle(
|
|
idToken: idToken,
|
|
remoteRideId: remoteRideId,
|
|
expectedSha256: expectedSha256,
|
|
expectedSize: expectedSize?.toInt(),
|
|
);
|
|
|
|
// If verification is rejected as unauthorized (e.g. expired cached token),
|
|
// force refresh the ID token once and retry before concluding unauthorized.
|
|
if (r2Result.status == CloudConfirmationStatus.unauthorized) {
|
|
final refreshedToken = await _authService.getIdToken(true);
|
|
if (refreshedToken != null &&
|
|
refreshedToken.isNotEmpty &&
|
|
refreshedToken != idToken) {
|
|
idToken = refreshedToken;
|
|
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.legacyUnverified:
|
|
return CloudConfirmationResult.legacyUnverified(
|
|
remoteRideId: remoteRideId,
|
|
ownerUid: user.uid,
|
|
objectKey: r2Result.metadata?.objectKey ?? expectedObjectKey,
|
|
sizeBytes: r2Result.metadata?.size ?? expectedSize?.toInt(),
|
|
sha256: r2Result.metadata?.sha256 ?? expectedSha256,
|
|
message:
|
|
r2Result.errorMessage ??
|
|
'Historical bundle verified in R2 without full checksum metadata.',
|
|
);
|
|
|
|
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<SyncResult> 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 || confirmation.isLegacyUnverified) {
|
|
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 = <String, dynamic>{
|
|
'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');
|
|
}
|
|
}
|
|
}
|