From b9ed2d629b036510ffdd928fa4eb925f88989e0f Mon Sep 17 00:00:00 2001 From: bponders12-cell Date: Tue, 1 Sep 2026 21:04:47 -0400 Subject: [PATCH] Add additive cloud metadata reconciliation service --- .../sync/cloud_reconciliation_service.dart | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 lib/services/sync/cloud_reconciliation_service.dart diff --git a/lib/services/sync/cloud_reconciliation_service.dart b/lib/services/sync/cloud_reconciliation_service.dart new file mode 100644 index 0000000..dbfd8bf --- /dev/null +++ b/lib/services/sync/cloud_reconciliation_service.dart @@ -0,0 +1,215 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import '../../database/database.dart'; +import '../auth/auth_service.dart'; + +class CloudReconciliationResult { + final int added; + final int unchanged; + final int conflicts; + final int skipped; + + const CloudReconciliationResult({ + this.added = 0, + this.unchanged = 0, + this.conflicts = 0, + this.skipped = 0, + }); + + CloudReconciliationResult operator +(CloudReconciliationResult other) { + return CloudReconciliationResult( + added: added + other.added, + unchanged: unchanged + other.unchanged, + conflicts: conflicts + other.conflicts, + skipped: skipped + other.skipped, + ); + } +} + +/// 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 FirebaseFirestore _firestore; + + CloudReconciliationService({ + required AppDatabase db, + required AuthService authService, + FirebaseFirestore? firestore, + }) : _db = db, + _authService = authService, + _firestore = firestore ?? FirebaseFirestore.instance; + + Future reconcileMetadata() async { + final user = _authService.currentUser; + if (user == null || user.isAnonymous) { + throw StateError('Authentication is required for cloud reconciliation.'); + } + + final snapshot = await _firestore + .collection('users') + .doc(user.uid) + .collection('rides') + .get(); + + var result = const CloudReconciliationResult(); + for (final document in snapshot.docs) { + result += await _reconcileDocument(document, user.uid); + } + return result; + } + + Future _reconcileDocument( + DocumentSnapshot> document, + String ownerId, + ) async { + final data = document.data(); + if (data == null) { + return const CloudReconciliationResult(skipped: 1); + } + + // 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(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 FROM rides WHERE id = ?', + variables: [Variable(localRideId)], + ).get(); + if (legacyRows.isNotEmpty) { + 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], + ); + return const CloudReconciliationResult(unchanged: 1); + } + if (existingCloudId == cloudRideId) { + return const CloudReconciliationResult(unchanged: 1); + } + return const CloudReconciliationResult(conflicts: 1); + } + } + + 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, + startTime: startTime, + endTime: endTime, + data: data, + ); + + return const CloudReconciliationResult(added: 1); + } + + Future _insertRestoredMetadata({ + required String cloudRideId, + required DateTime startTime, + required DateTime? endTime, + required Map data, + }) async { + await _db.customStatement(''' + INSERT INTO rides ( + cloud_ride_id, + start_time, + end_time, + distance_miles, + top_speed_mph, + max_lean_left, + max_lean_right, + max_accel_g, + max_braking_g, + distance, + average_speed, + top_speed, + elevation_gain, + elevation_loss, + motorcycle_id, + motorcycle_year, + motorcycle_make, + motorcycle_model, + motorcycle_variant + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', [ + cloudRideId, + startTime.toUtc().millisecondsSinceEpoch, + endTime?.toUtc().millisecondsSinceEpoch, + _asDouble(data['distanceMiles']), + _asDouble(data['topSpeedMph']), + _asDouble(data['maxLeanLeft']), + _asDouble(data['maxLeanRight']), + _asDouble(data['maxAccelG']), + _asDouble(data['maxBrakingG']), + _asDouble(data['distanceMiles']), + _asDouble(data['averageSpeed'] ?? data['averageSpeedMph']), + _asDouble(data['topSpeedMph']), + _asDouble(data['elevationGain']), + _asDouble(data['elevationLoss']), + _asInt(data['motorcycleId']), + _asInt(data['motorcycleYear']), + data['motorcycleMake']?.toString(), + data['motorcycleModel']?.toString(), + data['motorcycleVariant']?.toString(), + ]); + } + + bool _metadataDiffers(Map local, Map 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 - cloudDistance).abs() > 0.0001) return true; + if (cloudTopSpeed != 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(); + } +}