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

216 lines
7.3 KiB
Dart

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<CloudReconciliationResult> 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<CloudReconciliationResult> _reconcileDocument(
DocumentSnapshot<Map<String, dynamic>> 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<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 FROM rides WHERE id = ?',
variables: [Variable<int>(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<void> _insertRestoredMetadata({
required String cloudRideId,
required DateTime startTime,
required DateTime? endTime,
required Map<String, dynamic> 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<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 - 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();
}
}