Merge pull request #37 from bponders12-cell/fix/v1.1-completed-ride-sync-recovery

fix(sync): completed-ride outbox recovery and cloud confirmation reconciliation
This commit is contained in:
Billie Ponder 2026-10-14 17:11:43 -04:00 committed by GitHub
commit f5b734cccd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 3942 additions and 128 deletions

View File

@ -16,14 +16,21 @@ class FirebaseSyncProvider implements SyncProvider {
final R2UploadService _r2UploadService; final R2UploadService _r2UploadService;
final SyncBundleWorker _bundleWorker; final SyncBundleWorker _bundleWorker;
FirebaseSyncProvider({required AppDatabase db, required this._authService, FirebaseFirestore? firestore, R2UploadService? r2UploadService, SyncBundleWorker? bundleWorker}) FirebaseSyncProvider({
: _db = db, required AppDatabase db,
_firestore = firestore ?? _resolveFirestore(), required this._authService,
_r2UploadService = r2UploadService ?? R2UploadService(), FirebaseFirestore? firestore,
_bundleWorker = bundleWorker ?? SyncBundleWorker(db); R2UploadService? r2UploadService,
SyncBundleWorker? bundleWorker,
}) : _db = db,
_firestore = firestore ?? _resolveFirestore(),
_r2UploadService = r2UploadService ?? R2UploadService(),
_bundleWorker = bundleWorker ?? SyncBundleWorker(db);
static FirebaseFirestore? _resolveFirestore() { static FirebaseFirestore? _resolveFirestore() {
try { if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; } catch (_) {} try {
if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance;
} catch (_) {}
return null; return null;
} }
@ -32,7 +39,7 @@ class FirebaseSyncProvider implements SyncProvider {
@override @override
Future<bool> isAvailable() async { Future<bool> isAvailable() async {
try { return Firebase.apps.isNotEmpty && _firestore != null; } catch (_) { return false; } return _firestore != null;
} }
@override @override
@ -41,12 +48,263 @@ class FirebaseSyncProvider implements SyncProvider {
return user == null || user.isAnonymous; 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 @override
Future<SyncResult> uploadRide(SyncItem item) async { Future<SyncResult> uploadRide(SyncItem item) async {
final user = _authService.currentUser; final user = _authService.currentUser;
if (user == null || user.isAnonymous) 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 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 && ride.cloudOwnerUid != user.uid) { if (ride != null && ride.cloudOwnerUid != user.uid) {
return SyncResult.permanentFailure( return SyncResult.permanentFailure(
ride.cloudOwnerUid == null ride.cloudOwnerUid == null
@ -54,7 +312,16 @@ class FirebaseSyncProvider implements SyncProvider {
: 'This ride belongs to another MotorCloud account and was not uploaded.', : '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 (!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 // Refresh the Firebase ID token before a manual upload. This prevents a
// recently completed sign-in from being rejected because the cached token // recently completed sign-in from being rejected because the cached token
@ -63,18 +330,46 @@ class FirebaseSyncProvider implements SyncProvider {
if (idToken == null || idToken.isEmpty) { if (idToken == null || idToken.isEmpty) {
idToken = await _authService.getIdToken(true); idToken = await _authService.getIdToken(true);
} }
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 { try {
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.');
// The cloud identity is generated once and then reused forever. Never // The cloud identity is generated once and then reused forever. Never
// derive the remote identity from the local auto-increment database ID. // derive the remote identity from the local auto-increment database ID.
final remoteRideId = await _db.ensureCloudRideId(item.localRideId); 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 appIdentity = AppIdentity.current();
final zipBytes = await _bundleWorker.generateCompressedBundle(item.localRideId); final zipBytes = await _bundleWorker.generateCompressedBundle(
if (zipBytes == null || zipBytes.isEmpty) return SyncResult.permanentFailure('Failed generating compressed telemetry bundle for ride #${item.localRideId}.'); 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(); final sha256Hash = sha256.convert(zipBytes).toString();
var r2Result = await _r2UploadService.uploadTelemetryBundle( var r2Result = await _r2UploadService.uploadTelemetryBundle(
@ -90,7 +385,9 @@ class FirebaseSyncProvider implements SyncProvider {
// turning a valid signed-in session into a false authentication error. // turning a valid signed-in session into a false authentication error.
if (!r2Result.success && r2Result.requiresAuth) { if (!r2Result.success && r2Result.requiresAuth) {
final refreshedToken = await _authService.getIdToken(true); final refreshedToken = await _authService.getIdToken(true);
if (refreshedToken != null && refreshedToken.isNotEmpty && refreshedToken != idToken) { if (refreshedToken != null &&
refreshedToken.isNotEmpty &&
refreshedToken != idToken) {
idToken = refreshedToken; idToken = refreshedToken;
r2Result = await _r2UploadService.uploadTelemetryBundle( r2Result = await _r2UploadService.uploadTelemetryBundle(
idToken: idToken, idToken: idToken,
@ -103,15 +400,39 @@ class FirebaseSyncProvider implements SyncProvider {
} }
if (!r2Result.success) { if (!r2Result.success) {
if (r2Result.requiresAuth) return SyncResult.authRequired(message: r2Result.errorMessage ?? 'R2 Gateway authentication required'); if (r2Result.requiresAuth) {
if (r2Result.isRetryable) return SyncResult.temporaryFailure(r2Result.errorMessage ?? 'Temporary R2 upload error'); return SyncResult.authRequired(
return SyncResult.permanentFailure(r2Result.errorMessage ?? 'Permanent R2 upload rejection'); 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'; final expectedObjectKey =
final events = await (_db.select(_db.rideEvents)..where((e) => e.rideId.equals(item.localRideId))).get(); 'users/${user.uid}/rides/$remoteRideId/telemetry.zip';
final performanceRuns = await (_db.select(_db.performanceRuns)..where((p) => p.rideId.equals(item.localRideId))).get(); final objectKey = r2Result.objectKey ?? expectedObjectKey;
final summary = await (_db.select(_db.fieldTestSummaries)..where((s) => s.rideId.equals(item.localRideId))).getSingleOrNull(); 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>{ final firestorePayload = <String, dynamic>{
'id': remoteRideId, 'id': remoteRideId,
@ -137,12 +458,37 @@ class FirebaseSyncProvider implements SyncProvider {
'motorcycleModel': ride.motorcycleModel, 'motorcycleModel': ride.motorcycleModel,
'motorcycleYear': ride.motorcycleYear, 'motorcycleYear': ride.motorcycleYear,
'motorcycleVariant': ride.motorcycleVariant, 'motorcycleVariant': ride.motorcycleVariant,
'hardBrakingEvents': events.where((e) => e.eventType.contains('Braking')).length, 'hardBrakingEvents': events
'extremeLeanEvents': events.where((e) => e.eventType.contains('Lean') || e.eventType.contains('Corner')).length, .where((e) => e.eventType.contains('Braking'))
'highSpeedEvents': events.where((e) => e.eventType.contains('Speed')).length, .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, 'totalEventsCount': events.length,
'performanceRuns': performanceRuns.map((r) => {'type': r.type, 'timeMs': r.timeMs, 'topSpeedReached': r.topSpeedReached, 'timestamp': r.timestamp.toUtc().toIso8601String()}).toList(), 'performanceRuns': performanceRuns
'telemetryBundle': {'provider': 'cloudflare-r2', 'objectKey': objectKey, 'status': 'uploaded', 'bundleSizeBytes': zipBytes.length, 'sha256': sha256Hash}, .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, 'telemetryBundlePath': objectKey,
'bundleSizeBytes': zipBytes.length, 'bundleSizeBytes': zipBytes.length,
'syncVersion': 3, 'syncVersion': 3,
@ -154,9 +500,21 @@ class FirebaseSyncProvider implements SyncProvider {
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') return SyncResult.authRequired(message: 'Firebase authorization error: ${e.message}'); if (e.code == 'permission-denied' || e.code == 'unauthenticated') {
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.authRequired(
return SyncResult.permanentFailure('Firebase sync error (${e.code}): ${e.message}'); 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) { } catch (e) {
return SyncResult.temporaryFailure('Unexpected sync exception: $e'); return SyncResult.temporaryFailure('Unexpected sync exception: $e');
} }

View File

@ -9,10 +9,7 @@ class GitHubSyncProvider implements SyncProvider {
final GitHubSyncService service; final GitHubSyncService service;
final GitHubAuthService auth; final GitHubAuthService auth;
GitHubSyncProvider({ GitHubSyncProvider({required this.service, required this.auth});
required this.service,
required this.auth,
});
@override @override
String get providerId => 'github'; String get providerId => 'github';
@ -23,12 +20,21 @@ class GitHubSyncProvider implements SyncProvider {
@override @override
Future<bool> requiresAuthentication() async => auth.getToken() == null; Future<bool> requiresAuthentication() async => auth.getToken() == null;
@override
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
@override
Future<CloudConfirmationResult> verifyCloudConfirmation(
String remoteRideId,
) async => CloudConfirmationResult.missing(remoteRideId: remoteRideId);
@override @override
Future<SyncResult> uploadRide(SyncItem item) async { Future<SyncResult> uploadRide(SyncItem item) async {
final hasAuth = auth.getToken() != null; final hasAuth = auth.getToken() != null;
if (!hasAuth) { if (!hasAuth) {
return SyncResult.authRequired( return SyncResult.authRequired(
message: 'GitHub developer export requires an explicitly configured GitHub token.', message:
'GitHub developer export requires an explicitly configured GitHub token.',
); );
} }

View File

@ -2,6 +2,95 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'sync_provider.dart';
@immutable
class R2VerificationMetadata {
final bool verified;
final String remoteRideId;
final String? objectKey;
final int? size;
final String? sha256;
final String? etag;
const R2VerificationMetadata({
required this.verified,
required this.remoteRideId,
this.objectKey,
this.size,
this.sha256,
this.etag,
});
}
@immutable
class R2VerificationResponse {
final CloudConfirmationStatus status;
final String? errorMessage;
final int? statusCode;
final R2VerificationMetadata? metadata;
const R2VerificationResponse({
required this.status,
this.errorMessage,
this.statusCode,
this.metadata,
});
const R2VerificationResponse.confirmed(
R2VerificationMetadata metadata, {
int statusCode = 200,
}) : this(
status: CloudConfirmationStatus.confirmed,
statusCode: statusCode,
metadata: metadata,
);
const R2VerificationResponse.legacyUnverified(
R2VerificationMetadata metadata, {
int statusCode = 200,
String? message,
}) : this(
status: CloudConfirmationStatus.legacyUnverified,
statusCode: statusCode,
metadata: metadata,
errorMessage: message,
);
const R2VerificationResponse.missing(String message, {int statusCode = 404})
: this(
status: CloudConfirmationStatus.missing,
errorMessage: message,
statusCode: statusCode,
);
const R2VerificationResponse.inconsistent(
String message, {
int statusCode = 409,
}) : this(
status: CloudConfirmationStatus.inconsistent,
errorMessage: message,
statusCode: statusCode,
);
const R2VerificationResponse.unauthorized(
String message, {
int statusCode = 401,
}) : this(
status: CloudConfirmationStatus.unauthorized,
errorMessage: message,
statusCode: statusCode,
);
const R2VerificationResponse.temporarilyUnverifiable(
String message, {
int? statusCode,
}) : this(
status: CloudConfirmationStatus.temporarilyUnverifiable,
errorMessage: message,
statusCode: statusCode,
);
}
@immutable @immutable
class R2UploadResult { class R2UploadResult {
@ -12,59 +101,381 @@ class R2UploadResult {
final int? statusCode; final int? statusCode;
final bool isRetryable; final bool isRetryable;
final bool requiresAuth; final bool requiresAuth;
const R2UploadResult({required this.success, this.objectKey, this.bytesWritten, this.errorMessage, this.statusCode, this.isRetryable = true, this.requiresAuth = false}); const R2UploadResult({
const R2UploadResult.success({required String objectKey, int? bytesWritten}) : this(success: true, objectKey: objectKey, bytesWritten: bytesWritten, statusCode: 200, isRetryable: false); required this.success,
const R2UploadResult.authRequired(String message, {int statusCode = 401}) : this(success: false, errorMessage: message, statusCode: statusCode, isRetryable: true, requiresAuth: true); this.objectKey,
const R2UploadResult.temporaryFailure(String message, {int? statusCode}) : this(success: false, errorMessage: message, statusCode: statusCode, isRetryable: true); this.bytesWritten,
const R2UploadResult.permanentFailure(String message, {int? statusCode}) : this(success: false, errorMessage: message, statusCode: statusCode, isRetryable: false); this.errorMessage,
this.statusCode,
this.isRetryable = true,
this.requiresAuth = false,
});
const R2UploadResult.success({required String objectKey, int? bytesWritten})
: this(
success: true,
objectKey: objectKey,
bytesWritten: bytesWritten,
statusCode: 200,
isRetryable: false,
);
const R2UploadResult.authRequired(String message, {int statusCode = 401})
: this(
success: false,
errorMessage: message,
statusCode: statusCode,
isRetryable: true,
requiresAuth: true,
);
const R2UploadResult.temporaryFailure(String message, {int? statusCode})
: this(
success: false,
errorMessage: message,
statusCode: statusCode,
isRetryable: true,
);
const R2UploadResult.permanentFailure(String message, {int? statusCode})
: this(
success: false,
errorMessage: message,
statusCode: statusCode,
isRetryable: false,
);
} }
/// Uploads immutable ride telemetry bundles through the authenticated Worker gateway. /// Uploads immutable ride telemetry bundles through the authenticated Worker gateway.
class R2UploadService { class R2UploadService {
static const String defaultWorkerUrl = String.fromEnvironment('R2_WORKER_URL', defaultValue: 'https://motorsignal-r2-worker.bponders12.workers.dev'); static const String defaultWorkerUrl = String.fromEnvironment(
'R2_WORKER_URL',
defaultValue: 'https://motorsignal-r2-worker.bponders12.workers.dev',
);
final http.Client _client; final http.Client _client;
final String _workerBaseUrl; final String _workerBaseUrl;
R2UploadService({http.Client? client, String? workerBaseUrl}) : _client = client ?? http.Client(), _workerBaseUrl = (workerBaseUrl ?? defaultWorkerUrl).replaceAll(RegExp(r'/+$'), ''); R2UploadService({http.Client? client, String? workerBaseUrl})
: _client = client ?? http.Client(),
_workerBaseUrl = (workerBaseUrl ?? defaultWorkerUrl).replaceAll(
RegExp(r'/+$'),
'',
);
String get workerBaseUrl => _workerBaseUrl; String get workerBaseUrl => _workerBaseUrl;
Future<R2UploadResult> uploadTelemetryBundle({required String idToken, required String remoteRideId, required Uint8List zipBytes, required String sha256Hash, required String appVersion, Duration timeout = const Duration(seconds: 45)}) async { Future<R2UploadResult> uploadTelemetryBundle({
if (idToken.trim().isEmpty) return const R2UploadResult.authRequired('Firebase ID token is required for R2 upload.'); required String idToken,
if (zipBytes.isEmpty) return const R2UploadResult.permanentFailure('Telemetry bundle is empty.', statusCode: 400); required String remoteRideId,
required Uint8List zipBytes,
required String sha256Hash,
required String appVersion,
Duration timeout = const Duration(seconds: 45),
}) async {
if (idToken.trim().isEmpty) {
return const R2UploadResult.authRequired(
'Firebase ID token is required for R2 upload.',
);
}
if (zipBytes.isEmpty) {
return const R2UploadResult.permanentFailure(
'Telemetry bundle is empty.',
statusCode: 400,
);
}
// Prevent path traversal at the client boundary. The Worker remains the // Prevent path traversal at the client boundary. The Worker remains the
// authoritative verifier of SHA-256 and Firebase UID ownership. // authoritative verifier of SHA-256 and Firebase UID ownership.
if (!RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(remoteRideId)) return const R2UploadResult.permanentFailure('Invalid remote ride identifier.', statusCode: 400); if (!RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(remoteRideId)) {
if (sha256Hash.trim().isEmpty) return const R2UploadResult.permanentFailure('Telemetry bundle SHA-256 is required.', statusCode: 400); return const R2UploadResult.permanentFailure(
'Invalid remote ride identifier.',
statusCode: 400,
);
}
if (sha256Hash.trim().isEmpty) {
return const R2UploadResult.permanentFailure(
'Telemetry bundle SHA-256 is required.',
statusCode: 400,
);
}
try { try {
final response = await _client.put(Uri.parse('$_workerBaseUrl/r2/upload'), headers: { final response = await _client
'Authorization': 'Bearer ${idToken.trim()}', .put(
'Content-Type': 'application/zip', Uri.parse('$_workerBaseUrl/r2/upload'),
'X-Remote-Ride-Id': remoteRideId, headers: {
'X-Bundle-Sha256': sha256Hash.trim().toLowerCase(), 'Authorization': 'Bearer ${idToken.trim()}',
'X-App-Version': appVersion, 'Content-Type': 'application/zip',
}, body: zipBytes).timeout(timeout); 'X-Remote-Ride-Id': remoteRideId,
'X-Bundle-Sha256': sha256Hash.trim().toLowerCase(),
'X-App-Version': appVersion,
},
body: zipBytes,
)
.timeout(timeout);
final status = response.statusCode; final status = response.statusCode;
if (status >= 200 && status < 300) { if (status >= 200 && status < 300) {
try { try {
final decoded = jsonDecode(response.body); final decoded = jsonDecode(response.body);
if (decoded is! Map) throw const FormatException('Invalid worker response'); if (decoded is! Map) {
throw const FormatException('Invalid worker response');
}
final data = Map<String, dynamic>.from(decoded); final data = Map<String, dynamic>.from(decoded);
final objectKey = data['objectKey']; final objectKey = data['objectKey'];
if (objectKey is! String || !objectKey.endsWith('/rides/$remoteRideId/telemetry.zip') || !objectKey.startsWith('users/')) return const R2UploadResult.permanentFailure('R2 worker returned an invalid object key.'); if (objectKey is! String ||
!objectKey.endsWith('/rides/$remoteRideId/telemetry.zip') ||
!objectKey.startsWith('users/')) {
return const R2UploadResult.permanentFailure(
'R2 worker returned an invalid object key.',
);
}
final size = data['size']; final size = data['size'];
final bytes = size is num && size >= 0 ? size.toInt() : zipBytes.length; final bytes = size is num && size >= 0
return R2UploadResult.success(objectKey: objectKey, bytesWritten: bytes); ? size.toInt()
: zipBytes.length;
return R2UploadResult.success(
objectKey: objectKey,
bytesWritten: bytes,
);
} catch (e) { } catch (e) {
return R2UploadResult.temporaryFailure('R2 upload response could not be verified: $e', statusCode: status); return R2UploadResult.temporaryFailure(
'R2 upload response could not be verified: $e',
statusCode: status,
);
} }
} }
if (status == 401 || status == 403) return R2UploadResult.authRequired('R2 Worker authorization denied ($status).', statusCode: status); if (status == 401 || status == 403) {
if (status == 400 || status == 404 || status == 409 || status == 422) return R2UploadResult.permanentFailure('R2 Worker rejected the bundle ($status).', statusCode: status); return R2UploadResult.authRequired(
return R2UploadResult.temporaryFailure('R2 Worker temporary error ($status).', statusCode: status); 'R2 Worker authorization denied ($status).',
statusCode: status,
);
}
if (status == 400 || status == 404 || status == 409 || status == 422) {
return R2UploadResult.permanentFailure(
'R2 Worker rejected the bundle ($status).',
statusCode: status,
);
}
return R2UploadResult.temporaryFailure(
'R2 Worker temporary error ($status).',
statusCode: status,
);
} on TimeoutException { } on TimeoutException {
return R2UploadResult.temporaryFailure('Upload to Cloudflare R2 timed out after ${timeout.inSeconds} seconds.'); return R2UploadResult.temporaryFailure(
'Upload to Cloudflare R2 timed out after ${timeout.inSeconds} seconds.',
);
} catch (e) { } catch (e) {
return R2UploadResult.temporaryFailure('Network error uploading to Cloudflare R2: $e'); return R2UploadResult.temporaryFailure(
'Network error uploading to Cloudflare R2: $e',
);
}
}
/// Lightweight verification of telemetry bundle storage and integrity in Cloudflare R2.
/// Queries the worker via /r2/verify to verify existence, metadata, and hash match
/// without downloading the full ZIP archive.
///
/// CRITICAL ARCHITECTURAL DISTINCTION:
/// R2 HEAD / metadata checks prove that an object exists in Cloudflare R2 and that its stored
/// metadata (SHA-256 hash, byte size, object key, remote ride identity) matches the expected
/// Firestore and local contract.
/// They do NOT independently prove that the stored ZIP archive's binary payload is readable,
/// decompresses cleanly, or is uncorrupted at rest. Full bundle downloads on startup are
/// intentionally avoided to protect mobile bandwidth and startup latency. For future stronger
/// verification, background sampling or periodic full restore-integrity checks should be
/// scheduled rather than downloading every bundle on every launch or reconciliation pass.
Future<R2VerificationResponse> verifyTelemetryBundle({
required String idToken,
required String remoteRideId,
String? expectedSha256,
int? expectedSize,
Duration timeout = const Duration(seconds: 15),
}) async {
if (idToken.trim().isEmpty) {
return const R2VerificationResponse.unauthorized(
'Firebase ID token is required for verification.',
);
}
if (!RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(remoteRideId)) {
return const R2VerificationResponse.inconsistent(
'Invalid remote ride identifier format.',
);
}
try {
final uri = Uri.parse('$_workerBaseUrl/r2/verify?rideId=$remoteRideId');
final response = await _client
.get(
uri,
headers: {
'Authorization': 'Bearer ${idToken.trim()}',
'Accept': 'application/json',
},
)
.timeout(timeout);
final status = response.statusCode;
if (status >= 200 && status < 300) {
try {
final decoded = jsonDecode(response.body);
if (decoded is! Map) {
return const R2VerificationResponse.inconsistent(
'Worker verification returned invalid response structure.',
);
}
final data = Map<String, dynamic>.from(decoded);
final respRideId = data['remoteRideId']?.toString();
if (respRideId == null || respRideId != remoteRideId) {
return R2VerificationResponse.inconsistent(
'Worker verification rideId ($respRideId) does not match expected ($remoteRideId).',
statusCode: status,
);
}
final objectKey = data['objectKey']?.toString();
if (objectKey == null ||
!objectKey.endsWith('/rides/$remoteRideId/telemetry.zip') ||
!objectKey.startsWith('users/')) {
return R2VerificationResponse.inconsistent(
'Worker verification objectKey ($objectKey) does not match expected pattern.',
statusCode: status,
);
}
final respSize = (data['size'] as num?)?.toInt();
if (respSize == null || respSize <= 0) {
return R2VerificationResponse.inconsistent(
'Worker verification returned invalid or non-positive object size ($respSize).',
statusCode: status,
);
}
if (expectedSize != null &&
expectedSize > 0 &&
expectedSize != respSize) {
return R2VerificationResponse.inconsistent(
'Size mismatch: expected $expectedSize bytes but got $respSize bytes.',
statusCode: 422,
);
}
final respSha256 = data['sha256']?.toString();
if (expectedSha256 != null && expectedSha256.trim().isNotEmpty) {
if (respSha256 == null || respSha256.trim().isEmpty) {
return R2VerificationResponse.inconsistent(
'Missing required SHA-256 in R2 worker response for ride with expected hash $expectedSha256.',
statusCode: status,
);
}
if (expectedSha256.trim().toLowerCase() !=
respSha256.trim().toLowerCase()) {
return R2VerificationResponse.inconsistent(
'SHA-256 checksum mismatch: expected $expectedSha256 but got $respSha256.',
statusCode: 422,
);
}
}
final metadata = R2VerificationMetadata(
verified: true,
remoteRideId: remoteRideId,
objectKey: objectKey,
size: respSize,
sha256: respSha256,
etag: data['etag']?.toString(),
);
// If bundle lacks modern checksum metadata, report as legacyUnverified rather than confirmed.
if (expectedSha256 == null ||
expectedSha256.trim().isEmpty ||
respSha256 == null ||
respSha256.trim().isEmpty) {
return R2VerificationResponse.legacyUnverified(
metadata,
statusCode: status,
message:
'Historical bundle verified in R2 without full checksum metadata.',
);
}
return R2VerificationResponse.confirmed(metadata, statusCode: status);
} catch (e) {
return R2VerificationResponse.temporarilyUnverifiable(
'Failed to parse worker verification response: $e',
statusCode: status,
);
}
}
if (status == 401 || status == 403) {
return R2VerificationResponse.unauthorized(
'R2 verification unauthorized ($status): ${response.body}',
statusCode: status,
);
}
if (status == 404) {
// Distinguish between:
// 1. Live worker without /r2/verify endpoint ("Endpoint not found") -> temporarilyUnverifiable
// 2. Non-JSON 404 (e.g. Cloudflare proxy / edge HTML 404) -> temporarilyUnverifiable
// 3. Telemetry bundle or ride not found from API -> missing
try {
final decoded = jsonDecode(response.body);
if (decoded is Map) {
final error = decoded['error']?.toString();
final code = decoded['code']?.toString();
if (error == 'Endpoint not found') {
return const R2VerificationResponse.temporarilyUnverifiable(
'R2 verification endpoint not yet deployed on worker.',
statusCode: 404,
);
}
if (code == 'OBJECT_NOT_FOUND' ||
code == 'NOT_FOUND' ||
error == 'Telemetry bundle not found in R2' ||
error == 'Ride not found') {
return R2VerificationResponse.missing(
error ?? 'Telemetry bundle or ride not found in cloud.',
statusCode: 404,
);
}
}
} catch (_) {
return const R2VerificationResponse.temporarilyUnverifiable(
'Non-JSON 404 received from R2 gateway; worker endpoint may be undeployed or edge routing failed.',
statusCode: 404,
);
}
return R2VerificationResponse.missing(
'Telemetry bundle or ride not found in cloud ($status).',
statusCode: status,
);
}
if (status == 409 || status == 422) {
return R2VerificationResponse.inconsistent(
'Cloud metadata or object integrity rejected ($status): ${response.body}',
statusCode: status,
);
}
if (status == 429) {
return R2VerificationResponse.temporarilyUnverifiable(
'R2 verification rate limited (429).',
statusCode: status,
);
}
if (status >= 500) {
return R2VerificationResponse.temporarilyUnverifiable(
'R2 worker server error ($status).',
statusCode: status,
);
}
return R2VerificationResponse.temporarilyUnverifiable(
'Unexpected status ($status) from R2 worker.',
statusCode: status,
);
} on TimeoutException {
return R2VerificationResponse.temporarilyUnverifiable(
'R2 verification timed out after ${timeout.inSeconds}s.',
);
} catch (e) {
return R2VerificationResponse.temporarilyUnverifiable(
'Network error verifying with R2 worker: $e',
);
} }
} }
} }

View File

@ -6,6 +6,49 @@ import 'sync_provider.dart';
import '../../database/database.dart'; import '../../database/database.dart';
import '../auth/auth_service.dart'; import '../auth/auth_service.dart';
/// Diagnostic summary returned by [SyncManager.reconcileCompletedRides].
class CompletedRideReconciliationReport {
final int discoveredCompletedRides;
final int alreadyTrackedCount;
final int recoveredQueuedCount;
final int recoveredAuthRequiredCount;
final int recoveredAlreadySyncedCount;
final int recoveredLegacyUnverifiedCount;
final int foreignOwnerSkippedCount;
final int inconsistentSkippedCount;
final int unverifiablePendingCount;
final int errorCount;
final List<String> errors;
const CompletedRideReconciliationReport({
this.discoveredCompletedRides = 0,
this.alreadyTrackedCount = 0,
this.recoveredQueuedCount = 0,
this.recoveredAuthRequiredCount = 0,
this.recoveredAlreadySyncedCount = 0,
this.recoveredLegacyUnverifiedCount = 0,
this.foreignOwnerSkippedCount = 0,
this.inconsistentSkippedCount = 0,
this.unverifiablePendingCount = 0,
this.errorCount = 0,
this.errors = const [],
});
int get totalRecovered =>
recoveredQueuedCount +
recoveredAuthRequiredCount +
recoveredAlreadySyncedCount;
@override
String toString() =>
'CompletedRideReconciliationReport(discovered: $discoveredCompletedRides, '
'tracked: $alreadyTrackedCount, queued: $recoveredQueuedCount, '
'authRequired: $recoveredAuthRequiredCount, alreadySynced: $recoveredAlreadySyncedCount, '
'legacyUnverified: $recoveredLegacyUnverifiedCount, '
'foreignSkipped: $foreignOwnerSkippedCount, inconsistent: $inconsistentSkippedCount, '
'unverifiable: $unverifiablePendingCount, errors: $errorCount)';
}
/// Central provider-agnostic synchronization coordinator. /// Central provider-agnostic synchronization coordinator.
/// ///
/// Sync work is persisted in [SyncOutbox]. The manager reconciles persisted /// Sync work is persisted in [SyncOutbox]. The manager reconciles persisted
@ -16,23 +59,50 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
final Map<String, SyncProvider> _providers = {}; final Map<String, SyncProvider> _providers = {};
String _activeProviderId = 'fake'; String _activeProviderId = 'fake';
bool _isProcessing = false; bool _isProcessing = false;
Future<CompletedRideReconciliationReport>? _activeReconciliation;
Future<void>? _currentProcessingFuture;
bool _needsAnotherPass = false;
DateTime? Function() _nowProvider; DateTime? Function() _nowProvider;
final AppDatabase? _db; final AppDatabase? _db;
final AuthService? _authService; final AuthService? _authService;
SyncManager(this._outbox, {List<SyncProvider>? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider, this._db, this._authService}) : _nowProvider = nowProvider ?? (() => DateTime.now()) { SyncManager(
if (initialProviders != null) { this._outbox, {
for (final p in initialProviders) { registerProvider(p); } List<SyncProvider>? initialProviders,
String? defaultProviderId,
DateTime? Function()? nowProvider,
this._db,
this._authService,
bool autoStart = true,
}) : _nowProvider = nowProvider ?? (() => DateTime.now()) {
if (defaultProviderId != null) {
_activeProviderId = defaultProviderId;
} else if (initialProviders != null && initialProviders.isNotEmpty) {
_activeProviderId = initialProviders.first.providerId;
} else {
_activeProviderId = 'fake';
}
if (initialProviders != null) {
for (final p in initialProviders) {
_providers[p.providerId] = p;
}
} }
if (defaultProviderId != null) _activeProviderId = defaultProviderId;
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
unawaited(processQueue()); if (autoStart) {
unawaited(_startupRecoveryAndProcess());
}
} }
void setNowProvider(DateTime? Function() provider) => _nowProvider = provider; void setNowProvider(DateTime? Function() provider) => _nowProvider = provider;
DateTime _now() => _nowProvider() ?? DateTime.now(); DateTime _now() => _nowProvider() ?? DateTime.now();
String get activeProviderId => _activeProviderId; String get activeProviderId => _activeProviderId;
set activeProviderId(String id) { if (_activeProviderId != id) { _activeProviderId = id; notifyListeners(); } } set activeProviderId(String id) {
if (_activeProviderId != id) {
_activeProviderId = id;
notifyListeners();
}
}
bool get isProcessing => _isProcessing; bool get isProcessing => _isProcessing;
int get outboxCount => _outbox.count; int get outboxCount => _outbox.count;
List<SyncItem> get allItems => _outbox.getAllItems(); List<SyncItem> get allItems => _outbox.getAllItems();
@ -45,29 +115,79 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
for (final item in _outbox.getAllItems()) { for (final item in _outbox.getAllItems()) {
if (item.status == SyncStatus.synced) { if (item.status == SyncStatus.synced) {
synced++; synced++;
if (item.uploadedAt != null && (lastSuccess == null || item.uploadedAt!.isAfter(lastSuccess))) lastSuccess = item.uploadedAt; if (item.uploadedAt != null &&
} else if (item.status == SyncStatus.failed || item.status == SyncStatus.cancelled) { (lastSuccess == null || item.uploadedAt!.isAfter(lastSuccess))) {
lastSuccess = item.uploadedAt;
}
} else if (item.status == SyncStatus.failed ||
item.status == SyncStatus.cancelled) {
failed++; failed++;
if (item.lastError != null) lastErr = item.lastError; if (item.lastError != null) lastErr = item.lastError;
} else { } else {
pending++; pending++;
} }
} }
return SyncManagerState(isProcessing: _isProcessing, pendingCount: pending, syncedCount: synced, failedCount: failed, lastSuccessfulSync: lastSuccess, lastError: lastErr, activeProviderId: _activeProviderId); return SyncManagerState(
isProcessing: _isProcessing,
pendingCount: pending,
syncedCount: synced,
failedCount: failed,
lastSuccessfulSync: lastSuccess,
lastError: lastErr,
activeProviderId: _activeProviderId,
);
} }
void registerProvider(SyncProvider provider) { void registerProvider(SyncProvider provider) {
final wasRegistered = _providers.containsKey(provider.providerId);
_providers[provider.providerId] = provider; _providers[provider.providerId] = provider;
if (_activeProviderId == 'none' || _activeProviderId == 'fake') _activeProviderId = provider.providerId; if (_activeProviderId == 'none' || _activeProviderId == 'fake') {
_activeProviderId = provider.providerId;
}
notifyListeners(); notifyListeners();
if (!wasRegistered && _db != null) {
unawaited(reconcileCompletedRides(targetProviderId: provider.providerId));
unawaited(processQueue());
}
} }
SyncProvider? getProvider(String providerId) => _providers[providerId]; SyncProvider? getProvider(String providerId) => _providers[providerId];
Future<SyncItem> enqueueRide(int localRideId, {String? targetProviderId, bool autoProcess = true}) async { Future<SyncItem> enqueueRide(
int localRideId, {
String? targetProviderId,
bool autoProcess = true,
}) async {
final providerId = targetProviderId ?? _activeProviderId; final providerId = targetProviderId ?? _activeProviderId;
if (providerId == 'firebase') { if (providerId == 'firebase' && _db != null) {
final user = _authService?.currentUser; final user = _authService?.currentUser;
if (user != null && !user.isAnonymous && _db != null) { final isAuthenticated = user != null && !user.isAnonymous;
final rideOwner = await _db.getCloudOwnerUid(localRideId);
if (rideOwner != null && isAuthenticated && rideOwner != user.uid) {
final cancelledItem = SyncItem(
localRideId: localRideId,
providerId: providerId,
status: SyncStatus.cancelled,
createdAt: _now(),
lastError: 'Ride belongs to another account and cannot be uploaded',
);
await _outbox.update(cancelledItem);
notifyListeners();
return cancelledItem;
}
if (rideOwner != null && !isAuthenticated) {
final cancelledItem = SyncItem(
localRideId: localRideId,
providerId: providerId,
status: SyncStatus.cancelled,
createdAt: _now(),
lastError: 'Cannot upload another user\'s ride while unauthenticated',
);
await _outbox.update(cancelledItem);
notifyListeners();
return cancelledItem;
}
if (isAuthenticated && rideOwner == null) {
await _db.claimRideForCloudOwner(localRideId, user.uid); await _db.claimRideForCloudOwner(localRideId, user.uid);
} }
} }
@ -77,78 +197,548 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
return item; return item;
} }
Future<int> backupAllRideIds(Iterable<int> rideIds, {String? targetProviderId}) async { Future<int> backupAllRideIds(
Iterable<int> rideIds, {
String? targetProviderId,
}) async {
var enqueued = 0; var enqueued = 0;
for (final rideId in rideIds) { for (final rideId in rideIds) {
await enqueueRide(rideId, targetProviderId: targetProviderId, autoProcess: false); await enqueueRide(
rideId,
targetProviderId: targetProviderId,
autoProcess: false,
);
enqueued++; enqueued++;
} }
if (enqueued > 0) unawaited(processQueue()); if (enqueued > 0) unawaited(processQueue());
return enqueued; return enqueued;
} }
Future<void> _startupRecoveryAndProcess() async {
await reconcileCompletedRides();
await processQueue();
}
Future<void> retryAuthRequired() async { Future<void> retryAuthRequired() async {
for (final item in _outbox.getAllItems().where((i) => i.status == SyncStatus.authRequired)) { final user = _authService?.currentUser;
await _outbox.update(item.copyWith(status: SyncStatus.queued, clearLastError: true, clearNextRetry: true)); final isAuthenticated = user != null && !user.isAnonymous;
if (isAuthenticated && _db != null) {
await reconcileCompletedRides();
}
for (final item in _outbox.getAllItems().where(
(i) => i.status == SyncStatus.authRequired,
)) {
if (item.providerId == 'firebase') {
if (!isAuthenticated) continue;
if (_db != null) {
final rideOwner = await _db.getCloudOwnerUid(item.localRideId);
if (rideOwner != null && rideOwner != user.uid) {
await _outbox.update(
item.copyWith(
status: SyncStatus.cancelled,
lastError:
'Ride belongs to another account and cannot be retried',
clearNextRetry: true,
),
);
continue;
}
if (rideOwner == null) {
await _db.claimRideForCloudOwner(item.localRideId, user.uid);
}
}
}
await _outbox.update(
item.copyWith(
status: SyncStatus.queued,
clearLastError: true,
clearNextRetry: true,
),
);
} }
await processQueue(); await processQueue();
} }
/// Discovers completed rides in local SQLite that are missing from [SyncOutbox]
/// or need cloud confirmation recovery.
Future<CompletedRideReconciliationReport> reconcileCompletedRides({
String? targetProviderId,
}) async {
if (_activeReconciliation != null) {
return _activeReconciliation!;
}
final future = _performReconcileCompletedRides(
targetProviderId: targetProviderId,
);
_activeReconciliation = future;
try {
return await future;
} finally {
_activeReconciliation = null;
}
}
Future<CompletedRideReconciliationReport> _performReconcileCompletedRides({
String? targetProviderId,
}) async {
if (_db == null) {
return const CompletedRideReconciliationReport();
}
final providerId = targetProviderId ?? _activeProviderId;
final provider = _providers[providerId];
final user = _authService?.currentUser;
final isAuthenticated = user != null && !user.isAnonymous;
int discovered = 0;
int alreadyTracked = 0;
int recoveredQueued = 0;
int recoveredAuthRequired = 0;
int recoveredAlreadySynced = 0;
int recoveredLegacyUnverified = 0;
int foreignOwnerSkipped = 0;
int inconsistentSkipped = 0;
int unverifiablePending = 0;
int errorCount = 0;
final List<String> errors = [];
try {
final completedRides = await (_db.select(
_db.rides,
)..where((r) => r.endTime.isNotNull())).get();
discovered = completedRides.length;
for (final ride in completedRides) {
try {
// Check if already tracked in outbox for this provider
final existingItem = _outbox.getItem(ride.id, providerId);
if (existingItem != null) {
if (!existingItem.status.isTerminal &&
ride.cloudOwnerUid != null &&
isAuthenticated &&
ride.cloudOwnerUid != user.uid &&
providerId == 'firebase') {
await _outbox.update(
existingItem.copyWith(
status: SyncStatus.cancelled,
lastError:
'Ride belongs to another account and cannot be uploaded',
clearNextRetry: true,
),
);
foreignOwnerSkipped++;
continue;
}
alreadyTracked++;
continue;
}
// Foreign account check: never claim or upload another user's ride
if (ride.cloudOwnerUid != null &&
isAuthenticated &&
ride.cloudOwnerUid != user.uid) {
foreignOwnerSkipped++;
continue;
}
if (ride.cloudOwnerUid != null && !isAuthenticated) {
foreignOwnerSkipped++;
continue;
}
// Stable cloud identity check
final remoteRideId = await _db.ensureCloudRideId(ride.id);
// Deep verification check in cloud backend
final confirmation = provider != null
? await provider.verifyCloudConfirmation(remoteRideId)
: const CloudConfirmationResult(
status: CloudConfirmationStatus.missing,
);
switch (confirmation.status) {
case CloudConfirmationStatus.confirmed:
if (ride.cloudOwnerUid == null &&
isAuthenticated &&
providerId == 'firebase') {
await _db.claimRideForCloudOwner(ride.id, user.uid);
}
final syncedItem = SyncItem(
localRideId: ride.id,
providerId: providerId,
status: SyncStatus.synced,
remoteId: remoteRideId,
createdAt: ride.endTime ?? ride.startTime,
uploadedAt: _now(),
);
await _outbox.update(syncedItem);
recoveredAlreadySynced++;
break;
case CloudConfirmationStatus.legacyUnverified:
if (ride.cloudOwnerUid == null &&
isAuthenticated &&
providerId == 'firebase') {
await _db.claimRideForCloudOwner(ride.id, user.uid);
}
final legacySyncedItem = SyncItem(
localRideId: ride.id,
providerId: providerId,
status: SyncStatus.synced,
remoteId: remoteRideId,
createdAt: ride.endTime ?? ride.startTime,
uploadedAt: _now(),
lastError:
'Legacy ride cloud-confirmed without modern checksum',
);
await _outbox.update(legacySyncedItem);
recoveredLegacyUnverified++;
recoveredAlreadySynced++;
break;
case CloudConfirmationStatus.missing:
final providerRequiresAuth =
provider != null && await provider.requiresAuthentication();
if (providerRequiresAuth ||
(!isAuthenticated && providerId == 'firebase')) {
final authReqItem = SyncItem(
localRideId: ride.id,
providerId: providerId,
status: SyncStatus.authRequired,
createdAt: ride.endTime ?? ride.startTime,
lastError:
'Authentication required for provider "$providerId"',
);
await _outbox.update(authReqItem);
recoveredAuthRequired++;
} else {
if (ride.cloudOwnerUid == null &&
isAuthenticated &&
providerId == 'firebase') {
await _db.claimRideForCloudOwner(ride.id, user.uid);
}
final queuedItem = SyncItem(
localRideId: ride.id,
providerId: providerId,
status: SyncStatus.queued,
createdAt: ride.endTime ?? ride.startTime,
);
await _outbox.update(queuedItem);
recoveredQueued++;
}
break;
case CloudConfirmationStatus.inconsistent:
inconsistentSkipped++;
errors.add(
'Ride #${ride.id} ($remoteRideId) inconsistent in cloud: ${confirmation.message}',
);
final inconsistentItem = SyncItem(
localRideId: ride.id,
providerId: providerId,
status: SyncStatus.failed,
remoteId: remoteRideId,
createdAt: ride.endTime ?? ride.startTime,
lastError: 'Cloud data inconsistent: ${confirmation.message}',
attemptCount: 1,
);
await _outbox.update(inconsistentItem);
break;
case CloudConfirmationStatus.unauthorized:
final providerRequiresAuth =
provider != null && await provider.requiresAuthentication();
if (!isAuthenticated || providerRequiresAuth) {
final authReqItem = SyncItem(
localRideId: ride.id,
providerId: providerId,
status: SyncStatus.authRequired,
createdAt: ride.endTime ?? ride.startTime,
lastError:
'Authentication required for provider "$providerId"',
);
await _outbox.update(authReqItem);
recoveredAuthRequired++;
} else {
foreignOwnerSkipped++;
errors.add(
'Ride #${ride.id} ($remoteRideId) unauthorized: ${confirmation.message}',
);
}
break;
case CloudConfirmationStatus.temporarilyUnverifiable:
unverifiablePending++;
final retryItem = SyncItem(
localRideId: ride.id,
providerId: providerId,
status: SyncStatus.retryWait,
remoteId: remoteRideId,
createdAt: ride.endTime ?? ride.startTime,
nextRetryAt: _now().add(const Duration(minutes: 2)),
lastError:
'Cloud temporarily unverifiable: ${confirmation.message}',
);
await _outbox.update(retryItem);
break;
}
} catch (itemErr) {
errorCount++;
errors.add('Ride #${ride.id}: $itemErr');
}
}
} catch (e) {
errorCount++;
errors.add('Reconciliation failed: $e');
}
final report = CompletedRideReconciliationReport(
discoveredCompletedRides: discovered,
alreadyTrackedCount: alreadyTracked,
recoveredQueuedCount: recoveredQueued,
recoveredAuthRequiredCount: recoveredAuthRequired,
recoveredAlreadySyncedCount: recoveredAlreadySynced,
recoveredLegacyUnverifiedCount: recoveredLegacyUnverified,
foreignOwnerSkippedCount: foreignOwnerSkipped,
inconsistentSkippedCount: inconsistentSkipped,
unverifiablePendingCount: unverifiablePending,
errorCount: errorCount,
errors: errors,
);
if (report.totalRecovered > 0) {
notifyListeners();
}
return report;
}
static Duration calculateRetryDelay(int attemptCount) { static Duration calculateRetryDelay(int attemptCount) {
switch (attemptCount) { switch (attemptCount) {
case 1: return const Duration(seconds: 5); case 1:
case 2: return const Duration(seconds: 30); return const Duration(seconds: 5);
case 3: return const Duration(minutes: 2); case 2:
case 4: return const Duration(minutes: 5); return const Duration(seconds: 30);
case 5: return const Duration(minutes: 15); case 3:
default: return const Duration(minutes: 30); return const Duration(minutes: 2);
case 4:
return const Duration(minutes: 5);
case 5:
return const Duration(minutes: 15);
default:
return const Duration(minutes: 30);
} }
} }
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) unawaited(processQueue()); if (state == AppLifecycleState.resumed) {
unawaited(_startupRecoveryAndProcess());
}
} }
Future<void> processQueue() async { Future<void> processQueue() async {
if (_isProcessing) return; if (_currentProcessingFuture != null) {
_needsAnotherPass = true;
return _currentProcessingFuture!;
}
final future = _performProcessQueue();
_currentProcessingFuture = future;
try {
await future;
} finally {
_currentProcessingFuture = null;
}
}
Future<void> _performProcessQueue() async {
_isProcessing = true; _isProcessing = true;
notifyListeners(); notifyListeners();
try { try {
final now = _now(); do {
for (final item in _outbox.getEligibleItems(now)) { _needsAnotherPass = false;
final provider = _providers[item.providerId]; final now = _now();
if (provider == null) { for (final item in _outbox.getEligibleItems(now)) {
await _outbox.update(item.copyWith(status: SyncStatus.failed, lastError: 'Provider "${item.providerId}" not registered', lastAttemptAt: now)); final provider = _providers[item.providerId];
continue; if (provider == null) {
await _outbox.update(
item.copyWith(
status: SyncStatus.failed,
lastError: 'Provider "${item.providerId}" not registered',
lastAttemptAt: now,
),
);
continue;
}
// 1. Confirm local database record exists
final ride = _db != null
? await (_db.select(_db.rides)
..where((r) => r.id.equals(item.localRideId)))
.getSingleOrNull()
: null;
if (_db != null && ride == null) {
await _outbox.update(
item.copyWith(
status: SyncStatus.failed,
lastError:
'Local ride #${item.localRideId} not found in database',
lastAttemptAt: now,
clearNextRetry: true,
),
);
continue;
}
// 2. Immediate pre-upload ownership and account checks
// 2. Immediate pre-upload ownership and account checks
if (item.providerId == 'firebase' && _authService != null) {
final user = _authService.currentUser;
final isAuthenticated = user != null && !user.isAnonymous;
if (!isAuthenticated) {
await _outbox.update(
item.copyWith(
status: SyncStatus.authRequired,
lastError:
'Authentication required for provider "${item.providerId}"',
lastAttemptAt: now,
),
);
continue;
}
if (ride != null &&
ride.cloudOwnerUid != null &&
ride.cloudOwnerUid != user.uid) {
await _outbox.update(
item.copyWith(
status: SyncStatus.cancelled,
lastError:
'Ride belongs to another account and cannot be uploaded',
lastAttemptAt: now,
clearNextRetry: true,
),
);
continue;
}
if (ride != null && ride.cloudOwnerUid == null && _db != null) {
final claimed = await _db.claimRideForCloudOwner(
ride.id,
user.uid,
);
if (!claimed) {
await _outbox.update(
item.copyWith(
status: SyncStatus.cancelled,
lastError: 'Failed to claim ride ownership for upload',
lastAttemptAt: now,
clearNextRetry: true,
),
);
continue;
}
}
}
if (!await provider.isAvailable()) {
await _outbox.update(
item.copyWith(
status: SyncStatus.waitingForNetwork,
lastAttemptAt: now,
),
);
continue;
}
if (await provider.requiresAuthentication()) {
await _outbox.update(
item.copyWith(
status: SyncStatus.authRequired,
lastError:
'Authentication required for provider "${item.providerId}"',
lastAttemptAt: now,
),
);
continue;
}
final uidBeforeUpload = _authService?.currentUser?.uid;
final uploadingItem = item.copyWith(
status: SyncStatus.uploading,
lastAttemptAt: now,
);
await _outbox.update(uploadingItem);
SyncResult result;
try {
result = await provider.uploadRide(uploadingItem);
} catch (e) {
result = SyncResult.temporaryFailure(
'Unhandled provider exception: $e',
timestamp: now,
);
}
// 3. Post-upload identity integrity check: verify user didn't logout/switch mid-flight
final uidAfterUpload = _authService?.currentUser?.uid;
if (item.providerId == 'firebase' &&
_authService != null &&
uidBeforeUpload != uidAfterUpload) {
await _outbox.update(
uploadingItem.copyWith(
status: SyncStatus.authRequired,
lastError: 'User changed or logged out during upload',
clearNextRetry: true,
),
);
continue;
}
// 4. In-flight cancellation protection: verify item was not cancelled while uploading
final currentItem = _outbox.getItem(
item.localRideId,
item.providerId,
);
if (currentItem?.status == SyncStatus.cancelled) {
continue;
}
if (result.success) {
await _outbox.update(
uploadingItem.copyWith(
status: SyncStatus.synced,
remoteId: result.remoteId,
uploadedAt: result.timestamp,
clearLastError: true,
clearNextRetry: true,
),
);
} else if (result.requiresAuth) {
await _outbox.update(
uploadingItem.copyWith(
status: SyncStatus.authRequired,
lastError: result.errorMessage,
),
);
} else if (result.isRetryable) {
final attempts = uploadingItem.attemptCount + 1;
await _outbox.update(
uploadingItem.copyWith(
status: SyncStatus.retryWait,
attemptCount: attempts,
nextRetryAt: now.add(calculateRetryDelay(attempts)),
lastError: result.errorMessage,
),
);
} else {
await _outbox.update(
uploadingItem.copyWith(
status: SyncStatus.failed,
attemptCount: uploadingItem.attemptCount + 1,
lastError: result.errorMessage,
clearNextRetry: true,
),
);
}
} }
if (!await provider.isAvailable()) { } while (_needsAnotherPass);
await _outbox.update(item.copyWith(status: SyncStatus.waitingForNetwork, lastAttemptAt: now));
continue;
}
if (await provider.requiresAuthentication()) {
await _outbox.update(item.copyWith(status: SyncStatus.authRequired, lastError: 'Authentication required for provider "${item.providerId}"', lastAttemptAt: now));
continue;
}
final uploadingItem = item.copyWith(status: SyncStatus.uploading, lastAttemptAt: now);
await _outbox.update(uploadingItem);
SyncResult result;
try {
result = await provider.uploadRide(uploadingItem);
} catch (e) {
result = SyncResult.temporaryFailure('Unhandled provider exception: $e', timestamp: now);
}
if (result.success) {
await _outbox.update(uploadingItem.copyWith(status: SyncStatus.synced, remoteId: result.remoteId, uploadedAt: result.timestamp, clearLastError: true, clearNextRetry: true));
} else if (result.requiresAuth) {
await _outbox.update(uploadingItem.copyWith(status: SyncStatus.authRequired, lastError: result.errorMessage));
} else if (result.isRetryable) {
final attempts = uploadingItem.attemptCount + 1;
await _outbox.update(uploadingItem.copyWith(status: SyncStatus.retryWait, attemptCount: attempts, nextRetryAt: now.add(calculateRetryDelay(attempts)), lastError: result.errorMessage));
} else {
await _outbox.update(uploadingItem.copyWith(status: SyncStatus.failed, attemptCount: uploadingItem.attemptCount + 1, lastError: result.errorMessage, clearNextRetry: true));
}
}
} finally { } finally {
_isProcessing = false; _isProcessing = false;
notifyListeners(); notifyListeners();
@ -164,9 +754,37 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
} }
Future<void> retryAllPending() async { Future<void> retryAllPending() async {
final retryable = _outbox.getAllItems().where((i) => i.status.isPending || i.status == SyncStatus.failed).toList(growable: false); final user = _authService?.currentUser;
final isAuthenticated = user != null && !user.isAnonymous;
final database = _db;
final retryable = _outbox
.getAllItems()
.where((i) => i.status.isPending || i.status == SyncStatus.failed)
.toList(growable: false);
for (final item in retryable) { for (final item in retryable) {
await _outbox.update(item.copyWith(status: SyncStatus.queued, clearLastError: true, clearNextRetry: true)); if (item.providerId == 'firebase' &&
database != null &&
_authService != null) {
final rideOwner = await database.getCloudOwnerUid(item.localRideId);
if (rideOwner != null && isAuthenticated && rideOwner != user.uid) {
await _outbox.update(
item.copyWith(
status: SyncStatus.cancelled,
lastError:
'Ride belongs to another account and cannot be retried',
clearNextRetry: true,
),
);
continue;
}
}
await _outbox.update(
item.copyWith(
status: SyncStatus.queued,
clearLastError: true,
clearNextRetry: true,
),
);
} }
await processQueue(); await processQueue();
} }

View File

@ -1,6 +1,137 @@
import 'dart:async'; import 'dart:async';
import 'sync_models.dart'; import 'sync_models.dart';
/// Explicit cloud confirmation states representing the exact verification status of a remote ride.
enum CloudConfirmationStatus {
/// Firestore metadata and corresponding R2 bundle are consistently verified.
confirmed,
/// Required Firestore metadata or R2 object is absent.
missing,
/// Ride identity, owner, bundle reference, size, or checksum does not match.
inconsistent,
/// The current user cannot access or verify the ride.
unauthorized,
/// Network outage, timeout, rate limit, or transient backend failure.
temporarilyUnverifiable,
/// Bundle exists in cloud (e.g. historical ride) but lacks modern cryptographic checksum verification.
legacyUnverified,
}
/// Explicit verification report for a cloud ride.
class CloudConfirmationResult {
final CloudConfirmationStatus status;
final String? message;
final String? remoteRideId;
final String? ownerUid;
final String? objectKey;
final int? sizeBytes;
final String? sha256;
const CloudConfirmationResult({
required this.status,
this.message,
this.remoteRideId,
this.ownerUid,
this.objectKey,
this.sizeBytes,
this.sha256,
});
const CloudConfirmationResult.confirmed({
required String remoteRideId,
String? ownerUid,
String? objectKey,
int? sizeBytes,
String? sha256,
String? message,
}) : this(
status: CloudConfirmationStatus.confirmed,
remoteRideId: remoteRideId,
ownerUid: ownerUid,
objectKey: objectKey,
sizeBytes: sizeBytes,
sha256: sha256,
message: message,
);
const CloudConfirmationResult.missing({
required String remoteRideId,
String? message,
}) : this(
status: CloudConfirmationStatus.missing,
remoteRideId: remoteRideId,
message: message,
);
const CloudConfirmationResult.inconsistent({
required String remoteRideId,
String? ownerUid,
String? objectKey,
String? message,
}) : this(
status: CloudConfirmationStatus.inconsistent,
remoteRideId: remoteRideId,
ownerUid: ownerUid,
objectKey: objectKey,
message: message,
);
const CloudConfirmationResult.unauthorized({
required String remoteRideId,
String? ownerUid,
String? message,
}) : this(
status: CloudConfirmationStatus.unauthorized,
remoteRideId: remoteRideId,
ownerUid: ownerUid,
message: message,
);
const CloudConfirmationResult.temporarilyUnverifiable({
required String remoteRideId,
String? message,
}) : this(
status: CloudConfirmationStatus.temporarilyUnverifiable,
remoteRideId: remoteRideId,
message: message,
);
const CloudConfirmationResult.legacyUnverified({
required String remoteRideId,
String? ownerUid,
String? objectKey,
int? sizeBytes,
String? sha256,
String? message,
}) : this(
status: CloudConfirmationStatus.legacyUnverified,
remoteRideId: remoteRideId,
ownerUid: ownerUid,
objectKey: objectKey,
sizeBytes: sizeBytes,
sha256: sha256,
message: message,
);
bool get isConfirmed => status == CloudConfirmationStatus.confirmed;
bool get isLegacyUnverified =>
status == CloudConfirmationStatus.legacyUnverified;
bool get isMissing => status == CloudConfirmationStatus.missing;
bool get isInconsistent => status == CloudConfirmationStatus.inconsistent;
bool get isUnauthorized => status == CloudConfirmationStatus.unauthorized;
bool get isTemporarilyUnverifiable =>
status == CloudConfirmationStatus.temporarilyUnverifiable;
@override
String toString() =>
'CloudConfirmationResult(status: $status, ride: $remoteRideId, owner: $ownerUid, message: $message)';
}
/// Abstract contract for synchronization providers (e.g. Firebase, GitHub, Supabase). /// Abstract contract for synchronization providers (e.g. Firebase, GitHub, Supabase).
/// Implementations must not leak provider-specific SDK types into the core sync engine. /// Implementations must not leak provider-specific SDK types into the core sync engine.
abstract class SyncProvider { abstract class SyncProvider {
@ -16,6 +147,19 @@ abstract class SyncProvider {
/// Performs the upload of the local ride referenced by [item]. /// Performs the upload of the local ride referenced by [item].
/// Must return a deterministic [SyncResult]. /// Must return a deterministic [SyncResult].
Future<SyncResult> uploadRide(SyncItem item); Future<SyncResult> uploadRide(SyncItem item);
/// Checks if the ride with [remoteRideId] is already confirmed uploaded in the cloud.
/// Backward compatible wrapper around [verifyCloudConfirmation].
Future<bool> isRideConfirmedInCloud(String remoteRideId) async {
final result = await verifyCloudConfirmation(remoteRideId);
return result.isConfirmed || result.isLegacyUnverified;
}
/// Deeply verifies whether [remoteRideId] is confirmed and recoverable in the cloud.
/// Default implementation returns [CloudConfirmationStatus.missing].
Future<CloudConfirmationResult> verifyCloudConfirmation(
String remoteRideId,
) async => CloudConfirmationResult.missing(remoteRideId: remoteRideId);
} }
/// Simulated behavior modes for testing the synchronization engine deterministically /// Simulated behavior modes for testing the synchronization engine deterministically
@ -36,6 +180,8 @@ class FakeSyncProvider implements SyncProvider {
int delayMs; int delayMs;
final List<SyncItem> uploadedItems = []; final List<SyncItem> uploadedItems = [];
final Map<int, int> uploadAttemptsPerRide = {}; final Map<int, int> uploadAttemptsPerRide = {};
final Set<String> confirmedRemoteRideIds = {};
final Map<String, CloudConfirmationResult> confirmationResults = {};
FakeSyncProvider({ FakeSyncProvider({
this.providerId = 'fake', this.providerId = 'fake',
@ -54,6 +200,41 @@ class FakeSyncProvider implements SyncProvider {
return behavior == FakeSyncBehavior.authRequired; return behavior == FakeSyncBehavior.authRequired;
} }
@override
Future<bool> isRideConfirmedInCloud(String remoteRideId) async {
final result = await verifyCloudConfirmation(remoteRideId);
return result.isConfirmed || result.isLegacyUnverified;
}
@override
Future<CloudConfirmationResult> verifyCloudConfirmation(
String remoteRideId,
) async {
if (confirmationResults.containsKey(remoteRideId)) {
return confirmationResults[remoteRideId]!;
}
if (confirmedRemoteRideIds.contains(remoteRideId)) {
return CloudConfirmationResult.confirmed(remoteRideId: remoteRideId);
}
if (behavior == FakeSyncBehavior.offline ||
behavior == FakeSyncBehavior.temporaryFailure) {
return CloudConfirmationResult.temporarilyUnverifiable(
remoteRideId: remoteRideId,
message: 'Fake provider backend unavailable',
);
}
if (behavior == FakeSyncBehavior.authRequired) {
return CloudConfirmationResult.unauthorized(
remoteRideId: remoteRideId,
message: 'Fake provider requires authentication',
);
}
return CloudConfirmationResult.missing(
remoteRideId: remoteRideId,
message: 'Ride not found in fake provider',
);
}
@override @override
Future<SyncResult> uploadRide(SyncItem item) async { Future<SyncResult> uploadRide(SyncItem item) async {
if (delayMs > 0) { if (delayMs > 0) {
@ -63,12 +244,30 @@ class FakeSyncProvider implements SyncProvider {
uploadAttemptsPerRide[item.localRideId] = uploadAttemptsPerRide[item.localRideId] =
(uploadAttemptsPerRide[item.localRideId] ?? 0) + 1; (uploadAttemptsPerRide[item.localRideId] ?? 0) + 1;
final existingRemoteId = item.remoteId;
if (existingRemoteId != null) {
final confirmation = await verifyCloudConfirmation(existingRemoteId);
if (confirmation.isConfirmed || confirmation.isLegacyUnverified) {
return SyncResult.success(remoteId: existingRemoteId);
}
if (confirmation.isInconsistent) {
return SyncResult.permanentFailure(
'Cloud data inconsistent: ${confirmation.message}',
);
}
}
switch (behavior) { switch (behavior) {
case FakeSyncBehavior.success: case FakeSyncBehavior.success:
uploadedItems.add(item); uploadedItems.add(item);
return SyncResult.success( final remoteId =
remoteId: 'remote_${item.localRideId}_${DateTime.now().millisecondsSinceEpoch}', existingRemoteId ??
'remote_${item.localRideId}_${DateTime.now().millisecondsSinceEpoch}';
confirmedRemoteRideIds.add(remoteId);
confirmationResults[remoteId] = CloudConfirmationResult.confirmed(
remoteRideId: remoteId,
); );
return SyncResult.success(remoteId: remoteId);
case FakeSyncBehavior.temporaryFailure: case FakeSyncBehavior.temporaryFailure:
return SyncResult.temporaryFailure( return SyncResult.temporaryFailure(
@ -95,5 +294,7 @@ class FakeSyncProvider implements SyncProvider {
void reset() { void reset() {
uploadedItems.clear(); uploadedItems.clear();
uploadAttemptsPerRide.clear(); uploadAttemptsPerRide.clear();
confirmedRemoteRideIds.clear();
confirmationResults.clear();
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,890 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:drift/native.dart';
import 'package:drift/drift.dart' as drift;
import 'package:motorcycle_dash/database/database.dart';
import 'package:motorcycle_dash/services/auth/auth_service.dart';
import 'package:motorcycle_dash/services/auth/auth_state.dart';
import 'package:motorcycle_dash/services/auth/auth_user.dart';
import 'package:motorcycle_dash/services/sync/sync_models.dart';
import 'package:motorcycle_dash/services/sync/sync_provider.dart';
import 'package:motorcycle_dash/services/sync/sync_outbox.dart';
import 'package:motorcycle_dash/services/sync/sync_manager.dart';
class _TestAuthService implements AuthService {
AuthUser? _currentUser;
AuthState _authState = const AuthState.unauthenticated();
void setAuthenticatedUser(String uid, String email) {
_currentUser = AuthUser(
uid: uid,
email: email,
displayName: 'Test Rider',
isAnonymous: false,
);
_authState = AuthState.authenticated(_currentUser!);
}
void setGuestUser() {
_currentUser = const AuthUser(
uid: 'guest_uid_123',
email: null,
displayName: 'Guest Rider',
isAnonymous: true,
);
_authState = AuthState.anonymous(_currentUser!);
}
void setLoggedOut() {
_currentUser = null;
_authState = const AuthState.unauthenticated();
}
@override
AuthUser? get currentUser => _currentUser;
@override
AuthState get currentState => _authState;
@override
Stream<AuthState> get authStateChanges => Stream.value(_authState);
@override
bool get isConfigured => true;
@override
bool get isEmailVerified => false;
@override
Future<void> createUserWithEmailAndPassword(
String email,
String password, {
String? displayName,
}) async {}
@override
Future<void> deleteCurrentUser() async {}
@override
void dispose() {}
@override
Future<void> ensureBackendIdentity() async {}
@override
Future<String?> getIdToken([bool forceRefresh = false]) async =>
'fake_id_token';
@override
Future<void> reauthenticate({String? password}) async {}
@override
Future<void> reloadCurrentUser() async {}
@override
Future<void> sendEmailVerification() async {}
@override
Future<void> sendPasswordResetEmail(String email) async {}
@override
Future<void> signInAnonymously() async => setGuestUser();
@override
Future<void> signInWithEmailAndPassword(
String email,
String password,
) async => setAuthenticatedUser('uid_123', email);
@override
Future<void> signInWithGoogle() async =>
setAuthenticatedUser('uid_google', 'google@example.com');
@override
Future<void> signOut() async => setLoggedOut();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('v1.1 Completed-Ride Sync Outbox Recovery Tests', () {
late SharedPreferences prefs;
late AppDatabase db;
late _TestAuthService authService;
late FakeSyncProvider fakeProvider;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
db = AppDatabase.forTesting(NativeDatabase.memory());
authService = _TestAuthService();
fakeProvider = FakeSyncProvider(providerId: 'firebase');
});
tearDown(() async {
await db.close();
});
test(
'1. Crash Window Recovery: Completed ride missing from outbox is recovered and uploaded',
() async {
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
// Simulate a completed ride committed to SQLite during a previous session
final rideId = await db.startRide(
startTime: DateTime.utc(2026, 10, 12, 10, 0, 0),
motorcycleMake: 'Honda',
motorcycleModel: 'CBR600RR',
);
await db.endRide(rideId, const RidesCompanion());
// Confirm outbox starts empty (app terminated before enqueue ran)
final outbox = SyncOutbox(prefs);
expect(outbox.count, equals(0));
// Startup initialization of SyncManager (testing step-by-step)
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
autoStart: false,
);
// Reconcile runs
final report = await manager.reconcileCompletedRides();
expect(report.discoveredCompletedRides, equals(1));
expect(report.recoveredQueuedCount, equals(1));
expect(report.alreadyTrackedCount, equals(0));
expect(outbox.count, equals(1));
final item = outbox.getItem(rideId, 'firebase');
expect(item, isNotNull);
expect(item?.status, equals(SyncStatus.queued));
// Verify ride ownership was claimed for authenticated user
final ownerUid = await db.getCloudOwnerUid(rideId);
expect(ownerUid, equals('rider_alice'));
// Process queue to complete upload
await manager.processQueue();
expect(
outbox.getItem(rideId, 'firebase')?.status,
equals(SyncStatus.synced),
);
expect(fakeProvider.uploadedItems.length, equals(1));
},
);
test(
'2. Idempotence: Repeated reconciliation does not create duplicate entries or uploads',
() async {
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
final rideId = await db.startRide(
startTime: DateTime.utc(2026, 10, 12, 11, 0, 0),
);
await db.endRide(rideId, const RidesCompanion());
final outbox = SyncOutbox(prefs);
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
autoStart: false,
);
// First run reconciles and queues the ride
final report1 = await manager.reconcileCompletedRides();
expect(report1.recoveredQueuedCount, equals(1));
expect(outbox.count, equals(1));
// Process upload
await manager.processQueue();
expect(
outbox.getItem(rideId, 'firebase')?.status,
equals(SyncStatus.synced),
);
expect(fakeProvider.uploadedItems.length, equals(1));
// Second run reports ride as already tracked, zero duplicates
final report2 = await manager.reconcileCompletedRides();
expect(report2.discoveredCompletedRides, equals(1));
expect(report2.alreadyTrackedCount, equals(1));
expect(report2.recoveredQueuedCount, equals(0));
expect(report2.totalRecovered, equals(0));
expect(outbox.count, equals(1));
// Running processQueue again does not re-upload
await manager.processQueue();
expect(fakeProvider.uploadedItems.length, equals(1));
},
);
test(
'3. Guest State & Auth Transition: Recovers as authRequired then claims and syncs on sign-in',
() async {
// User is initially a guest and provider requires auth
authService.setGuestUser();
fakeProvider.behavior = FakeSyncBehavior.authRequired;
final rideId = await db.startRide(
startTime: DateTime.utc(2026, 10, 12, 12, 0, 0),
);
await db.endRide(rideId, const RidesCompanion());
final outbox = SyncOutbox(prefs);
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
autoStart: false,
);
// Reconcile as guest -> authRequired
final report = await manager.reconcileCompletedRides();
expect(report.recoveredAuthRequiredCount, equals(1));
expect(
outbox.getItem(rideId, 'firebase')?.status,
equals(SyncStatus.authRequired),
);
// Ownership remains unclaimed
expect(await db.getCloudOwnerUid(rideId), isNull);
// Guest now signs in with Google / Email
authService.setAuthenticatedUser('rider_bob', 'bob@example.com');
fakeProvider.behavior = FakeSyncBehavior.success;
// Invoking retryAuthRequired claims the ride and triggers queue upload
await manager.retryAuthRequired();
expect(await db.getCloudOwnerUid(rideId), equals('rider_bob'));
expect(
outbox.getItem(rideId, 'firebase')?.status,
equals(SyncStatus.synced),
);
expect(fakeProvider.uploadedItems.length, equals(1));
},
);
test(
'4. Cloud Confirmed Skip: Confirmed ride marks synced without re-uploading bundle',
() async {
authService.setAuthenticatedUser(
'rider_charlie',
'charlie@example.com',
);
final rideId = await db.startRide(
startTime: DateTime.utc(2026, 10, 12, 13, 0, 0),
);
await db.endRide(rideId, const RidesCompanion());
final remoteRideId = await db.ensureCloudRideId(rideId);
// Simulate that cloud backend already has this ride bundle uploaded
fakeProvider.confirmedRemoteRideIds.add(remoteRideId);
final outbox = SyncOutbox(prefs);
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
);
final report = await manager.reconcileCompletedRides();
expect(report.recoveredAlreadySyncedCount, equals(1));
expect(report.recoveredQueuedCount, equals(0));
final item = outbox.getItem(rideId, 'firebase');
expect(item?.status, equals(SyncStatus.synced));
expect(item?.remoteId, equals(remoteRideId));
// Zero upload attempts because it was already confirmed
expect(fakeProvider.uploadedItems.isEmpty, isTrue);
},
);
test(
'5. Foreign Account Isolation: Does not claim or upload rides belonging to another user',
() async {
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
// Create ride explicitly owned by rider_bob
final rideId = await db
.into(db.rides)
.insert(
RidesCompanion.insert(
cloudRideId: const drift.Value('ride_bob_123'),
cloudOwnerUid: const drift.Value('rider_bob'),
startTime: DateTime.utc(2026, 10, 12, 14, 0, 0),
endTime: drift.Value(DateTime.utc(2026, 10, 12, 15, 0, 0)),
),
);
final outbox = SyncOutbox(prefs);
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
);
final report = await manager.reconcileCompletedRides();
expect(report.foreignOwnerSkippedCount, equals(1));
expect(report.totalRecovered, equals(0));
expect(outbox.count, equals(0));
// Ownership in db remains unchanged
expect(await db.getCloudOwnerUid(rideId), equals('rider_bob'));
},
);
test(
'6. In-Progress Isolation: Active rides without endTime are never recovered as completed',
() async {
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
// Start ride but do not end it (endTime is null)
final rideId = await db.startRide(
startTime: DateTime.utc(2026, 10, 12, 16, 0, 0),
);
final outbox = SyncOutbox(prefs);
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
);
final report = await manager.reconcileCompletedRides();
expect(report.discoveredCompletedRides, equals(0));
expect(report.totalRecovered, equals(0));
expect(outbox.getItem(rideId, 'firebase'), isNull);
},
);
test(
'7. Inconsistent Cloud Confirmation: Preserves local data and reports inconsistent skipped without overwriting',
() async {
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
final rideId = await db.startRide(
startTime: DateTime.utc(2026, 10, 12, 17, 0, 0),
);
await db.endRide(rideId, const RidesCompanion());
final remoteRideId = await db.ensureCloudRideId(rideId);
fakeProvider.confirmationResults[remoteRideId] =
CloudConfirmationResult.inconsistent(
remoteRideId: remoteRideId,
message: 'SHA-256 mismatch detected in storage',
);
final outbox = SyncOutbox(prefs);
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
);
final report = await manager.reconcileCompletedRides();
expect(report.inconsistentSkippedCount, equals(1));
expect(report.recoveredAlreadySyncedCount, equals(0));
final item = outbox.getItem(rideId, 'firebase');
expect(item, isNotNull);
expect(item?.status, equals(SyncStatus.failed));
expect(item?.lastError, contains('inconsistent'));
// Local ride remains safe in SQLite
final localRide = await (db.select(
db.rides,
)..where((r) => r.id.equals(rideId))).getSingle();
expect(localRide.cloudRideId, equals(remoteRideId));
},
);
test(
'8. Temporarily Unverifiable State: Leaves ride in retryWait with backoff without marking permanently missing',
() async {
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
final rideId = await db.startRide(
startTime: DateTime.utc(2026, 10, 12, 18, 0, 0),
);
await db.endRide(rideId, const RidesCompanion());
final remoteRideId = await db.ensureCloudRideId(rideId);
fakeProvider.confirmationResults[remoteRideId] =
CloudConfirmationResult.temporarilyUnverifiable(
remoteRideId: remoteRideId,
message: 'HTTP 503 backend gateway timeout',
);
final outbox = SyncOutbox(prefs);
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
);
final report = await manager.reconcileCompletedRides();
expect(report.unverifiablePendingCount, equals(1));
expect(report.recoveredAlreadySyncedCount, equals(0));
final item = outbox.getItem(rideId, 'firebase');
expect(item, isNotNull);
expect(item?.status, equals(SyncStatus.retryWait));
expect(item?.nextRetryAt, isNotNull);
expect(item?.lastError, contains('temporarily unverifiable'));
},
);
test(
'9. Pre-Upload Ownership Protection: processQueue cancels foreign-owned outbox item before calling provider',
() async {
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
// Create ride owned by rider_bob
final rideId = await db
.into(db.rides)
.insert(
RidesCompanion.insert(
cloudRideId: const drift.Value('ride_bob_456'),
cloudOwnerUid: const drift.Value('rider_bob'),
startTime: DateTime.utc(2026, 10, 12, 19, 0, 0),
endTime: drift.Value(DateTime.utc(2026, 10, 12, 20, 0, 0)),
),
);
// Force an outbox item into the queue (e.g., from stale shared storage)
final outbox = SyncOutbox(prefs);
await outbox.update(
SyncItem(
localRideId: rideId,
providerId: 'firebase',
status: SyncStatus.queued,
createdAt: DateTime.now(),
),
);
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
autoStart: false,
);
await manager.processQueue();
final item = outbox.getItem(rideId, 'firebase');
expect(item?.status, equals(SyncStatus.cancelled));
expect(item?.lastError, contains('belongs to another account'));
expect(
fakeProvider.uploadedItems.isEmpty,
isTrue,
reason: 'Must not call provider upload for foreign ride',
);
},
);
test(
'10. Mid-flight Auth Change Protection: Reverts upload to authRequired if user logs out mid-flight',
() async {
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
final rideId = await db.startRide(
startTime: DateTime.utc(2026, 10, 12, 21, 0, 0),
);
await db.endRide(rideId, const RidesCompanion());
await db.claimRideForCloudOwner(rideId, 'rider_alice');
final outbox = SyncOutbox(prefs);
await outbox.update(
SyncItem(
localRideId: rideId,
providerId: 'firebase',
status: SyncStatus.queued,
createdAt: DateTime.now(),
),
);
// Provider simulates delay and during delay user logs out
fakeProvider.delayMs = 50;
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
autoStart: false,
);
// Start processQueue
final uploadFuture = manager.processQueue();
// Mid-flight logout
await Future<void>.delayed(const Duration(milliseconds: 10));
authService.setLoggedOut();
await uploadFuture;
final item = outbox.getItem(rideId, 'firebase');
expect(item?.status, equals(SyncStatus.authRequired));
expect(
item?.lastError,
contains('User changed or logged out during upload'),
);
},
);
test(
'11. Concurrency Protection: Simultaneous reconcileCompletedRides join the same future',
() async {
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
final rideId = await db.startRide(
startTime: DateTime.utc(2026, 10, 12, 22, 0, 0),
);
await db.endRide(rideId, const RidesCompanion());
final outbox = SyncOutbox(prefs);
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
autoStart: false,
);
final results = await Future.wait([
manager.reconcileCompletedRides(),
manager.reconcileCompletedRides(),
]);
expect(results[0].discoveredCompletedRides, equals(1));
expect(results[1].discoveredCompletedRides, equals(1));
expect(outbox.count, equals(1));
},
);
test(
'12. Stale Persisted Outbox on retryAllPending: Foreign-owned items are cancelled on retry',
() async {
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
final rideId = await db
.into(db.rides)
.insert(
RidesCompanion.insert(
cloudRideId: const drift.Value('ride_bob_789'),
cloudOwnerUid: const drift.Value('rider_bob'),
startTime: DateTime.utc(2026, 10, 12, 23, 0, 0),
endTime: drift.Value(DateTime.utc(2026, 10, 13, 0, 0, 0)),
),
);
final outbox = SyncOutbox(prefs);
await outbox.update(
SyncItem(
localRideId: rideId,
providerId: 'firebase',
status: SyncStatus.failed,
lastError: 'Previous failure',
createdAt: DateTime.now(),
),
);
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
autoStart: false,
);
await manager.retryAllPending();
final item = outbox.getItem(rideId, 'firebase');
expect(item?.status, equals(SyncStatus.cancelled));
expect(item?.lastError, contains('belongs to another account'));
expect(fakeProvider.uploadedItems.isEmpty, isTrue);
},
);
test(
'13. In-Flight Cancellation Protection: cancelSync preserves cancelled status even after upload completes',
() async {
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
final rideId = await db.startRide(
startTime: DateTime.utc(2026, 10, 12, 10, 0, 0),
);
await db.endRide(rideId, const RidesCompanion());
await db.claimRideForCloudOwner(rideId, 'rider_alice');
final outbox = SyncOutbox(prefs);
await outbox.update(
SyncItem(
localRideId: rideId,
providerId: 'firebase',
status: SyncStatus.queued,
createdAt: DateTime.now(),
),
);
// Simulate provider with 50ms upload latency
fakeProvider.delayMs = 50;
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
autoStart: false,
);
// Start processing queue
final processFuture = manager.processQueue();
// While upload is in flight, user cancels sync
await Future<void>.delayed(const Duration(milliseconds: 10));
await manager.cancelSync(rideId, 'firebase');
await processFuture;
final item = outbox.getItem(rideId, 'firebase');
expect(
item?.status,
equals(SyncStatus.cancelled),
reason:
'Item must remain cancelled and not get overwritten with synced',
);
},
);
test(
'14. Default Active Provider: Selects defaultProviderId or first initial provider, defaulting to fake for bare test manager',
() async {
final outbox = SyncOutbox(prefs);
final managerExplicit = SyncManager(
outbox,
defaultProviderId: 'firebase',
autoStart: false,
);
expect(managerExplicit.activeProviderId, equals('firebase'));
final customProvider = FakeSyncProvider(providerId: 'custom_gateway');
final managerWithProviders = SyncManager(
outbox,
initialProviders: [customProvider],
autoStart: false,
);
expect(managerWithProviders.activeProviderId, equals('custom_gateway'));
final managerNoProviders = SyncManager(outbox, autoStart: false);
expect(managerNoProviders.activeProviderId, equals('fake'));
},
);
test(
'15. Immediate Enqueue Ownership Protection: enqueueRide cancels foreign-owned rides immediately',
() async {
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
final rideId = await db
.into(db.rides)
.insert(
RidesCompanion.insert(
cloudRideId: const drift.Value('ride_bob_999'),
cloudOwnerUid: const drift.Value('rider_bob'),
startTime: DateTime.utc(2026, 10, 12, 11, 0, 0),
endTime: drift.Value(DateTime.utc(2026, 10, 12, 12, 0, 0)),
),
);
final outbox = SyncOutbox(prefs);
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
autoStart: false,
);
final item = await manager.enqueueRide(rideId, autoProcess: false);
expect(item.status, equals(SyncStatus.cancelled));
expect(item.lastError, contains('belongs to another account'));
expect(
outbox.getItem(rideId, 'firebase')?.status,
equals(SyncStatus.cancelled),
);
},
);
test(
'16. Failed Cloud Verification followed by Successful Retry: Recovers to synced without re-upload',
() async {
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
final rideId = await db.startRide(
startTime: DateTime.utc(2026, 10, 12, 13, 0, 0),
);
await db.endRide(rideId, const RidesCompanion());
final remoteRideId = await db.ensureCloudRideId(rideId);
// Step 1: Initial reconciliation fails with temporary verification error (e.g. HTTP 503)
fakeProvider.confirmationResults[remoteRideId] =
CloudConfirmationResult.temporarilyUnverifiable(
remoteRideId: remoteRideId,
message: 'HTTP 503 gateway timeout',
);
final outbox = SyncOutbox(prefs);
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
autoStart: false,
);
final report1 = await manager.reconcileCompletedRides();
expect(report1.unverifiablePendingCount, equals(1));
expect(
outbox.getItem(rideId, 'firebase')?.status,
equals(SyncStatus.retryWait),
);
// Step 2: Backend recovers and verifies the bundle in R2
fakeProvider.confirmationResults[remoteRideId] =
CloudConfirmationResult.confirmed(remoteRideId: remoteRideId);
fakeProvider.confirmedRemoteRideIds.add(remoteRideId);
// Process queue on retry
await manager.retryAllPending();
expect(
outbox.getItem(rideId, 'firebase')?.status,
equals(SyncStatus.synced),
);
expect(
fakeProvider.uploadedItems.isEmpty,
isTrue,
reason:
'Must confirm and mark synced without re-uploading the bundle',
);
},
);
test(
'17. Legacy Unverified Cloud Confirmation: Recovers as synced without re-upload and tracks count',
() async {
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
final rideId = await db.startRide(
startTime: DateTime.utc(2026, 10, 12, 14, 0, 0),
);
await db.endRide(rideId, const RidesCompanion());
final remoteRideId = await db.ensureCloudRideId(rideId);
fakeProvider.confirmationResults[remoteRideId] =
CloudConfirmationResult.legacyUnverified(
remoteRideId: remoteRideId,
ownerUid: 'rider_alice',
message: 'Historical bundle without checksum',
);
final outbox = SyncOutbox(prefs);
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
autoStart: false,
);
final report = await manager.reconcileCompletedRides();
expect(report.recoveredLegacyUnverifiedCount, equals(1));
expect(report.recoveredAlreadySyncedCount, equals(1));
expect(report.totalRecovered, equals(1));
final item = outbox.getItem(rideId, 'firebase');
expect(item?.status, equals(SyncStatus.synced));
expect(item?.lastError, contains('without modern checksum'));
expect(
fakeProvider.uploadedItems.isEmpty,
isTrue,
reason: 'Must never re-upload existing legacy unverified bundle',
);
},
);
test(
'18. Reconciliation Never Claims Unauthorized Rides: cloudOwnerUid remains null when verification returns unauthorized',
() async {
authService.setAuthenticatedUser('rider_eve', 'eve@example.com');
final rideId = await db.startRide(
startTime: DateTime.utc(2026, 10, 12, 15, 0, 0),
);
await db.endRide(rideId, const RidesCompanion());
final remoteRideId = await db.ensureCloudRideId(rideId);
// Verification returns unauthorized (foreign-owned or permission denied)
fakeProvider.confirmationResults[remoteRideId] =
CloudConfirmationResult.unauthorized(
remoteRideId: remoteRideId,
message: 'Foreign owner in cloud metadata',
);
final outbox = SyncOutbox(prefs);
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
autoStart: false,
);
final report = await manager.reconcileCompletedRides();
// Must NOT claim ownership for rider_eve
final localOwner = await db.getCloudOwnerUid(rideId);
expect(
localOwner,
isNull,
reason: 'Must NEVER claim an unauthorized ride',
);
expect(report.foreignOwnerSkippedCount, equals(1));
expect(report.errors.isNotEmpty, isTrue);
},
);
});
}

View File

@ -0,0 +1,194 @@
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/database/database.dart';
import 'package:motorcycle_dash/services/auth/auth_service.dart';
import 'package:motorcycle_dash/services/auth/auth_state.dart';
import 'package:motorcycle_dash/services/auth/auth_user.dart';
import 'package:motorcycle_dash/services/sync/sync_manager.dart';
import 'package:motorcycle_dash/services/sync/sync_outbox.dart';
import 'package:motorcycle_dash/services/sync/sync_provider.dart';
class _FakeAuthService implements AuthService {
final AuthUser _user;
_FakeAuthService([String uid = 'pilot_pixel_8a'])
: _user = AuthUser(
uid: uid,
email: '$uid@example.com',
displayName: 'Pixel Pilot',
isAnonymous: false,
);
@override
AuthUser? get currentUser => _user;
@override
AuthState get currentState => AuthState.authenticated(_user);
@override
Stream<AuthState> get authStateChanges => Stream.value(currentState);
@override
bool get isConfigured => true;
@override
bool get isEmailVerified => true;
@override
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {}
@override
Future<void> deleteCurrentUser() async {}
@override
void dispose() {}
@override
Future<void> ensureBackendIdentity() async {}
@override
Future<String?> getIdToken([bool forceRefresh = false]) async => 'fake_token';
@override
Future<void> reauthenticate({String? password}) async {}
@override
Future<void> reloadCurrentUser() async {}
@override
Future<void> sendEmailVerification() async {}
@override
Future<void> sendPasswordResetEmail(String email) async {}
@override
Future<void> signInAnonymously() async {}
@override
Future<void> signInWithEmailAndPassword(String email, String password) async {}
@override
Future<void> signInWithGoogle() async {}
@override
Future<void> signOut() async {}
}
File? _findBackupFile() {
final candidates = [
r'C:\Users\Bill\Desktop\flutter-app\output\master-consolidation-20261012\backup-071303\motorsignal.sqlite',
'output/master-consolidation-20261012/backup-071303/motorsignal.sqlite',
'../backup-071303/motorsignal.sqlite',
'../../output/master-consolidation-20261012/backup-071303/motorsignal.sqlite',
];
for (final path in candidates) {
final file = File(path);
if (file.existsSync()) {
return file;
}
}
return null;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final originalFile = _findBackupFile();
test(
'Phase 6 — Disposable Backup Verification & Outbox Recovery Gate',
() async {
expect(originalFile, isNotNull, reason: 'Original backup file must exist when test is executed');
final file = originalFile!;
// 1. Verify original SHA-256 before copy
final originalBytes = await file.readAsBytes();
final originalHash = sha256.convert(originalBytes).toString();
const expectedHash = '7125915195cdcf1de2d351426d8a991789904e7c3befd12af6f0d0f0b2416122';
expect(originalHash, equals(expectedHash), reason: 'Original backup must match authoritative SHA-256');
// 2. Create disposable copy in scratch directory
final scratchDir = Directory(
'${Directory.systemTemp.path}/motorsignal_backup_test_${DateTime.now().millisecondsSinceEpoch}',
);
if (!scratchDir.existsSync()) {
scratchDir.createSync(recursive: true);
}
final disposableFile = File('${scratchDir.path}/disposable_motorsignal.sqlite');
if (disposableFile.existsSync()) {
disposableFile.deleteSync();
}
await file.copy(disposableFile.path);
expect(disposableFile.existsSync(), isTrue);
// 3. Open disposable copy via Drift / SQLite
final db = AppDatabase.forTesting(NativeDatabase(disposableFile));
try {
// Integrity check
final integrityRows = await db.customSelect('PRAGMA integrity_check').get();
final integrityResult = integrityRows.first.data['integrity_check'];
expect(integrityResult, equals('ok'), reason: 'SQLite integrity must be OK');
// Count rides and samples
final rideCountRows = await db.customSelect('SELECT COUNT(*) as count FROM rides').get();
final totalRides = rideCountRows.first.data['count'] as int;
expect(totalRides, equals(22), reason: 'Must contain exactly 22 rides');
final completedRidesRows = await db.customSelect('SELECT COUNT(*) as count FROM rides WHERE end_time IS NOT NULL').get();
final completedRides = completedRidesRows.first.data['count'] as int;
final samplesRows = await db.customSelect('SELECT COUNT(*) as count FROM telemetry_samples').get();
final totalSamples = samplesRows.first.data['count'] as int;
expect(totalSamples, equals(67798), reason: 'Must contain exactly 67,798 telemetry samples');
// 4. Test Foreign Account Isolation against real Pixel backup
// In the real backup, rides are owned by Pixel rider '4btn4W3iOLWgfloMBYYK5hWa1hY2'
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final outbox = SyncOutbox(prefs);
final fakeProvider = FakeSyncProvider(providerId: 'firebase');
final foreignAuth = _FakeAuthService('foreign_pilot_123');
final foreignSyncManager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: foreignAuth,
autoStart: false,
);
final foreignReport = await foreignSyncManager.reconcileCompletedRides();
expect(foreignReport.errorCount, equals(0));
expect(foreignReport.discoveredCompletedRides, equals(completedRides));
expect(foreignReport.foreignOwnerSkippedCount, equals(completedRides), reason: 'Foreign account must NEVER claim or touch real Pixel rides');
expect(foreignReport.totalRecovered, equals(0));
expect(outbox.count, equals(0));
// 5. Test Owner Recovery against disposable backup
final ownerAuth = _FakeAuthService('4btn4W3iOLWgfloMBYYK5hWa1hY2');
final ownerSyncManager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: ownerAuth,
autoStart: false,
);
final ownerReport = await ownerSyncManager.reconcileCompletedRides();
expect(ownerReport.errorCount, equals(0));
expect(ownerReport.discoveredCompletedRides, equals(completedRides));
expect(ownerReport.recoveredQueuedCount, equals(completedRides));
expect(ownerReport.totalRecovered, equals(completedRides));
expect(outbox.count, equals(completedRides));
// 6. Test repeated reconciliation idempotence as owner
final report2 = await ownerSyncManager.reconcileCompletedRides();
expect(report2.totalRecovered, equals(0));
expect(report2.alreadyTrackedCount, equals(completedRides));
expect(outbox.count, equals(completedRides));
} finally {
await db.close();
}
// 6. Verify original SHA-256 after operations to prove zero mutation
final postBytes = await file.readAsBytes();
final postHash = sha256.convert(postBytes).toString();
expect(postHash, equals(expectedHash), reason: 'Original backup MUST remain 100% UNTOUCHED');
},
skip: originalFile == null
? 'Real Pixel backup not present in this environment (e.g. CI runner)'
: null,
);
}

View File

@ -104,6 +104,13 @@ class _IsolatedContractSyncProvider implements SyncProvider {
return user == null || user.isAnonymous; return user == null || user.isAnonymous;
} }
@override
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
@override
Future<CloudConfirmationResult> verifyCloudConfirmation(String remoteRideId) async =>
CloudConfirmationResult.missing(remoteRideId: remoteRideId);
@override @override
Future<SyncResult> uploadRide(SyncItem item) async { Future<SyncResult> uploadRide(SyncItem item) async {
if (!networkAvailable) { if (!networkAvailable) {

View File

@ -20,6 +20,13 @@ class _CountingProvider implements SyncProvider {
@override @override
Future<bool> requiresAuthentication() async => result == _Result.authRequired; Future<bool> requiresAuthentication() async => result == _Result.authRequired;
@override
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
@override
Future<CloudConfirmationResult> verifyCloudConfirmation(String remoteRideId) async =>
CloudConfirmationResult.missing(remoteRideId: remoteRideId);
@override @override
Future<SyncResult> uploadRide(SyncItem item) async { Future<SyncResult> uploadRide(SyncItem item) async {
uploadCount++; uploadCount++;

View File

@ -31,6 +31,13 @@ class _R2ThenFirestoreProvider implements SyncProvider {
@override @override
Future<bool> requiresAuthentication() async => false; Future<bool> requiresAuthentication() async => false;
@override
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
@override
Future<CloudConfirmationResult> verifyCloudConfirmation(String remoteRideId) async =>
CloudConfirmationResult.missing(remoteRideId: remoteRideId);
@override @override
Future<SyncResult> uploadRide(SyncItem item) async { Future<SyncResult> uploadRide(SyncItem item) async {
attempts++; attempts++;

View File

@ -21,6 +21,13 @@ class _ValidationProvider implements SyncProvider {
@override @override
Future<bool> requiresAuthentication() async => !authenticated; Future<bool> requiresAuthentication() async => !authenticated;
@override
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
@override
Future<CloudConfirmationResult> verifyCloudConfirmation(String remoteRideId) async =>
CloudConfirmationResult.missing(remoteRideId: remoteRideId);
@override @override
Future<SyncResult> uploadRide(SyncItem item) async { Future<SyncResult> uploadRide(SyncItem item) async {
uploadCount++; uploadCount++;