MotorSignal-App/lib/services/sync/cloud_reconciliation_servic...

307 lines
9.9 KiB
Dart

// ignore_for_file: type_init_formals
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:drift/drift.dart' show Variable;
import 'package:firebase_core/firebase_core.dart';
import '../../database/database.dart';
import '../auth/auth_service.dart';
class CloudRideMetadataDocument {
final String id;
final Map<String, dynamic> data;
const CloudRideMetadataDocument({required this.id, required this.data});
}
abstract class CloudRideMetadataStore {
Future<List<CloudRideMetadataDocument>> listRideDocuments(String uid);
}
class FirestoreCloudRideMetadataStore implements CloudRideMetadataStore {
final FirebaseFirestore? _firestore;
FirestoreCloudRideMetadataStore({FirebaseFirestore? firestore})
: _firestore = firestore ?? _resolveFirestore();
static FirebaseFirestore? _resolveFirestore() {
try {
if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance;
} catch (_) {}
return null;
}
@override
Future<List<CloudRideMetadataDocument>> listRideDocuments(String uid) async {
final firestore = _firestore;
if (firestore == null) {
throw StateError('Firebase services are unavailable or unconfigured.');
}
final snapshot = await firestore
.collection('users')
.doc(uid)
.collection('rides')
.get();
return snapshot.docs
.map(
(document) =>
CloudRideMetadataDocument(id: document.id, data: document.data()),
)
.toList(growable: false);
}
}
class CloudReconciliationResult {
final int discovered;
final int added;
final int unchanged;
final int conflicts;
final int skipped;
const CloudReconciliationResult({
this.discovered = 0,
this.added = 0,
this.unchanged = 0,
this.conflicts = 0,
this.skipped = 0,
});
CloudReconciliationResult operator +(CloudReconciliationResult other) {
return CloudReconciliationResult(
discovered: discovered + other.discovered,
added: added + other.added,
unchanged: unchanged + other.unchanged,
conflicts: conflicts + other.conflicts,
skipped: skipped + other.skipped,
);
}
int get existing => unchanged + conflicts;
}
/// Reconciles cloud ride metadata into the local Drift database without ever
/// replacing an existing local ride. Telemetry restoration is intentionally a
/// separate phase because R2 download/authentication is not available yet.
class CloudReconciliationService {
final AppDatabase _db;
final AuthService _authService;
final CloudRideMetadataStore _metadataStore;
CloudReconciliationService({
required AppDatabase this._db,
required AuthService this._authService,
FirebaseFirestore? firestore,
CloudRideMetadataStore? metadataStore,
}) : _metadataStore =
metadataStore ??
FirestoreCloudRideMetadataStore(firestore: firestore);
Future<CloudReconciliationResult> reconcileMetadata() async {
final user = _authService.currentUser;
if (user == null || user.isAnonymous) {
throw StateError('Authentication is required for cloud reconciliation.');
}
final documents = await _metadataStore.listRideDocuments(user.uid);
var result = CloudReconciliationResult(discovered: documents.length);
for (final document in documents) {
result += await _reconcileDocument(document, user.uid);
}
return result;
}
Future<CloudReconciliationResult> _reconcileDocument(
CloudRideMetadataDocument document,
String ownerId,
) async {
final data = document.data;
// Never trust a client-selected owner. Firestore rules are authoritative,
// and this client-side guard prevents accidental cross-account materialization.
final storedOwner = data['ownerId'] ?? data['uid'];
if (storedOwner != null && storedOwner != ownerId) {
return const CloudReconciliationResult(skipped: 1);
}
final cloudRideId = document.id;
if (!_isValidCloudRideId(cloudRideId)) {
return const CloudReconciliationResult(skipped: 1);
}
final localRows = await _db
.customSelect(
'SELECT id, start_time, end_time, distance_miles, top_speed_mph, cloud_ride_id '
'FROM rides WHERE cloud_ride_id = ?',
variables: [Variable<String>(cloudRideId)],
)
.get();
if (localRows.isNotEmpty) {
final local = localRows.first.data;
if (_metadataDiffers(local, data)) {
// Conservative conflict policy: do not overwrite local data. A future
// revision-aware sync layer can resolve this explicitly using versions.
return const CloudReconciliationResult(conflicts: 1);
}
return const CloudReconciliationResult(unchanged: 1);
}
final localRideId = _asInt(data['localRideId']);
if (localRideId != null) {
final legacyRows = await _db
.customSelect(
'SELECT id, cloud_ride_id, start_time FROM rides WHERE id = ?',
variables: [Variable<int>(localRideId)],
)
.get();
if (legacyRows.isNotEmpty) {
final existingCloudId = legacyRows.first.data['cloud_ride_id'];
final startTime = _readDateTime(data['startTime'] ?? data['startedAt']);
final localStartTime = _readDateTime(legacyRows.first.data['start_time']);
final isSameRide = (existingCloudId == null ||
existingCloudId.toString().isEmpty ||
existingCloudId == cloudRideId) &&
startTime != null &&
localStartTime != null &&
(localStartTime.difference(startTime).inSeconds).abs() <= 5;
if (isSameRide) {
await _db.customStatement(
'UPDATE rides SET cloud_ride_id = ?, cloud_owner_uid = COALESCE(cloud_owner_uid, ?) WHERE id = ?',
[cloudRideId, ownerId, localRideId],
);
return const CloudReconciliationResult(unchanged: 1);
}
// If not the same ride, this is simply a device local-id collision.
// Fall through directly to insert the cloud ride.
}
}
final startTime = _readDateTime(data['startTime'] ?? data['startedAt']);
if (startTime == null) {
return const CloudReconciliationResult(skipped: 1);
}
final endTime = _readDateTime(data['endTime'] ?? data['endedAt']);
await _insertRestoredMetadata(
cloudRideId: cloudRideId,
ownerId: ownerId,
startTime: startTime,
endTime: endTime,
data: data,
);
return const CloudReconciliationResult(added: 1);
}
Future<void> _insertRestoredMetadata({
required String cloudRideId,
required String ownerId,
required DateTime startTime,
required DateTime? endTime,
required Map<String, dynamic> data,
}) async {
await _db.customStatement(
'''
INSERT INTO rides (
cloud_ride_id,
cloud_owner_uid,
start_time,
end_time,
distance_miles,
top_speed_mph,
max_lean_left,
max_lean_right,
max_accel_g,
max_braking_g,
best0to60_ms,
best0to100_ms,
distance,
average_speed,
top_speed,
moving_time,
stopped_time,
elevation_gain,
elevation_loss,
motorcycle_id,
motorcycle_year,
motorcycle_make,
motorcycle_model,
motorcycle_variant
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
[
cloudRideId,
ownerId,
startTime.toUtc().millisecondsSinceEpoch,
endTime?.toUtc().millisecondsSinceEpoch,
_asDouble(data['distanceMiles']) ?? 0.0,
_asDouble(data['topSpeedMph']) ?? 0.0,
_asDouble(data['maxLeanLeft']) ?? 0.0,
_asDouble(data['maxLeanRight']) ?? 0.0,
_asDouble(data['maxAccelG']) ?? 0.0,
_asDouble(data['maxBrakingG']) ?? 0.0,
_asDouble(data['best0to60Ms'] ?? data['best0to60_ms']),
_asDouble(data['best0to100Ms'] ?? data['best0to100_ms']),
_asDouble(data['distanceMiles']) ?? 0.0,
_asDouble(data['averageSpeed'] ?? data['averageSpeedMph']) ?? 0.0,
_asDouble(data['topSpeedMph']) ?? 0.0,
_asInt(data['movingTime']) ?? 0,
_asInt(data['stoppedTime']) ?? 0,
_asDouble(data['elevationGain']) ?? 0.0,
_asDouble(data['elevationLoss']) ?? 0.0,
_asInt(data['motorcycleId']),
_asInt(data['motorcycleYear']),
data['motorcycleMake']?.toString(),
data['motorcycleModel']?.toString(),
data['motorcycleVariant']?.toString(),
],
);
}
bool _metadataDiffers(
Map<String, dynamic> local,
Map<String, dynamic> cloud,
) {
final localDistance = _asDouble(local['distance_miles']);
final cloudDistance = _asDouble(cloud['distanceMiles']);
final localTopSpeed = _asDouble(local['top_speed_mph']);
final cloudTopSpeed = _asDouble(cloud['topSpeedMph']);
// Only flag meaningful numeric divergence. Missing cloud fields are not
// treated as authoritative and therefore cannot overwrite local values.
if (cloudDistance != null &&
localDistance != null &&
(localDistance - cloudDistance).abs() > 0.0001) {
return true;
}
if (cloudTopSpeed != null &&
localTopSpeed != null &&
(localTopSpeed - cloudTopSpeed).abs() > 0.0001) {
return true;
}
return false;
}
bool _isValidCloudRideId(String id) =>
RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(id);
int? _asInt(Object? value) =>
value is num ? value.toInt() : int.tryParse(value?.toString() ?? '');
double? _asDouble(Object? value) => value is num
? value.toDouble()
: double.tryParse(value?.toString() ?? '');
DateTime? _readDateTime(Object? value) {
if (value is Timestamp) return value.toDate().toUtc();
if (value is DateTime) return value.toUtc();
if (value is num) {
return DateTime.fromMillisecondsSinceEpoch(value.toInt(), isUtc: true);
}
final parsed = DateTime.tryParse(value?.toString() ?? '');
return parsed?.toUtc();
}
}