diff --git a/lib/database/database.dart b/lib/database/database.dart index 00fc344..11301da 100644 --- a/lib/database/database.dart +++ b/lib/database/database.dart @@ -18,7 +18,7 @@ class AppDatabase extends _$AppDatabase { static final Random _random = Random.secure(); @override - int get schemaVersion => 6; + int get schemaVersion => 7; @override MigrationStrategy get migration => MigrationStrategy( @@ -91,6 +91,9 @@ class AppDatabase extends _$AppDatabase { await customStatement('UPDATE rides SET cloud_ride_id = ? WHERE id = ?', [legacyId, id]); } } + if (from < 7) { + await m.addColumn(rides, rides.cloudOwnerUid); + } }, ); @@ -161,6 +164,25 @@ class AppDatabase extends _$AppDatabase { )); } + Future getCloudOwnerUid(int rideId) async { + final rows = await customSelect( + 'SELECT cloud_owner_uid FROM rides WHERE id = ?', + variables: [Variable(rideId)], + ).get(); + if (rows.isEmpty) return null; + return rows.first.data['cloud_owner_uid'] as String?; + } + + /// Claims only an unclaimed ride. Existing ownership is immutable. + Future claimRideForCloudOwner(int rideId, String uid) async { + final normalizedUid = uid.trim(); + if (normalizedUid.isEmpty) return false; + final changed = await (update(rides) + ..where((r) => r.id.equals(rideId) & r.cloudOwnerUid.isNull())) + .write(RidesCompanion(cloudOwnerUid: Value(normalizedUid))); + return changed == 1 || await getCloudOwnerUid(rideId) == normalizedUid; + } + Future endRide(int rideId, RidesCompanion data) async { await (update(rides)..where((t) => t.id.equals(rideId))).write(data.copyWith(endTime: Value(DateTime.now()))); } diff --git a/lib/database/database.g.dart b/lib/database/database.g.dart index bf3b585..23e0aa7 100644 --- a/lib/database/database.g.dart +++ b/lib/database/database.g.dart @@ -32,6 +32,17 @@ class $RidesTable extends Rides with TableInfo<$RidesTable, Ride> { type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _cloudOwnerUidMeta = const VerificationMeta( + 'cloudOwnerUid', + ); + @override + late final GeneratedColumn cloudOwnerUid = GeneratedColumn( + 'cloud_owner_uid', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _startTimeMeta = const VerificationMeta( 'startTime', ); @@ -292,6 +303,7 @@ class $RidesTable extends Rides with TableInfo<$RidesTable, Ride> { List get $columns => [ id, cloudRideId, + cloudOwnerUid, startTime, endTime, distanceMiles, @@ -339,6 +351,15 @@ class $RidesTable extends Rides with TableInfo<$RidesTable, Ride> { ), ); } + if (data.containsKey('cloud_owner_uid')) { + context.handle( + _cloudOwnerUidMeta, + cloudOwnerUid.isAcceptableOrUnknown( + data['cloud_owner_uid']!, + _cloudOwnerUidMeta, + ), + ); + } if (data.containsKey('start_time')) { context.handle( _startTimeMeta, @@ -538,6 +559,10 @@ class $RidesTable extends Rides with TableInfo<$RidesTable, Ride> { DriftSqlType.string, data['${effectivePrefix}cloud_ride_id'], ), + cloudOwnerUid: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}cloud_owner_uid'], + ), startTime: attachedDatabase.typeMapping.read( DriftSqlType.dateTime, data['${effectivePrefix}start_time'], @@ -640,6 +665,9 @@ class Ride extends DataClass implements Insertable { /// Stable MotorCloud identity preserved across local IDs, reinstalls, and sync retries. final String? cloudRideId; + + /// Firebase UID that owns this ride in MotorCloud. Null means device-only. + final String? cloudOwnerUid; final DateTime startTime; final DateTime? endTime; final double distanceMiles; @@ -665,6 +693,7 @@ class Ride extends DataClass implements Insertable { const Ride({ required this.id, this.cloudRideId, + this.cloudOwnerUid, required this.startTime, this.endTime, required this.distanceMiles, @@ -695,6 +724,9 @@ class Ride extends DataClass implements Insertable { if (!nullToAbsent || cloudRideId != null) { map['cloud_ride_id'] = Variable(cloudRideId); } + if (!nullToAbsent || cloudOwnerUid != null) { + map['cloud_owner_uid'] = Variable(cloudOwnerUid); + } map['start_time'] = Variable(startTime); if (!nullToAbsent || endTime != null) { map['end_time'] = Variable(endTime); @@ -742,6 +774,9 @@ class Ride extends DataClass implements Insertable { cloudRideId: cloudRideId == null && nullToAbsent ? const Value.absent() : Value(cloudRideId), + cloudOwnerUid: cloudOwnerUid == null && nullToAbsent + ? const Value.absent() + : Value(cloudOwnerUid), startTime: Value(startTime), endTime: endTime == null && nullToAbsent ? const Value.absent() @@ -791,6 +826,7 @@ class Ride extends DataClass implements Insertable { return Ride( id: serializer.fromJson(json['id']), cloudRideId: serializer.fromJson(json['cloudRideId']), + cloudOwnerUid: serializer.fromJson(json['cloudOwnerUid']), startTime: serializer.fromJson(json['startTime']), endTime: serializer.fromJson(json['endTime']), distanceMiles: serializer.fromJson(json['distanceMiles']), @@ -823,6 +859,7 @@ class Ride extends DataClass implements Insertable { return { 'id': serializer.toJson(id), 'cloudRideId': serializer.toJson(cloudRideId), + 'cloudOwnerUid': serializer.toJson(cloudOwnerUid), 'startTime': serializer.toJson(startTime), 'endTime': serializer.toJson(endTime), 'distanceMiles': serializer.toJson(distanceMiles), @@ -851,6 +888,7 @@ class Ride extends DataClass implements Insertable { Ride copyWith({ int? id, Value cloudRideId = const Value.absent(), + Value cloudOwnerUid = const Value.absent(), DateTime? startTime, Value endTime = const Value.absent(), double? distanceMiles, @@ -876,6 +914,9 @@ class Ride extends DataClass implements Insertable { }) => Ride( id: id ?? this.id, cloudRideId: cloudRideId.present ? cloudRideId.value : this.cloudRideId, + cloudOwnerUid: cloudOwnerUid.present + ? cloudOwnerUid.value + : this.cloudOwnerUid, startTime: startTime ?? this.startTime, endTime: endTime.present ? endTime.value : this.endTime, distanceMiles: distanceMiles ?? this.distanceMiles, @@ -913,6 +954,9 @@ class Ride extends DataClass implements Insertable { cloudRideId: data.cloudRideId.present ? data.cloudRideId.value : this.cloudRideId, + cloudOwnerUid: data.cloudOwnerUid.present + ? data.cloudOwnerUid.value + : this.cloudOwnerUid, startTime: data.startTime.present ? data.startTime.value : this.startTime, endTime: data.endTime.present ? data.endTime.value : this.endTime, distanceMiles: data.distanceMiles.present @@ -977,6 +1021,7 @@ class Ride extends DataClass implements Insertable { return (StringBuffer('Ride(') ..write('id: $id, ') ..write('cloudRideId: $cloudRideId, ') + ..write('cloudOwnerUid: $cloudOwnerUid, ') ..write('startTime: $startTime, ') ..write('endTime: $endTime, ') ..write('distanceMiles: $distanceMiles, ') @@ -1007,6 +1052,7 @@ class Ride extends DataClass implements Insertable { int get hashCode => Object.hashAll([ id, cloudRideId, + cloudOwnerUid, startTime, endTime, distanceMiles, @@ -1036,6 +1082,7 @@ class Ride extends DataClass implements Insertable { (other is Ride && other.id == this.id && other.cloudRideId == this.cloudRideId && + other.cloudOwnerUid == this.cloudOwnerUid && other.startTime == this.startTime && other.endTime == this.endTime && other.distanceMiles == this.distanceMiles && @@ -1063,6 +1110,7 @@ class Ride extends DataClass implements Insertable { class RidesCompanion extends UpdateCompanion { final Value id; final Value cloudRideId; + final Value cloudOwnerUid; final Value startTime; final Value endTime; final Value distanceMiles; @@ -1088,6 +1136,7 @@ class RidesCompanion extends UpdateCompanion { const RidesCompanion({ this.id = const Value.absent(), this.cloudRideId = const Value.absent(), + this.cloudOwnerUid = const Value.absent(), this.startTime = const Value.absent(), this.endTime = const Value.absent(), this.distanceMiles = const Value.absent(), @@ -1114,6 +1163,7 @@ class RidesCompanion extends UpdateCompanion { RidesCompanion.insert({ this.id = const Value.absent(), this.cloudRideId = const Value.absent(), + this.cloudOwnerUid = const Value.absent(), required DateTime startTime, this.endTime = const Value.absent(), this.distanceMiles = const Value.absent(), @@ -1140,6 +1190,7 @@ class RidesCompanion extends UpdateCompanion { static Insertable custom({ Expression? id, Expression? cloudRideId, + Expression? cloudOwnerUid, Expression? startTime, Expression? endTime, Expression? distanceMiles, @@ -1166,6 +1217,7 @@ class RidesCompanion extends UpdateCompanion { return RawValuesInsertable({ if (id != null) 'id': id, if (cloudRideId != null) 'cloud_ride_id': cloudRideId, + if (cloudOwnerUid != null) 'cloud_owner_uid': cloudOwnerUid, if (startTime != null) 'start_time': startTime, if (endTime != null) 'end_time': endTime, if (distanceMiles != null) 'distance_miles': distanceMiles, @@ -1194,6 +1246,7 @@ class RidesCompanion extends UpdateCompanion { RidesCompanion copyWith({ Value? id, Value? cloudRideId, + Value? cloudOwnerUid, Value? startTime, Value? endTime, Value? distanceMiles, @@ -1220,6 +1273,7 @@ class RidesCompanion extends UpdateCompanion { return RidesCompanion( id: id ?? this.id, cloudRideId: cloudRideId ?? this.cloudRideId, + cloudOwnerUid: cloudOwnerUid ?? this.cloudOwnerUid, startTime: startTime ?? this.startTime, endTime: endTime ?? this.endTime, distanceMiles: distanceMiles ?? this.distanceMiles, @@ -1254,6 +1308,9 @@ class RidesCompanion extends UpdateCompanion { if (cloudRideId.present) { map['cloud_ride_id'] = Variable(cloudRideId.value); } + if (cloudOwnerUid.present) { + map['cloud_owner_uid'] = Variable(cloudOwnerUid.value); + } if (startTime.present) { map['start_time'] = Variable(startTime.value); } @@ -1328,6 +1385,7 @@ class RidesCompanion extends UpdateCompanion { return (StringBuffer('RidesCompanion(') ..write('id: $id, ') ..write('cloudRideId: $cloudRideId, ') + ..write('cloudOwnerUid: $cloudOwnerUid, ') ..write('startTime: $startTime, ') ..write('endTime: $endTime, ') ..write('distanceMiles: $distanceMiles, ') @@ -10030,6 +10088,7 @@ typedef $$RidesTableCreateCompanionBuilder = RidesCompanion Function({ Value id, Value cloudRideId, + Value cloudOwnerUid, required DateTime startTime, Value endTime, Value distanceMiles, @@ -10057,6 +10116,7 @@ typedef $$RidesTableUpdateCompanionBuilder = RidesCompanion Function({ Value id, Value cloudRideId, + Value cloudOwnerUid, Value startTime, Value endTime, Value distanceMiles, @@ -10224,6 +10284,11 @@ class $$RidesTableFilterComposer extends Composer<_$AppDatabase, $RidesTable> { builder: (column) => ColumnFilters(column), ); + ColumnFilters get cloudOwnerUid => $composableBuilder( + column: $table.cloudOwnerUid, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get startTime => $composableBuilder( column: $table.startTime, builder: (column) => ColumnFilters(column), @@ -10504,6 +10569,11 @@ class $$RidesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get cloudOwnerUid => $composableBuilder( + column: $table.cloudOwnerUid, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get startTime => $composableBuilder( column: $table.startTime, builder: (column) => ColumnOrderings(column), @@ -10632,6 +10702,11 @@ class $$RidesTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get cloudOwnerUid => $composableBuilder( + column: $table.cloudOwnerUid, + builder: (column) => column, + ); + GeneratedColumn get startTime => $composableBuilder(column: $table.startTime, builder: (column) => column); @@ -10922,6 +10997,7 @@ class $$RidesTableTableManager ({ Value id = const Value.absent(), Value cloudRideId = const Value.absent(), + Value cloudOwnerUid = const Value.absent(), Value startTime = const Value.absent(), Value endTime = const Value.absent(), Value distanceMiles = const Value.absent(), @@ -10947,6 +11023,7 @@ class $$RidesTableTableManager }) => RidesCompanion( id: id, cloudRideId: cloudRideId, + cloudOwnerUid: cloudOwnerUid, startTime: startTime, endTime: endTime, distanceMiles: distanceMiles, @@ -10974,6 +11051,7 @@ class $$RidesTableTableManager ({ Value id = const Value.absent(), Value cloudRideId = const Value.absent(), + Value cloudOwnerUid = const Value.absent(), required DateTime startTime, Value endTime = const Value.absent(), Value distanceMiles = const Value.absent(), @@ -10999,6 +11077,7 @@ class $$RidesTableTableManager }) => RidesCompanion.insert( id: id, cloudRideId: cloudRideId, + cloudOwnerUid: cloudOwnerUid, startTime: startTime, endTime: endTime, distanceMiles: distanceMiles, diff --git a/lib/database/tables.dart b/lib/database/tables.dart index 4410576..c5e5f77 100644 --- a/lib/database/tables.dart +++ b/lib/database/tables.dart @@ -4,6 +4,8 @@ class Rides extends Table { IntColumn get id => integer().autoIncrement()(); /// Stable MotorCloud identity preserved across local IDs, reinstalls, and sync retries. TextColumn get cloudRideId => text().nullable()(); + /// Firebase UID that owns this ride in MotorCloud. Null means device-only. + TextColumn get cloudOwnerUid => text().nullable()(); DateTimeColumn get startTime => dateTime()(); DateTimeColumn get endTime => dateTime().nullable()(); RealColumn get distanceMiles => real().withDefault(const Constant(0.0))(); diff --git a/lib/features/settings/account_screen.dart b/lib/features/settings/account_screen.dart index 22e31f9..6c49727 100644 --- a/lib/features/settings/account_screen.dart +++ b/lib/features/settings/account_screen.dart @@ -25,6 +25,7 @@ class _AccountScreenState extends ConsumerState { _AuthFormMode _formMode = _AuthFormMode.signIn; bool _obscurePassword = true; + bool _showAnonymousUpgrade = false; String? _statusMessage; @override @@ -75,7 +76,7 @@ class _AccountScreenState extends ConsumerState { isEvolution, isWide, ); - } else if (authState.isAnonymous) { + } else if (authState.isAnonymous && !_showAnonymousUpgrade) { content = _buildAnonymousView( context, authState.user!, @@ -719,8 +720,7 @@ class _AccountScreenState extends ConsumerState { 'SIGN IN WITH FULL ACCOUNT', style: TextStyle(fontWeight: FontWeight.bold), ), - onPressed: () => - ref.read(authNotifierProvider.notifier).signOut(), + onPressed: () => setState(() => _showAnonymousUpgrade = true), ), ), ], @@ -803,7 +803,7 @@ class _AccountScreenState extends ConsumerState { const SizedBox(height: 12), if (!isAuthenticated) ...[ const Text( - 'Sign in to back up your rides and access multi-device synchronization. All rides are safely stored on this device in your local Drift SQLite database.', + 'Sign in to MotorCloud to back up eligible rides and restore them on another device. Local rides remain available on this device.', style: TextStyle( fontSize: 11, color: Colors.white70, @@ -891,7 +891,7 @@ class _AccountScreenState extends ConsumerState { ], _buildInfoRow( 'Cloud Provider', - 'Firebase (Auth/Firestore) + Cloudflare R2', + 'MotorCloud backup', ), if (syncState.lastSuccessfulSync != null) _buildInfoRow( @@ -1084,10 +1084,40 @@ class _AccountScreenState extends ConsumerState { Future _handleBackupAllHistoricalRides() async { final db = ref.read(databaseProvider); final syncManager = ref.read(syncManagerProvider); + final user = ref.read(currentUserProvider); + if (user == null || user.isAnonymous) return; final rides = await db.select(db.rides).get(); + final owned = rides.where((ride) => ride.cloudOwnerUid == user.uid).toList(); + final unclaimed = rides.where((ride) => ride.cloudOwnerUid == null).toList(); + final otherOwned = rides.length - owned.length - unclaimed.length; + var claimUnclaimed = false; + if (unclaimed.isNotEmpty && mounted) { + claimUnclaimed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Back Up Device Rides?'), + content: Text( + 'This will associate ${unclaimed.length} device-local ride(s) with your signed-in MotorCloud account. Rides already associated with another account will not be included.', + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('CANCEL')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('BACK UP')), + ], + ), + ) ?? + false; + } - int enqueued = 0; - for (final ride in rides) { + var enqueued = 0; + if (claimUnclaimed) { + for (final ride in unclaimed) { + if (await db.claimRideForCloudOwner(ride.id, user.uid)) { + await syncManager.enqueueRide(ride.id, autoProcess: false); + enqueued++; + } + } + } + for (final ride in owned) { await syncManager.enqueueRide(ride.id, autoProcess: false); enqueued++; } @@ -1096,7 +1126,7 @@ class _AccountScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - 'Enqueued $enqueued local ride(s) for MotorCloud synchronization.', + 'Queued $enqueued ride(s). Skipped $otherOwned ride(s) associated with another account.', ), duration: const Duration(seconds: 3), ), @@ -1227,7 +1257,7 @@ class _AccountScreenState extends ConsumerState { ), SizedBox(height: 2), Text( - 'Your local Schema 5 Drift SQLite database remains the authoritative source of truth for telemetry and rides.', + 'Your local ride history remains available on this device. MotorCloud backup is optional and never required for recording or offline use.', style: TextStyle(fontSize: 11, color: Colors.white60), ), ], @@ -1304,10 +1334,14 @@ class _AccountScreenState extends ConsumerState { displayName: displayName.isNotEmpty ? displayName : null, ); } else if (_formMode == _AuthFormMode.forgotPassword) { - await notifier.sendPasswordResetEmail(email); - if (mounted) { + try { + await notifier.sendPasswordResetEmail(email); + } catch (_) { + return; + } + if (mounted && !ref.read(authNotifierProvider).hasError) { setState(() { - _statusMessage = 'Password reset instructions sent to $email'; + _statusMessage = 'Password reset instructions sent. Check your email.'; }); } } diff --git a/lib/services/auth/auth_service.dart b/lib/services/auth/auth_service.dart index e118b7a..2ec3504 100644 --- a/lib/services/auth/auth_service.dart +++ b/lib/services/auth/auth_service.dart @@ -363,6 +363,7 @@ class FirebaseAuthService implements AuthService { user: _currentState.user, isFirebaseConfigured: true, )); + rethrow; } } diff --git a/lib/services/sync/cloud_reconciliation_service.dart b/lib/services/sync/cloud_reconciliation_service.dart index 97571db..6310bba 100644 --- a/lib/services/sync/cloud_reconciliation_service.dart +++ b/lib/services/sync/cloud_reconciliation_service.dart @@ -158,12 +158,16 @@ class CloudReconciliationService { final existingCloudId = legacyRows.first.data['cloud_ride_id']; if (existingCloudId == null || existingCloudId.toString().isEmpty) { await _db.customStatement( - 'UPDATE rides SET cloud_ride_id = ? WHERE id = ?', - [cloudRideId, localRideId], + 'UPDATE rides SET cloud_ride_id = ?, cloud_owner_uid = COALESCE(cloud_owner_uid, ?) WHERE id = ?', + [cloudRideId, ownerId, localRideId], ); return const CloudReconciliationResult(unchanged: 1); } if (existingCloudId == cloudRideId) { + await _db.customStatement( + 'UPDATE rides SET cloud_owner_uid = COALESCE(cloud_owner_uid, ?) WHERE id = ?', + [ownerId, localRideId], + ); return const CloudReconciliationResult(unchanged: 1); } return const CloudReconciliationResult(conflicts: 1); @@ -178,6 +182,7 @@ class CloudReconciliationService { final endTime = _readDateTime(data['endTime'] ?? data['endedAt']); await _insertRestoredMetadata( cloudRideId: cloudRideId, + ownerId: ownerId, startTime: startTime, endTime: endTime, data: data, @@ -188,6 +193,7 @@ class CloudReconciliationService { Future _insertRestoredMetadata({ required String cloudRideId, + required String ownerId, required DateTime startTime, required DateTime? endTime, required Map data, @@ -196,6 +202,7 @@ class CloudReconciliationService { ''' INSERT INTO rides ( cloud_ride_id, + cloud_owner_uid, start_time, end_time, distance_miles, @@ -216,10 +223,11 @@ class CloudReconciliationService { motorcycle_make, motorcycle_model, motorcycle_variant - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', [ cloudRideId, + ownerId, startTime.toUtc().millisecondsSinceEpoch, endTime?.toUtc().millisecondsSinceEpoch, _asDouble(data['distanceMiles']), diff --git a/lib/services/sync/firebase_sync_provider.dart b/lib/services/sync/firebase_sync_provider.dart index 6db304f..73a3684 100644 --- a/lib/services/sync/firebase_sync_provider.dart +++ b/lib/services/sync/firebase_sync_provider.dart @@ -43,10 +43,19 @@ class FirebaseSyncProvider implements SyncProvider { @override Future uploadRide(SyncItem item) async { - if (!await isAvailable()) return SyncResult.temporaryFailure('Firebase services are unavailable or unconfigured. Ride remains safely stored locally.'); final user = _authService.currentUser; if (user == null || user.isAnonymous) return SyncResult.authRequired(message: 'Sign in to MotorCloud is required for cloud backup.'); + final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(item.localRideId))).getSingleOrNull(); + if (ride != null && ride.cloudOwnerUid != user.uid) { + return SyncResult.permanentFailure( + ride.cloudOwnerUid == null + ? 'This device-only ride needs explicit approval before MotorCloud backup.' + : 'This ride belongs to another MotorCloud account and was not uploaded.', + ); + } + if (!await isAvailable()) return SyncResult.temporaryFailure('Firebase services are unavailable or unconfigured. Ride remains safely stored locally.'); + // Refresh the Firebase ID token before a manual upload. This prevents a // recently completed sign-in from being rejected because the cached token // has not yet propagated through the app's auth abstraction. diff --git a/lib/services/sync/sync_manager.dart b/lib/services/sync/sync_manager.dart index b64fc29..786cf61 100644 --- a/lib/services/sync/sync_manager.dart +++ b/lib/services/sync/sync_manager.dart @@ -3,6 +3,8 @@ import 'package:flutter/widgets.dart'; import 'sync_models.dart'; import 'sync_outbox.dart'; import 'sync_provider.dart'; +import '../../database/database.dart'; +import '../auth/auth_service.dart'; /// Central provider-agnostic synchronization coordinator. /// @@ -15,8 +17,10 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { String _activeProviderId = 'fake'; bool _isProcessing = false; DateTime? Function() _nowProvider; + final AppDatabase? _db; + final AuthService? _authService; - SyncManager(this._outbox, {List? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider}) : _nowProvider = nowProvider ?? (() => DateTime.now()) { + SyncManager(this._outbox, {List? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider, this._db, this._authService}) : _nowProvider = nowProvider ?? (() => DateTime.now()) { if (initialProviders != null) { for (final p in initialProviders) { registerProvider(p); } } @@ -61,6 +65,12 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { Future enqueueRide(int localRideId, {String? targetProviderId, bool autoProcess = true}) async { final providerId = targetProviderId ?? _activeProviderId; + if (providerId == 'firebase') { + final user = _authService?.currentUser; + if (user != null && !user.isAnonymous && _db != null) { + await _db.claimRideForCloudOwner(localRideId, user.uid); + } + } final item = await _outbox.enqueue(localRideId, providerId, now: _now()); notifyListeners(); if (autoProcess) unawaited(processQueue()); diff --git a/lib/services/sync/sync_providers.dart b/lib/services/sync/sync_providers.dart index 28195de..4f6aefd 100644 --- a/lib/services/sync/sync_providers.dart +++ b/lib/services/sync/sync_providers.dart @@ -79,6 +79,8 @@ final syncManagerProvider = ChangeNotifierProvider((ref) { ref.watch(githubSyncProvider), ], defaultProviderId: 'firebase', + db: ref.watch(databaseProvider), + authService: ref.watch(authServiceProvider), ); final accountSync = ref.watch(firebaseAccountSyncServiceProvider); diff --git a/test/cloud_ride_identity_migration_test.dart b/test/cloud_ride_identity_migration_test.dart index 59cab19..0dc2c0a 100644 --- a/test/cloud_ride_identity_migration_test.dart +++ b/test/cloud_ride_identity_migration_test.dart @@ -90,6 +90,7 @@ void main() { final migrated = await db.getCloudRideId(42); expect(migrated, 'ride_42_20260831_210705'); + expect(await db.getCloudOwnerUid(42), isNull); final rows = await db.customSelect('PRAGMA table_info(rides)').get(); expect(rows.any((row) => row.data['name'] == 'cloud_ride_id'), isTrue); diff --git a/test/ownership_isolation_test.dart b/test/ownership_isolation_test.dart new file mode 100644 index 0000000..73e151a --- /dev/null +++ b/test/ownership_isolation_test.dart @@ -0,0 +1,100 @@ +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'; +import 'package:motorcycle_dash/services/sync/sync_models.dart'; +import 'package:motorcycle_dash/services/sync/firebase_sync_provider.dart'; + +class _Auth implements AuthService { + AuthUser? user; + @override bool get isConfigured => true; + @override AuthUser? get currentUser => user; + @override AuthState get currentState => user == null ? const AuthState.unauthenticated() : AuthState.authenticated(user!); + @override Stream get authStateChanges => Stream.value(currentState); + @override Future createUserWithEmailAndPassword(String e, String p, {String? displayName}) async {} + @override Future sendPasswordResetEmail(String email) async {} + @override Future signInAnonymously() async {} + @override Future signInWithEmailAndPassword(String e, String p) async {} + @override Future signInWithGoogle() async {} + @override Future getIdToken([bool forceRefresh = false]) async => user == null ? null : 'token'; + @override Future signOut() async { user = null; } + @override void dispose() {} +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('new rides are unclaimed and claim is immutable', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + final rideId = await db.startRide(); + expect(await db.getCloudOwnerUid(rideId), isNull); + expect(await db.claimRideForCloudOwner(rideId, 'user_a'), isTrue); + expect(await db.getCloudOwnerUid(rideId), 'user_a'); + expect(await db.claimRideForCloudOwner(rideId, 'user_b'), isFalse); + expect(await db.getCloudOwnerUid(rideId), 'user_a'); + expect(await db.getCloudRideId(rideId), startsWith('ride_')); + }); + + test('authenticated enqueue claims only for the current permanent account', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + final auth = _Auth()..user = const AuthUser(uid: 'user_a', email: 'a@test.com'); + final rideId = await db.startRide(); + final manager = SyncManager( + SyncOutbox(prefs), + db: db, + authService: auth, + initialProviders: [FakeSyncProvider(providerId: 'firebase')], + defaultProviderId: 'firebase', + ); + addTearDown(manager.dispose); + + await manager.enqueueRide(rideId, autoProcess: false); + expect(await db.getCloudOwnerUid(rideId), 'user_a'); + + auth.user = const AuthUser(uid: 'user_b', email: 'b@test.com'); + await manager.enqueueRide(rideId, autoProcess: false); + expect(await db.getCloudOwnerUid(rideId), 'user_a'); + }); + + test('signed-out enqueue leaves ride unclaimed', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + final rideId = await db.startRide(); + final manager = SyncManager( + SyncOutbox(prefs), + db: db, + authService: _Auth(), + initialProviders: [FakeSyncProvider(providerId: 'firebase')], + defaultProviderId: 'firebase', + ); + addTearDown(manager.dispose); + await manager.enqueueRide(rideId, autoProcess: false); + expect(await db.getCloudOwnerUid(rideId), isNull); + }); + + test('Firebase provider rejects another account before any cloud call', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + final rideId = await db.startRide(); + await db.claimRideForCloudOwner(rideId, 'user_a'); + final auth = _Auth()..user = const AuthUser(uid: 'user_b', email: 'b@test.com'); + final result = await FirebaseSyncProvider(db: db, authService: auth).uploadRide( + SyncItem(localRideId: rideId, providerId: 'firebase', createdAt: DateTime.now()), + ); + expect(result.success, isFalse); + expect(result.isRetryable, isFalse); + expect(await db.getCloudOwnerUid(rideId), 'user_a'); + }); +}