diff --git a/lib/services/sync/sync_outbox.dart b/lib/services/sync/sync_outbox.dart index 9a708ef..007e817 100644 --- a/lib/services/sync/sync_outbox.dart +++ b/lib/services/sync/sync_outbox.dart @@ -4,21 +4,38 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'sync_models.dart'; class SyncOutbox { - static const String _outboxStorageKey = 'motorsignal_sync_outbox_manifest_v1'; + static const String _outboxStorageKeyPrefix = 'motorsignal_sync_outbox_manifest_v2'; static const int maxOutboxCapacity = 500; final SharedPreferences _prefs; + final String _storageNamespace; final Map _items = {}; Future _saveChain = Future.value(); bool _isLoaded = false; - SyncOutbox(this._prefs) { load(); } + /// The namespace must be tied to the current Firebase UID (or a local + /// signed-out namespace). This prevents one account's sync state from being + /// mistaken for another account's state on the same device. + SyncOutbox(this._prefs, {String? storageNamespace}) + : _storageNamespace = _normalizeNamespace(storageNamespace) { + load(); + } + + static String _normalizeNamespace(String? value) { + final normalized = (value == null || value.trim().isEmpty) ? 'local' : value.trim(); + return normalized.replaceAll(RegExp(r'[^a-zA-Z0-9_.-]'), '_'); + } + + String get storageKey => '${_outboxStorageKeyPrefix}_$_storageNamespace'; bool get isLoaded => _isLoaded; int get count => _items.length; void load() { _items.clear(); - final rawJson = _prefs.getString(_outboxStorageKey); - if (rawJson == null || rawJson.trim().isEmpty) { _isLoaded = true; return; } + final rawJson = _prefs.getString(storageKey); + if (rawJson == null || rawJson.trim().isEmpty) { + _isLoaded = true; + return; + } try { final decoded = jsonDecode(rawJson); if (decoded is! List) throw const FormatException('Outbox manifest is not a list'); @@ -26,27 +43,33 @@ class SyncOutbox { try { final map = entry is Map ? entry : Map.from(entry as Map); final item = SyncItem.fromJson(map); - // An app/process death cannot leave an upload genuinely in progress. - // Requeue it so the provider can safely retry its deterministic operation. _items[item.key] = item.status == SyncStatus.uploading ? item.copyWith(status: SyncStatus.queued, clearNextRetry: true) : item; - } catch (itemError) { debugPrint('[SyncOutbox] Skipped malformed item: $itemError'); } + } catch (itemError) { + debugPrint('[SyncOutbox] Skipped malformed item: $itemError'); + } } - } catch (e) { debugPrint('[SyncOutbox] Corrupt manifest recovered safely: $e'); _items.clear(); } + } catch (e) { + debugPrint('[SyncOutbox] Corrupt manifest recovered safely: $e'); + _items.clear(); + } _isLoaded = true; } Future _save() { final operation = _saveChain.then((_) async { if (_items.length > maxOutboxCapacity) { - final terminal = _items.values.where((i) => i.status.isTerminal).toList()..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + final terminal = _items.values.where((i) => i.status.isTerminal).toList() + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); while (_items.length > maxOutboxCapacity && terminal.isNotEmpty) { _items.remove(terminal.removeAt(0).key); } } - if (_items.length > maxOutboxCapacity) throw StateError('Sync outbox capacity reached with no evictable terminal items.'); - return _prefs.setString(_outboxStorageKey, jsonEncode(_items.values.map((e) => e.toJson()).toList())); + if (_items.length > maxOutboxCapacity) { + throw StateError('Sync outbox capacity reached with no evictable terminal items.'); + } + return _prefs.setString(storageKey, jsonEncode(_items.values.map((e) => e.toJson()).toList())); }); _saveChain = operation.then((_) {}, onError: (_) {}); return operation; @@ -58,22 +81,44 @@ class SyncOutbox { List getEligibleItems(DateTime now) => _items.values.where((i) => i.isEligibleForAttempt(now)).toList(growable: false); Future enqueue(int localRideId, String providerId, {DateTime? now}) async { - if (localRideId <= 0 || providerId.trim().isEmpty) throw ArgumentError('A positive localRideId and non-empty providerId are required.'); + if (localRideId <= 0 || providerId.trim().isEmpty) { + throw ArgumentError('A positive localRideId and non-empty providerId are required.'); + } final normalizedProvider = providerId.trim(); final key = '$normalizedProvider:$localRideId'; final existing = _items[key]; if (existing != null) { - if (existing.status == SyncStatus.synced || existing.status == SyncStatus.queued || existing.status == SyncStatus.uploading) return existing; + if (existing.status == SyncStatus.synced || existing.status == SyncStatus.queued || existing.status == SyncStatus.uploading) { + return existing; + } final rearmed = existing.copyWith(status: SyncStatus.queued, clearLastError: true, clearNextRetry: true); - _items[key] = rearmed; await _save(); return rearmed; + _items[key] = rearmed; + await _save(); + return rearmed; + } + if (_items.length >= maxOutboxCapacity && !_items.values.any((i) => i.status.isTerminal)) { + throw StateError('Sync outbox capacity reached; pending rides were not discarded.'); } - if (_items.length >= maxOutboxCapacity && !_items.values.any((i) => i.status.isTerminal)) throw StateError('Sync outbox capacity reached; pending rides were not discarded.'); final item = SyncItem(localRideId: localRideId, providerId: normalizedProvider, status: SyncStatus.queued, createdAt: now ?? DateTime.now()); - _items[key] = item; await _save(); return item; + _items[key] = item; + await _save(); + return item; + } + + Future update(SyncItem item) async { + _items[item.key] = item; + await _save(); + } + + Future remove(int localRideId, String providerId) async { + if (_items.remove('${providerId.trim()}:$localRideId') != null) await _save(); + } + + Future clear() async { + _items.clear(); + _saveChain = Future.value(); + await _prefs.remove(storageKey); } - Future update(SyncItem item) async { _items[item.key] = item; await _save(); } - Future remove(int localRideId, String providerId) async { if (_items.remove('${providerId.trim()}:$localRideId') != null) await _save(); } - Future clear() async { _items.clear(); _saveChain = Future.value(); await _prefs.remove(_outboxStorageKey); } bool isRideSynced(int localRideId, String providerId) => getItem(localRideId, providerId)?.status == SyncStatus.synced; }