fix: isolate MotorCloud ride ownership across accounts

This commit is contained in:
Developer 2026-09-03 08:12:10 -04:00
parent 8393849f39
commit 353b6fb66c
11 changed files with 286 additions and 18 deletions

View File

@ -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<String?> getCloudOwnerUid(int rideId) async {
final rows = await customSelect(
'SELECT cloud_owner_uid FROM rides WHERE id = ?',
variables: [Variable<int>(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<bool> 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<void> endRide(int rideId, RidesCompanion data) async {
await (update(rides)..where((t) => t.id.equals(rideId))).write(data.copyWith(endTime: Value(DateTime.now())));
}

View File

@ -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<String> cloudOwnerUid = GeneratedColumn<String>(
'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<GeneratedColumn> 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<Ride> {
/// 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<Ride> {
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<Ride> {
if (!nullToAbsent || cloudRideId != null) {
map['cloud_ride_id'] = Variable<String>(cloudRideId);
}
if (!nullToAbsent || cloudOwnerUid != null) {
map['cloud_owner_uid'] = Variable<String>(cloudOwnerUid);
}
map['start_time'] = Variable<DateTime>(startTime);
if (!nullToAbsent || endTime != null) {
map['end_time'] = Variable<DateTime>(endTime);
@ -742,6 +774,9 @@ class Ride extends DataClass implements Insertable<Ride> {
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<Ride> {
return Ride(
id: serializer.fromJson<int>(json['id']),
cloudRideId: serializer.fromJson<String?>(json['cloudRideId']),
cloudOwnerUid: serializer.fromJson<String?>(json['cloudOwnerUid']),
startTime: serializer.fromJson<DateTime>(json['startTime']),
endTime: serializer.fromJson<DateTime?>(json['endTime']),
distanceMiles: serializer.fromJson<double>(json['distanceMiles']),
@ -823,6 +859,7 @@ class Ride extends DataClass implements Insertable<Ride> {
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'cloudRideId': serializer.toJson<String?>(cloudRideId),
'cloudOwnerUid': serializer.toJson<String?>(cloudOwnerUid),
'startTime': serializer.toJson<DateTime>(startTime),
'endTime': serializer.toJson<DateTime?>(endTime),
'distanceMiles': serializer.toJson<double>(distanceMiles),
@ -851,6 +888,7 @@ class Ride extends DataClass implements Insertable<Ride> {
Ride copyWith({
int? id,
Value<String?> cloudRideId = const Value.absent(),
Value<String?> cloudOwnerUid = const Value.absent(),
DateTime? startTime,
Value<DateTime?> endTime = const Value.absent(),
double? distanceMiles,
@ -876,6 +914,9 @@ class Ride extends DataClass implements Insertable<Ride> {
}) => 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<Ride> {
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<Ride> {
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<Ride> {
int get hashCode => Object.hashAll([
id,
cloudRideId,
cloudOwnerUid,
startTime,
endTime,
distanceMiles,
@ -1036,6 +1082,7 @@ class Ride extends DataClass implements Insertable<Ride> {
(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<Ride> {
class RidesCompanion extends UpdateCompanion<Ride> {
final Value<int> id;
final Value<String?> cloudRideId;
final Value<String?> cloudOwnerUid;
final Value<DateTime> startTime;
final Value<DateTime?> endTime;
final Value<double> distanceMiles;
@ -1088,6 +1136,7 @@ class RidesCompanion extends UpdateCompanion<Ride> {
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<Ride> {
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<Ride> {
static Insertable<Ride> custom({
Expression<int>? id,
Expression<String>? cloudRideId,
Expression<String>? cloudOwnerUid,
Expression<DateTime>? startTime,
Expression<DateTime>? endTime,
Expression<double>? distanceMiles,
@ -1166,6 +1217,7 @@ class RidesCompanion extends UpdateCompanion<Ride> {
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<Ride> {
RidesCompanion copyWith({
Value<int>? id,
Value<String?>? cloudRideId,
Value<String?>? cloudOwnerUid,
Value<DateTime>? startTime,
Value<DateTime?>? endTime,
Value<double>? distanceMiles,
@ -1220,6 +1273,7 @@ class RidesCompanion extends UpdateCompanion<Ride> {
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<Ride> {
if (cloudRideId.present) {
map['cloud_ride_id'] = Variable<String>(cloudRideId.value);
}
if (cloudOwnerUid.present) {
map['cloud_owner_uid'] = Variable<String>(cloudOwnerUid.value);
}
if (startTime.present) {
map['start_time'] = Variable<DateTime>(startTime.value);
}
@ -1328,6 +1385,7 @@ class RidesCompanion extends UpdateCompanion<Ride> {
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<int> id,
Value<String?> cloudRideId,
Value<String?> cloudOwnerUid,
required DateTime startTime,
Value<DateTime?> endTime,
Value<double> distanceMiles,
@ -10057,6 +10116,7 @@ typedef $$RidesTableUpdateCompanionBuilder =
RidesCompanion Function({
Value<int> id,
Value<String?> cloudRideId,
Value<String?> cloudOwnerUid,
Value<DateTime> startTime,
Value<DateTime?> endTime,
Value<double> distanceMiles,
@ -10224,6 +10284,11 @@ class $$RidesTableFilterComposer extends Composer<_$AppDatabase, $RidesTable> {
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get cloudOwnerUid => $composableBuilder(
column: $table.cloudOwnerUid,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get startTime => $composableBuilder(
column: $table.startTime,
builder: (column) => ColumnFilters(column),
@ -10504,6 +10569,11 @@ class $$RidesTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get cloudOwnerUid => $composableBuilder(
column: $table.cloudOwnerUid,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get startTime => $composableBuilder(
column: $table.startTime,
builder: (column) => ColumnOrderings(column),
@ -10632,6 +10702,11 @@ class $$RidesTableAnnotationComposer
builder: (column) => column,
);
GeneratedColumn<String> get cloudOwnerUid => $composableBuilder(
column: $table.cloudOwnerUid,
builder: (column) => column,
);
GeneratedColumn<DateTime> get startTime =>
$composableBuilder(column: $table.startTime, builder: (column) => column);
@ -10922,6 +10997,7 @@ class $$RidesTableTableManager
({
Value<int> id = const Value.absent(),
Value<String?> cloudRideId = const Value.absent(),
Value<String?> cloudOwnerUid = const Value.absent(),
Value<DateTime> startTime = const Value.absent(),
Value<DateTime?> endTime = const Value.absent(),
Value<double> 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<int> id = const Value.absent(),
Value<String?> cloudRideId = const Value.absent(),
Value<String?> cloudOwnerUid = const Value.absent(),
required DateTime startTime,
Value<DateTime?> endTime = const Value.absent(),
Value<double> distanceMiles = const Value.absent(),
@ -10999,6 +11077,7 @@ class $$RidesTableTableManager
}) => RidesCompanion.insert(
id: id,
cloudRideId: cloudRideId,
cloudOwnerUid: cloudOwnerUid,
startTime: startTime,
endTime: endTime,
distanceMiles: distanceMiles,

View File

@ -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))();

View File

@ -25,6 +25,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
_AuthFormMode _formMode = _AuthFormMode.signIn;
bool _obscurePassword = true;
bool _showAnonymousUpgrade = false;
String? _statusMessage;
@override
@ -75,7 +76,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
isEvolution,
isWide,
);
} else if (authState.isAnonymous) {
} else if (authState.isAnonymous && !_showAnonymousUpgrade) {
content = _buildAnonymousView(
context,
authState.user!,
@ -719,8 +720,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
'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<AccountScreen> {
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<AccountScreen> {
],
_buildInfoRow(
'Cloud Provider',
'Firebase (Auth/Firestore) + Cloudflare R2',
'MotorCloud backup',
),
if (syncState.lastSuccessfulSync != null)
_buildInfoRow(
@ -1084,10 +1084,40 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
Future<void> _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<bool>(
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<AccountScreen> {
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<AccountScreen> {
),
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<AccountScreen> {
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.';
});
}
}

View File

@ -363,6 +363,7 @@ class FirebaseAuthService implements AuthService {
user: _currentState.user,
isFirebaseConfigured: true,
));
rethrow;
}
}

View File

@ -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<void> _insertRestoredMetadata({
required String cloudRideId,
required String ownerId,
required DateTime startTime,
required DateTime? endTime,
required Map<String, dynamic> 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']),

View File

@ -43,10 +43,19 @@ class FirebaseSyncProvider implements SyncProvider {
@override
Future<SyncResult> 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.

View File

@ -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<SyncProvider>? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider}) : _nowProvider = nowProvider ?? (() => DateTime.now()) {
SyncManager(this._outbox, {List<SyncProvider>? 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<SyncItem> 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());

View File

@ -79,6 +79,8 @@ final syncManagerProvider = ChangeNotifierProvider<SyncManager>((ref) {
ref.watch(githubSyncProvider),
],
defaultProviderId: 'firebase',
db: ref.watch(databaseProvider),
authService: ref.watch(authServiceProvider),
);
final accountSync = ref.watch(firebaseAccountSyncServiceProvider);

View File

@ -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);

View File

@ -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<AuthState> get authStateChanges => Stream.value(currentState);
@override Future<void> createUserWithEmailAndPassword(String e, String p, {String? displayName}) async {}
@override Future<void> sendPasswordResetEmail(String email) async {}
@override Future<void> signInAnonymously() async {}
@override Future<void> signInWithEmailAndPassword(String e, String p) async {}
@override Future<void> signInWithGoogle() async {}
@override Future<String?> getIdToken([bool forceRefresh = false]) async => user == null ? null : 'token';
@override Future<void> 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');
});
}