diff --git a/README.md b/README.md index 10fb0a57..505b6281 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,13 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh - **Statistics Dashboard**: View repeater traffic, connected clients, and system health - **Remote Management**: Administer repeaters from anywhere on the mesh network +### Room Server Catch-Up + +- **Auto Login + Catch-Up Sync**: Optional automatic login with push-priority backlog sync and periodic fallback for room servers with saved credentials +- **Per-Room Sync Control**: Auto-sync is opt-in per room server from the room options sheet +- **Sync Health Indicators**: Room status in contacts/chat (`synced`, `syncing`, `stale`, `not logged in`, `sync disabled`) +- **Tunable Sync Parameters**: Configure base interval, max backoff, timeout, and stale threshold in app settings (conservative defaults) + ## Technical Details ### Architecture @@ -161,11 +168,13 @@ lib/ │ ├── notification_service.dart # Push notifications │ ├── message_retry_service.dart # Automatic message retry │ ├── background_service.dart # Background BLE connection -│ └── map_tile_cache_service.dart # Offline map storage +│ ├── map_tile_cache_service.dart # Offline map storage +│ └── room_sync_service.dart # Room server auto-login and catch-up orchestration └── storage/ ├── message_store.dart # Message persistence ├── contact_store.dart # Contact database - └── unread_store.dart # Unread message tracking + ├── unread_store.dart # Unread message tracking + └── room_sync_store.dart # Persistent room sync state ``` ## BLE Protocol @@ -192,6 +201,11 @@ Messages are transmitted as binary frames using a custom protocol optimized for - **Notifications**: Configurable for messages, channels, and node advertisements - **Battery Chemistry**: Support for NMC, LiFePO4, and LiPo battery types - **Message Retry**: Automatic retry with configurable path clearing +- **Room Sync**: Global enable or disable, auto-login toggle, per-room opt-in, interval/backoff/timeout/stale thresholds + +### Room Sync Guide + +Detailed room sync behavior and validation steps are documented in `docs/ROOM_SYNC.md`. ### Device Settings @@ -211,6 +225,7 @@ This is an open-source project. Contributions are welcome! - Use Material 3 design components - Write clear commit messages - Test on both Android and iOS before submitting PRs +- For room sync changes, run the checklist in `docs/ROOM_SYNC.md` ### Code Style diff --git a/docs/ROOM_SYNC.md b/docs/ROOM_SYNC.md new file mode 100644 index 00000000..902f984e --- /dev/null +++ b/docs/ROOM_SYNC.md @@ -0,0 +1,73 @@ +# Room Sync Feature Guide + +This document describes the room server auto-sync feature and the minimum validation expected before opening a pull request. + +## Scope + +Room sync adds app-side reliability for room server catch-up: + +- Optional auto-login to room servers with saved passwords +- Push-priority queued message sync with timeout and exponential backoff fallback +- Per-room auto-sync control (rooms are opt-in by default) +- Room sync status indicators in contacts and room chat header +- Global room sync tuning in app settings + +## User Controls + +### Global controls + +App settings include: + +- Enable room auto-sync +- Auto-login saved room sessions +- Base sync interval (seconds) +- Max backoff interval (seconds) +- Sync timeout (seconds) +- Stale threshold (minutes) + +Default tuning is conservative for low traffic: + +- Base interval: `300s` +- Max backoff: `3600s` +- Sync timeout: `20s` +- Stale threshold: `45m` + +When firmware emits `PUSH_CODE_MSG_WAITING`, room sync schedules an immediate catch-up (throttled) for enabled active rooms. + +### Per-room control + +From Contacts, long-press a room server and use: + +- `Auto-sync this room` switch + +Auto-sync is disabled by default for newly discovered rooms. When disabled, that room is excluded from auto-login and periodic sync. + +Saving and using a successful manual room login will automatically opt that room into auto-sync unless it was explicitly disabled. + +## Status Meanings + +- `Connected, synced`: active room session with recent successful sync +- `Syncing...`: sync cycle currently in progress +- `Connected, stale`: room session active but last successful sync is older than stale threshold +- `Not logged in`: sync is enabled but no active room session +- `Sync disabled`: per-room auto-sync is turned off +- `Room sync off`: global room sync feature is disabled + +## PR Validation Checklist + +Run this checklist on a real Android device and at least one room server. + +1. Enable global room sync and auto-login, keep one room enabled. +2. Save room password, disconnect BLE, reconnect BLE. +3. Confirm room reaches `Connected, synced` without manual login. +4. Set very short timeout and confirm status transitions during intermittent RF. +5. Disable auto-sync for one room and verify it shows `Sync disabled`. +6. Confirm enabled room still syncs while disabled room does not. +7. Re-enable disabled room and verify login/sync resumes. +8. Confirm app restart preserves per-room toggle state and room statuses. +9. Run `flutter analyze` with no new issues. + +## Notes + +- Feature is app-side and protocol-compatible with current room server firmware. +- Per-room selection is the intended way to avoid syncing every room in large node lists. diff --git a/lib/main.dart b/lib/main.dart index 9e53e215..44025658 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,6 +19,8 @@ import 'services/app_debug_log_service.dart'; import 'services/background_service.dart'; import 'services/map_tile_cache_service.dart'; import 'services/chat_text_scale_service.dart'; +import 'services/room_sync_service.dart'; +import 'storage/room_sync_store.dart'; import 'storage/prefs_manager.dart'; import 'utils/app_logger.dart'; @@ -39,6 +41,10 @@ void main() async { final backgroundService = BackgroundService(); final mapTileCacheService = MapTileCacheService(); final chatTextScaleService = ChatTextScaleService(); + final roomSyncService = RoomSyncService( + roomSyncStore: RoomSyncStore(), + storageService: storage, + ); // Load settings await appSettingsService.loadSettings(); @@ -74,6 +80,11 @@ void main() async { // Load persisted channel messages await connector.loadAllChannelMessages(); await connector.loadUnreadState(); + await roomSyncService.initialize( + connector: connector, + appSettingsService: appSettingsService, + appDebugLogService: appDebugLogService, + ); runApp( MeshCoreApp( @@ -86,6 +97,7 @@ void main() async { appDebugLogService: appDebugLogService, mapTileCacheService: mapTileCacheService, chatTextScaleService: chatTextScaleService, + roomSyncService: roomSyncService, ), ); } @@ -121,6 +133,7 @@ class MeshCoreApp extends StatelessWidget { final AppDebugLogService appDebugLogService; final MapTileCacheService mapTileCacheService; final ChatTextScaleService chatTextScaleService; + final RoomSyncService roomSyncService; const MeshCoreApp({ super.key, @@ -133,6 +146,7 @@ class MeshCoreApp extends StatelessWidget { required this.appDebugLogService, required this.mapTileCacheService, required this.chatTextScaleService, + required this.roomSyncService, }); @override @@ -146,6 +160,7 @@ class MeshCoreApp extends StatelessWidget { ChangeNotifierProvider.value(value: bleDebugLogService), ChangeNotifierProvider.value(value: appDebugLogService), ChangeNotifierProvider.value(value: chatTextScaleService), + ChangeNotifierProvider.value(value: roomSyncService), Provider.value(value: storage), Provider.value(value: mapTileCacheService), ], diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 62ba9ca6..77085ac0 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -37,6 +37,12 @@ class AppSettings { final Map batteryChemistryByDeviceId; final Map batteryChemistryByRepeaterId; final UnitSystem unitSystem; + final bool roomSyncEnabled; + final bool roomSyncAutoLoginEnabled; + final int roomSyncIntervalSeconds; + final int roomSyncMaxIntervalSeconds; + final int roomSyncTimeoutSeconds; + final int roomSyncStaleMinutes; final Set mutedChannels; AppSettings({ @@ -63,6 +69,12 @@ class AppSettings { Map? batteryChemistryByDeviceId, Map? batteryChemistryByRepeaterId, this.unitSystem = UnitSystem.metric, + this.roomSyncEnabled = true, + this.roomSyncAutoLoginEnabled = true, + this.roomSyncIntervalSeconds = 300, + this.roomSyncMaxIntervalSeconds = 3600, + this.roomSyncTimeoutSeconds = 20, + this.roomSyncStaleMinutes = 45, Set? mutedChannels, }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}, batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {}, @@ -93,6 +105,12 @@ class AppSettings { 'battery_chemistry_by_device_id': batteryChemistryByDeviceId, 'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId, 'unit_system': unitSystem.value, + 'room_sync_enabled': roomSyncEnabled, + 'room_sync_auto_login_enabled': roomSyncAutoLoginEnabled, + 'room_sync_interval_seconds': roomSyncIntervalSeconds, + 'room_sync_max_interval_seconds': roomSyncMaxIntervalSeconds, + 'room_sync_timeout_seconds': roomSyncTimeoutSeconds, + 'room_sync_stale_minutes': roomSyncStaleMinutes, 'muted_channels': mutedChannels.toList(), }; } @@ -142,6 +160,15 @@ class AppSettings { ) ?? {}, unitSystem: parseUnitSystem(json['unit_system']), + roomSyncEnabled: json['room_sync_enabled'] as bool? ?? true, + roomSyncAutoLoginEnabled: + json['room_sync_auto_login_enabled'] as bool? ?? true, + roomSyncIntervalSeconds: + json['room_sync_interval_seconds'] as int? ?? 300, + roomSyncMaxIntervalSeconds: + json['room_sync_max_interval_seconds'] as int? ?? 3600, + roomSyncTimeoutSeconds: json['room_sync_timeout_seconds'] as int? ?? 20, + roomSyncStaleMinutes: json['room_sync_stale_minutes'] as int? ?? 45, mutedChannels: ((json['muted_channels'] as List?) ?.map((e) => e.toString()) @@ -174,6 +201,12 @@ class AppSettings { Map? batteryChemistryByDeviceId, Map? batteryChemistryByRepeaterId, UnitSystem? unitSystem, + bool? roomSyncEnabled, + bool? roomSyncAutoLoginEnabled, + int? roomSyncIntervalSeconds, + int? roomSyncMaxIntervalSeconds, + int? roomSyncTimeoutSeconds, + int? roomSyncStaleMinutes, Set? mutedChannels, }) { return AppSettings( @@ -208,6 +241,16 @@ class AppSettings { batteryChemistryByRepeaterId: batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId, unitSystem: unitSystem ?? this.unitSystem, + roomSyncEnabled: roomSyncEnabled ?? this.roomSyncEnabled, + roomSyncAutoLoginEnabled: + roomSyncAutoLoginEnabled ?? this.roomSyncAutoLoginEnabled, + roomSyncIntervalSeconds: + roomSyncIntervalSeconds ?? this.roomSyncIntervalSeconds, + roomSyncMaxIntervalSeconds: + roomSyncMaxIntervalSeconds ?? this.roomSyncMaxIntervalSeconds, + roomSyncTimeoutSeconds: + roomSyncTimeoutSeconds ?? this.roomSyncTimeoutSeconds, + roomSyncStaleMinutes: roomSyncStaleMinutes ?? this.roomSyncStaleMinutes, mutedChannels: mutedChannels ?? this.mutedChannels, ); } diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index a2c920e7..9cae0fe5 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -32,6 +32,8 @@ class AppSettingsScreen extends StatelessWidget { const SizedBox(height: 16), _buildMessagingCard(context, settingsService), const SizedBox(height: 16), + _buildRoomSyncCard(context, settingsService), + const SizedBox(height: 16), _buildBatteryCard(context, settingsService, connector), const SizedBox(height: 16), _buildMapSettingsCard(context, settingsService), @@ -410,6 +412,120 @@ class AppSettingsScreen extends StatelessWidget { ); } + Widget _buildRoomSyncCard( + BuildContext context, + AppSettingsService settingsService, + ) { + final settings = settingsService.settings; + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + 'Room Sync', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + SwitchListTile( + secondary: const Icon(Icons.sync), + title: const Text('Enable room auto-sync'), + subtitle: const Text( + 'Automatically keep room-server backlog synced while connected.', + ), + value: settings.roomSyncEnabled, + onChanged: (value) => settingsService.setRoomSyncEnabled(value), + ), + const Divider(height: 1), + SwitchListTile( + secondary: const Icon(Icons.login), + title: const Text('Auto-login saved room sessions'), + subtitle: const Text( + 'On reconnect, login to room servers with saved passwords.', + ), + value: settings.roomSyncAutoLoginEnabled, + onChanged: settings.roomSyncEnabled + ? (value) => settingsService.setRoomSyncAutoLoginEnabled(value) + : null, + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.timer_outlined), + title: const Text('Base sync interval'), + subtitle: Text('${settings.roomSyncIntervalSeconds}s'), + trailing: const Icon(Icons.chevron_right), + enabled: settings.roomSyncEnabled, + onTap: settings.roomSyncEnabled + ? () => _editIntegerSetting( + context: context, + title: 'Base sync interval (seconds)', + initialValue: settings.roomSyncIntervalSeconds, + min: 15, + max: 3600, + onSave: settingsService.setRoomSyncIntervalSeconds, + ) + : null, + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.schedule), + title: const Text('Max backoff interval'), + subtitle: Text('${settings.roomSyncMaxIntervalSeconds}s'), + trailing: const Icon(Icons.chevron_right), + enabled: settings.roomSyncEnabled, + onTap: settings.roomSyncEnabled + ? () => _editIntegerSetting( + context: context, + title: 'Max backoff interval (seconds)', + initialValue: settings.roomSyncMaxIntervalSeconds, + min: 30, + max: 7200, + onSave: settingsService.setRoomSyncMaxIntervalSeconds, + ) + : null, + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.hourglass_bottom), + title: const Text('Sync timeout'), + subtitle: Text('${settings.roomSyncTimeoutSeconds}s'), + trailing: const Icon(Icons.chevron_right), + enabled: settings.roomSyncEnabled, + onTap: settings.roomSyncEnabled + ? () => _editIntegerSetting( + context: context, + title: 'Sync timeout (seconds)', + initialValue: settings.roomSyncTimeoutSeconds, + min: 5, + max: 120, + onSave: settingsService.setRoomSyncTimeoutSeconds, + ) + : null, + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.warning_amber_outlined), + title: const Text('Mark room stale after'), + subtitle: Text('${settings.roomSyncStaleMinutes} min'), + trailing: const Icon(Icons.chevron_right), + enabled: settings.roomSyncEnabled, + onTap: settings.roomSyncEnabled + ? () => _editIntegerSetting( + context: context, + title: 'Stale threshold (minutes)', + initialValue: settings.roomSyncStaleMinutes, + min: 1, + max: 240, + onSave: settingsService.setRoomSyncStaleMinutes, + ) + : null, + ), + ], + ), + ); + } + // Fixed rendering issues Widget _buildBatteryCard( BuildContext context, @@ -732,6 +848,54 @@ class AppSettingsScreen extends StatelessWidget { ); } + void _editIntegerSetting({ + required BuildContext context, + required String title, + required int initialValue, + required int min, + required int max, + required Future Function(int) onSave, + }) { + final controller = TextEditingController(text: initialValue.toString()); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + decoration: InputDecoration( + border: const OutlineInputBorder(), + helperText: 'Allowed range: $min - $max', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.common_cancel), + ), + TextButton( + onPressed: () async { + final parsed = int.tryParse(controller.text.trim()); + if (parsed == null || parsed < min || parsed > max) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Value must be between $min and $max'), + ), + ); + return; + } + await onSave(parsed); + if (!context.mounted) return; + Navigator.pop(context); + }, + child: Text(context.l10n.common_save), + ), + ], + ), + ); + } + void _showUnitsDialog( BuildContext context, AppSettingsService settingsService, diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 7c8fcfb9..e8e9cadf 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -24,6 +24,7 @@ import '../models/path_history.dart'; import '../services/app_settings_service.dart'; import '../services/chat_text_scale_service.dart'; import '../services/path_history_service.dart'; +import '../services/room_sync_service.dart'; import '../widgets/chat_zoom_wrapper.dart'; import '../widgets/elements_ui.dart'; import 'channel_message_path_screen.dart'; @@ -97,14 +98,17 @@ class _ChatScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Consumer2( - builder: (context, pathService, connector, _) { + title: Consumer3( + builder: (context, pathService, connector, roomSync, _) { final contact = _resolveContact(connector); final unreadCount = connector.getUnreadCountForContactKey( widget.contact.publicKeyHex, ); final unreadLabel = context.l10n.chat_unread(unreadCount); final pathLabel = _currentPathLabel(contact); + final roomStatus = contact.type == advTypeRoom + ? roomSync.roomStatusLabel(contact.publicKeyHex) + : null; // Show path details if we have path data (from device or override) final hasPathData = @@ -122,7 +126,9 @@ class _ChatScreenState extends State { ? () => _showFullPathDialog(context, effectivePath) : null, child: Text( - '$pathLabel • $unreadLabel', + roomStatus == null + ? '$pathLabel • $unreadLabel' + : '$pathLabel • $unreadLabel • $roomStatus', overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11, diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index eeecfb9b..bcd43e72 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -24,6 +24,7 @@ import '../widgets/quick_switch_bar.dart'; import '../widgets/repeater_login_dialog.dart'; import '../widgets/room_login_dialog.dart'; import '../widgets/unread_badge.dart'; +import '../services/room_sync_service.dart'; import 'channels_screen.dart'; import 'chat_screen.dart'; import 'map_screen.dart'; @@ -703,6 +704,7 @@ class _ContactsScreenState extends State if (contact.type == advTypeRepeater) { _showRepeaterLogin(context, contact); } else if (contact.type == advTypeRoom) { + context.read().markContactRead(contact.publicKeyHex); _showRoomLogin(context, contact, RoomLoginDestination.chat); } else { context.read().markContactRead(contact.publicKeyHex); @@ -755,12 +757,24 @@ class _ContactsScreenState extends State Contact room, RoomLoginDestination destination, ) { + // For chat, skip the login dialog if the room already has an active session. + // Management always needs the dialog to obtain the password for admin commands. + if (destination == RoomLoginDestination.chat) { + final roomSync = context.read(); + if (roomSync.isRoomSessionActive(room.publicKeyHex)) { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => ChatScreen(contact: room)), + ); + return; + } + } + showDialog( context: context, builder: (context) => RoomLoginDialog( room: room, onLogin: (password) { - context.read().markContactRead(room.publicKeyHex); Navigator.push( context, MaterialPageRoute( @@ -1021,6 +1035,7 @@ class _ContactsScreenState extends State ) { final isRepeater = contact.type == advTypeRepeater; final isRoom = contact.type == advTypeRoom; + final roomSyncService = context.read(); final isFavorite = contact.isFavorite; showModalBottomSheet( @@ -1085,6 +1100,22 @@ class _ContactsScreenState extends State _showRoomLogin(context, contact, RoomLoginDestination.chat); }, ), + SwitchListTile( + secondary: const Icon(Icons.sync), + title: const Text('Auto-sync this room'), + subtitle: const Text( + 'Enable automatic login and background catch-up sync for this room.', + ), + value: roomSyncService.isRoomAutoSyncEnabled( + contact.publicKeyHex, + ), + onChanged: (enabled) async { + await roomSyncService.setRoomAutoSyncEnabled( + contact.publicKeyHex, + enabled, + ); + }, + ), ListTile( leading: const Icon( Icons.room_preferences, @@ -1226,6 +1257,22 @@ class _ContactTile extends StatelessWidget { @override Widget build(BuildContext context) { + final roomSync = context.watch(); + final roomStatus = contact.type == advTypeRoom + ? roomSync.roomStatusLabel(contact.publicKeyHex) + : null; + final roomStatusColor = contact.type != advTypeRoom + ? Colors.grey[600] + : switch (roomSync.roomStatusKind(contact.publicKeyHex)) { + RoomSyncStatusKind.connectedSynced => Colors.green[700]!, + RoomSyncStatusKind.syncing => Colors.blue[700]!, + RoomSyncStatusKind.connectedWaitingSync || + RoomSyncStatusKind.connectedStale => Colors.orange[700]!, + RoomSyncStatusKind.syncDisabled || + RoomSyncStatusKind.notLoggedIn || + RoomSyncStatusKind.syncOff => Colors.grey[700]!, + }; + return ListTile( leading: CircleAvatar( backgroundColor: _getTypeColor(contact.type), @@ -1236,6 +1283,13 @@ class _ContactTile extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(contact.pathLabel, maxLines: 1, overflow: TextOverflow.ellipsis), + if (roomStatus != null) + Text( + roomStatus, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: roomStatusColor), + ), Text( contact.shortPubKeyHex, maxLines: 1, diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index eacf26f9..a35a1227 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -143,6 +143,32 @@ class AppSettingsService extends ChangeNotifier { ); } + Future setRoomSyncEnabled(bool value) async { + await updateSettings(_settings.copyWith(roomSyncEnabled: value)); + } + + Future setRoomSyncAutoLoginEnabled(bool value) async { + await updateSettings(_settings.copyWith(roomSyncAutoLoginEnabled: value)); + } + + Future setRoomSyncIntervalSeconds(int seconds) async { + await updateSettings(_settings.copyWith(roomSyncIntervalSeconds: seconds)); + } + + Future setRoomSyncMaxIntervalSeconds(int seconds) async { + await updateSettings( + _settings.copyWith(roomSyncMaxIntervalSeconds: seconds), + ); + } + + Future setRoomSyncTimeoutSeconds(int seconds) async { + await updateSettings(_settings.copyWith(roomSyncTimeoutSeconds: seconds)); + } + + Future setRoomSyncStaleMinutes(int minutes) async { + await updateSettings(_settings.copyWith(roomSyncStaleMinutes: minutes)); + } + Future setBatteryChemistryForRepeater( String repeaterPubKeyHex, String chemistry, diff --git a/lib/services/room_sync_service.dart b/lib/services/room_sync_service.dart new file mode 100644 index 00000000..7f79b65a --- /dev/null +++ b/lib/services/room_sync_service.dart @@ -0,0 +1,651 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; + +import '../connector/meshcore_connector.dart'; +import '../connector/meshcore_protocol.dart'; +import '../models/contact.dart'; +import '../storage/room_sync_store.dart'; +import 'app_settings_service.dart'; +import 'app_debug_log_service.dart'; +import 'storage_service.dart'; + +enum RoomSyncStatusKind { + syncOff, + syncDisabled, + syncing, + connectedWaitingSync, + connectedStale, + connectedSynced, + notLoggedIn, +} + +class _PendingRoomLogin { + final String roomPubKeyHex; + final Completer completer; + + const _PendingRoomLogin({ + required this.roomPubKeyHex, + required this.completer, + }); +} + +class RoomSyncService extends ChangeNotifier { + static const Duration _loginTimeoutFallback = Duration(seconds: 12); + static const int _maxAutoLoginAttempts = 3; + static const int _autoLoginBurstSize = 2; + static const Duration _autoLoginBurstPause = Duration(seconds: 4); + static const int _autoLoginJitterMinMs = 350; + static const int _autoLoginJitterMaxMs = 1250; + static const Duration _pushSyncThrottle = Duration(seconds: 20); + + final RoomSyncStore _roomSyncStore; + final StorageService _storageService; + + MeshCoreConnector? _connector; + AppDebugLogService? _debugLogService; + AppSettingsService? _appSettingsService; + StreamSubscription? _frameSubscription; + Timer? _nextSyncTimer; + Timer? _syncTimeoutTimer; + + final Map _pendingLoginByPrefix = {}; + final Set _activeRoomSessions = {}; + final Map _states = {}; + final Random _random = Random(); + + MeshCoreConnectionState? _lastConnectionState; + Duration _currentInterval = Duration.zero; + bool _started = false; + bool _lastRoomSyncEnabled = false; + bool _syncInFlight = false; + bool _autoLoginInProgress = false; + DateTime? _lastPushTriggeredSyncAt; + + RoomSyncService({ + required RoomSyncStore roomSyncStore, + required StorageService storageService, + }) : _roomSyncStore = roomSyncStore, + _storageService = storageService; + + Map get states => Map.unmodifiable(_states); + + bool isRoomAutoSyncEnabled(String roomPubKeyHex) { + return _states[roomPubKeyHex]?.autoSyncEnabled ?? false; + } + + /// Whether the room has an active login session for the current connection. + /// Returns true even when global room sync is disabled, so callers can use + /// this to skip the login dialog when the user is already authenticated. + bool isRoomSessionActive(String roomPubKeyHex) { + return _activeRoomSessions.contains(roomPubKeyHex); + } + + int? roomAclPermissions(String roomPubKeyHex) { + return _states[roomPubKeyHex]?.lastAclPermissions; + } + + int? roomFirmwareLevel(String roomPubKeyHex) { + return _states[roomPubKeyHex]?.lastLoginFirmwareLevel; + } + + Future setRoomAutoSyncEnabled( + String roomPubKeyHex, + bool enabled, + ) async { + final existing = + _states[roomPubKeyHex] ?? + RoomSyncStateRecord(roomPubKeyHex: roomPubKeyHex); + _states[roomPubKeyHex] = existing.copyWith(autoSyncEnabled: enabled); + + if (!enabled) { + _activeRoomSessions.remove(roomPubKeyHex); + } else { + final connector = _connector; + if (connector != null && connector.isConnected && _roomSyncEnabled) { + unawaited(_tryLoginRoomByPubKey(roomPubKeyHex)); + } + } + + await _persistStates(); + notifyListeners(); + } + + bool isRoomStale(String roomPubKeyHex) { + final state = _states[roomPubKeyHex]; + if (state == null || state.lastSuccessfulSyncAtMs == null) return true; + final ageMs = + DateTime.now().millisecondsSinceEpoch - state.lastSuccessfulSyncAtMs!; + return ageMs > _staleAfter.inMilliseconds; + } + + Future initialize({ + required MeshCoreConnector connector, + required AppSettingsService appSettingsService, + AppDebugLogService? appDebugLogService, + }) async { + if (_started) return; + _connector = connector; + _appSettingsService = appSettingsService; + _debugLogService = appDebugLogService; + _states + ..clear() + ..addAll(await _roomSyncStore.load()); + _lastConnectionState = connector.state; + _frameSubscription = connector.receivedFrames.listen(_handleFrame); + connector.addListener(_handleConnectorChange); + appSettingsService.addListener(_handleSettingsChange); + _started = true; + notifyListeners(); + } + + @override + void dispose() { + _appSettingsService?.removeListener(_handleSettingsChange); + _frameSubscription?.cancel(); + _nextSyncTimer?.cancel(); + _syncTimeoutTimer?.cancel(); + _pendingLoginByPrefix.clear(); + _activeRoomSessions.clear(); + super.dispose(); + } + + void _handleConnectorChange() { + final connector = _connector; + if (connector == null) return; + final state = connector.state; + if (state == _lastConnectionState) return; + _lastConnectionState = state; + if (state == MeshCoreConnectionState.connected) { + _onConnected(); + } else { + _onDisconnected(); + } + } + + void _onConnected() { + if (!_roomSyncEnabled) return; + _lastRoomSyncEnabled = true; + _currentInterval = _defaultSyncInterval; + _scheduleNextSync(_defaultSyncInterval); + unawaited(_autoLoginSavedRooms()); + } + + void _onDisconnected() { + _lastRoomSyncEnabled = false; + _syncInFlight = false; + _nextSyncTimer?.cancel(); + _syncTimeoutTimer?.cancel(); + _pendingLoginByPrefix.clear(); + _activeRoomSessions.clear(); + notifyListeners(); + } + + void _handleSettingsChange() { + final nowEnabled = _roomSyncEnabled; + final connector = _connector; + final isConnected = connector != null && connector.isConnected; + + if (nowEnabled && !_lastRoomSyncEnabled && isConnected) { + _lastRoomSyncEnabled = true; + _currentInterval = _defaultSyncInterval; + _scheduleNextSync(_defaultSyncInterval); + unawaited(_autoLoginSavedRooms()); + } else if (!nowEnabled && _lastRoomSyncEnabled) { + _lastRoomSyncEnabled = false; + _nextSyncTimer?.cancel(); + _syncTimeoutTimer?.cancel(); + } + notifyListeners(); + } + + Future _autoLoginSavedRooms() async { + if (_autoLoginInProgress) return; + final connector = _connector; + if (connector == null || !connector.isConnected) return; + if (!_roomSyncEnabled || !_roomSyncAutoLoginEnabled) return; + _autoLoginInProgress = true; + try { + final savedPasswords = await _storageService.loadRepeaterPasswords(); + if (savedPasswords.isEmpty) return; + + for (int i = 0; i < 20 && connector.isLoadingContacts; i++) { + await Future.delayed(const Duration(milliseconds: 300)); + } + + final roomContacts = connector.contacts + .where( + (c) => + c.type == advTypeRoom && + savedPasswords.containsKey(c.publicKeyHex) && + isRoomAutoSyncEnabled(c.publicKeyHex), + ) + .toList(); + if (roomContacts.isEmpty) return; + + roomContacts.sort((a, b) { + final aState = _states[a.publicKeyHex]; + final bState = _states[b.publicKeyHex]; + final aScore = + aState?.lastSuccessfulSyncAtMs ?? aState?.lastLoginSuccessAtMs ?? 0; + final bScore = + bState?.lastSuccessfulSyncAtMs ?? bState?.lastLoginSuccessAtMs ?? 0; + return bScore.compareTo(aScore); + }); + + int processed = 0; + for (final room in roomContacts) { + final delay = _nextAutoLoginDelay(processed); + if (delay > Duration.zero) { + await Future.delayed(delay); + } + if (!connector.isConnected || + !_roomSyncEnabled || + !_roomSyncAutoLoginEnabled) { + break; + } + final password = savedPasswords[room.publicKeyHex]; + if (password == null || password.isEmpty) continue; + final success = await _loginRoomWithRetries(room, password); + if (success) { + _activeRoomSessions.add(room.publicKeyHex); + _recordLoginSuccess(room.publicKeyHex); + } else { + _recordFailure(room.publicKeyHex); + } + processed++; + } + } finally { + _autoLoginInProgress = false; + await _persistStates(); + notifyListeners(); + } + } + + Future _loginRoomWithRetries(Contact room, String password) async { + if (!isRoomAutoSyncEnabled(room.publicKeyHex)) return false; + for (int attempt = 0; attempt < _maxAutoLoginAttempts; attempt++) { + final result = await _loginRoom(room, password); + if (result == true) return true; + if (result == false) return false; + // null indicates timeout/transport failure, so retry. + continue; + } + return false; + } + + Future _loginRoom(Contact room, String password) async { + final connector = _connector; + if (connector == null || !connector.isConnected) return null; + if (!isRoomAutoSyncEnabled(room.publicKeyHex)) return false; + _recordLoginAttempt(room.publicKeyHex); + + final selection = await connector.preparePathForContactSend(room); + final frame = buildSendLoginFrame(room.publicKey, password); + final timeoutMs = connector.calculateTimeout( + pathLength: selection.useFlood ? -1 : selection.hopCount, + messageBytes: frame.length > maxFrameSize ? frame.length : maxFrameSize, + ); + final timeout = + Duration(milliseconds: timeoutMs).compareTo(Duration.zero) > 0 + ? Duration(milliseconds: timeoutMs) + : _loginTimeoutFallback; + + final prefix = _prefixHex(room.publicKey.sublist(0, 6)); + final completer = Completer(); + _pendingLoginByPrefix[prefix] = _PendingRoomLogin( + roomPubKeyHex: room.publicKeyHex, + completer: completer, + ); + + try { + await connector.sendFrame(frame); + final result = await completer.future.timeout( + timeout, + onTimeout: () => null, + ); + return result; + } catch (_) { + return null; + } finally { + final currentPending = _pendingLoginByPrefix[prefix]; + if (currentPending != null && + identical(currentPending.completer, completer)) { + _pendingLoginByPrefix.remove(prefix); + } + } + } + + void _handleFrame(Uint8List frame) { + if (frame.isEmpty) return; + final code = frame[0]; + + if (code == pushCodeMsgWaiting) { + _handleQueuedMessagesHint(); + } + + if (code == pushCodeLoginSuccess || code == pushCodeLoginFail) { + _handleLoginResponseFrame(frame, code == pushCodeLoginSuccess); + return; + } + + if (!_syncInFlight) return; + if (code == respCodeNoMoreMessages) { + _markSyncSuccess(); + return; + } + if (code == respCodeContactMsgRecv || + code == respCodeContactMsgRecvV3 || + code == respCodeChannelMsgRecv || + code == respCodeChannelMsgRecvV3) { + // Messages are still flowing — extend the timeout to avoid a false failure + // while a large queue is being drained. + _syncTimeoutTimer?.cancel(); + _syncTimeoutTimer = Timer(_syncTimeout, _markSyncFailure); + } + } + + void _handleQueuedMessagesHint() { + final connector = _connector; + if (connector == null || !connector.isConnected) return; + if (!_roomSyncEnabled) return; + if (_syncInFlight) return; + + final hasEnabledActiveRooms = _activeRoomSessions.any( + isRoomAutoSyncEnabled, + ); + if (!hasEnabledActiveRooms) return; + + final lastTrigger = _lastPushTriggeredSyncAt; + final now = DateTime.now(); + if (lastTrigger != null && + now.difference(lastTrigger) < _pushSyncThrottle) { + return; + } + + _lastPushTriggeredSyncAt = now; + _scheduleNextSync(Duration.zero); + } + + void _handleLoginResponseFrame(Uint8List frame, bool success) { + if (frame.length < 8) return; + final prefix = _prefixHex(frame.sublist(2, 8)); + final pending = _pendingLoginByPrefix[prefix]; + if (pending != null && !pending.completer.isCompleted) { + if (success) { + _recordLoginMetadataFromFrame(pending.roomPubKeyHex, frame); + } + pending.completer.complete(success); + return; + } + if (!success) return; + + // Manual room logins are handled outside RoomSyncService; in that path we can + // still capture metadata if the prefix resolves uniquely to a room contact. + final roomPubKeyHex = _resolveRoomPubKeyHexByPrefix(prefix); + if (roomPubKeyHex != null) { + _recordLoginMetadataFromFrame(roomPubKeyHex, frame); + unawaited(_persistStates()); + notifyListeners(); + } + } + + void _scheduleNextSync(Duration delay) { + _nextSyncTimer?.cancel(); + _nextSyncTimer = Timer(delay, () { + unawaited(_runSyncCycle()); + }); + } + + Future _runSyncCycle() async { + final connector = _connector; + if (connector == null || !connector.isConnected) return; + if (!_roomSyncEnabled) return; + if (_activeRoomSessions.isEmpty) { + _scheduleNextSync(_defaultSyncInterval); + return; + } + final enabledSessionCount = _activeRoomSessions + .where((roomPubKeyHex) => isRoomAutoSyncEnabled(roomPubKeyHex)) + .length; + if (enabledSessionCount == 0) { + _scheduleNextSync(_defaultSyncInterval); + return; + } + if (_syncInFlight) return; + + _syncInFlight = true; + _syncTimeoutTimer?.cancel(); + _syncTimeoutTimer = Timer(_syncTimeout, _markSyncFailure); + + try { + await connector.syncQueuedMessages(force: true); + } catch (_) { + _markSyncFailure(); + } + } + + void _markSyncSuccess() { + _syncTimeoutTimer?.cancel(); + _syncInFlight = false; + _currentInterval = _defaultSyncInterval; + + for (final roomPubKeyHex in _activeRoomSessions) { + if (!isRoomAutoSyncEnabled(roomPubKeyHex)) continue; + final existing = + _states[roomPubKeyHex] ?? + RoomSyncStateRecord(roomPubKeyHex: roomPubKeyHex); + _states[roomPubKeyHex] = existing.copyWith( + lastSuccessfulSyncAtMs: DateTime.now().millisecondsSinceEpoch, + consecutiveFailures: 0, + ); + } + _persistStates(); + notifyListeners(); + _scheduleNextSync(_currentInterval); + } + + void _markSyncFailure() { + _syncTimeoutTimer?.cancel(); + _syncInFlight = false; + for (final roomPubKeyHex in _activeRoomSessions) { + if (!isRoomAutoSyncEnabled(roomPubKeyHex)) continue; + _recordFailure(roomPubKeyHex); + } + _currentInterval = _nextBackoffInterval(_currentInterval); + _persistStates(); + notifyListeners(); + _scheduleNextSync(_currentInterval); + } + + Duration _nextBackoffInterval(Duration current) { + final doubledMs = current.inMilliseconds * 2; + if (doubledMs >= _maxSyncInterval.inMilliseconds) { + return _maxSyncInterval; + } + return Duration(milliseconds: doubledMs); + } + + RoomSyncStatusKind roomStatusKind(String roomPubKeyHex) { + if (!_roomSyncEnabled) return RoomSyncStatusKind.syncOff; + if (!isRoomAutoSyncEnabled(roomPubKeyHex)) { + return RoomSyncStatusKind.syncDisabled; + } + if (_syncInFlight) return RoomSyncStatusKind.syncing; + final connector = _connector; + final isActivelyConnected = connector != null && connector.isConnected; + final state = _states[roomPubKeyHex]; + if (isActivelyConnected && _activeRoomSessions.contains(roomPubKeyHex)) { + if (state?.lastSuccessfulSyncAtMs == null) { + return RoomSyncStatusKind.connectedWaitingSync; + } + return isRoomStale(roomPubKeyHex) + ? RoomSyncStatusKind.connectedStale + : RoomSyncStatusKind.connectedSynced; + } + return RoomSyncStatusKind.notLoggedIn; + } + + Future registerManualRoomLogin(String roomPubKeyHex) async { + final existing = _states[roomPubKeyHex]; + if (existing == null) { + _states[roomPubKeyHex] = RoomSyncStateRecord( + roomPubKeyHex: roomPubKeyHex, + autoSyncEnabled: true, + ); + } else if (!existing.autoSyncEnabled) { + return; + } + _activeRoomSessions.add(roomPubKeyHex); + _recordLoginSuccess(roomPubKeyHex); + await _persistStates(); + notifyListeners(); + if (_roomSyncEnabled) { + _scheduleNextSync(Duration.zero); + } + } + + String? roomStatusLabel(String roomPubKeyHex) { + switch (roomStatusKind(roomPubKeyHex)) { + case RoomSyncStatusKind.syncOff: + return 'Room sync off'; + case RoomSyncStatusKind.syncDisabled: + return 'Sync disabled'; + case RoomSyncStatusKind.syncing: + return 'Syncing...'; + case RoomSyncStatusKind.connectedWaitingSync: + return 'Connected, waiting sync'; + case RoomSyncStatusKind.connectedStale: + return 'Connected, stale'; + case RoomSyncStatusKind.connectedSynced: + return 'Connected, synced'; + case RoomSyncStatusKind.notLoggedIn: + return 'Not logged in'; + } + } + + void _recordLoginAttempt(String roomPubKeyHex) { + final existing = + _states[roomPubKeyHex] ?? + RoomSyncStateRecord(roomPubKeyHex: roomPubKeyHex); + _states[roomPubKeyHex] = existing.copyWith( + lastLoginAttemptAtMs: DateTime.now().millisecondsSinceEpoch, + ); + } + + void _recordLoginSuccess(String roomPubKeyHex) { + final existing = + _states[roomPubKeyHex] ?? + RoomSyncStateRecord(roomPubKeyHex: roomPubKeyHex); + _states[roomPubKeyHex] = existing.copyWith( + lastLoginSuccessAtMs: DateTime.now().millisecondsSinceEpoch, + consecutiveFailures: 0, + ); + } + + void _recordLoginMetadataFromFrame(String roomPubKeyHex, Uint8List frame) { + final existing = + _states[roomPubKeyHex] ?? + RoomSyncStateRecord(roomPubKeyHex: roomPubKeyHex); + final serverTimestamp = frame.length >= 12 ? readUint32LE(frame, 8) : null; + final aclPermissions = frame.length >= 13 ? frame[12] : null; + final firmwareLevel = frame.length >= 14 ? frame[13] : null; + _states[roomPubKeyHex] = existing.copyWith( + lastLoginServerTimestamp: serverTimestamp, + lastAclPermissions: aclPermissions, + lastLoginFirmwareLevel: firmwareLevel, + ); + } + + void _recordFailure(String roomPubKeyHex) { + final existing = + _states[roomPubKeyHex] ?? + RoomSyncStateRecord(roomPubKeyHex: roomPubKeyHex); + final nextFailures = existing.consecutiveFailures + 1; + _states[roomPubKeyHex] = existing.copyWith( + lastFailureAtMs: DateTime.now().millisecondsSinceEpoch, + consecutiveFailures: nextFailures, + ); + _debugLogService?.warn( + 'Room sync/login failure for $roomPubKeyHex (consecutive: $nextFailures)', + tag: 'RoomSync', + ); + } + + Duration _nextAutoLoginDelay(int processedCount) { + if (processedCount <= 0) return Duration.zero; + if (processedCount % _autoLoginBurstSize == 0) { + return _autoLoginBurstPause; + } + final range = _autoLoginJitterMaxMs - _autoLoginJitterMinMs; + final jitterMs = range <= 0 + ? _autoLoginJitterMinMs + : _autoLoginJitterMinMs + _random.nextInt(range + 1); + return Duration(milliseconds: jitterMs); + } + + Future _persistStates() async { + await _roomSyncStore.save(_states); + } + + String _prefixHex(Uint8List bytes) { + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } + + String? _resolveRoomPubKeyHexByPrefix(String prefixHex) { + final connector = _connector; + if (connector == null) return null; + final matches = connector.contacts + .where((contact) { + if (contact.type != advTypeRoom) return false; + if (contact.publicKey.length < 6) return false; + return _prefixHex(contact.publicKey.sublist(0, 6)) == prefixHex; + }) + .map((contact) => contact.publicKeyHex) + .toSet() + .toList(); + if (matches.length != 1) return null; + return matches.first; + } + + Future _tryLoginRoomByPubKey(String roomPubKeyHex) async { + final connector = _connector; + if (connector == null || !connector.isConnected) return; + final savedPasswords = await _storageService.loadRepeaterPasswords(); + final password = savedPasswords[roomPubKeyHex]; + if (password == null || password.isEmpty) return; + final roomContact = connector.contacts.cast().firstWhere( + (c) => + c != null && c.publicKeyHex == roomPubKeyHex && c.type == advTypeRoom, + orElse: () => null, + ); + if (roomContact == null) return; + final success = await _loginRoomWithRetries(roomContact, password); + if (success) { + _activeRoomSessions.add(roomPubKeyHex); + _recordLoginSuccess(roomPubKeyHex); + } else { + _recordFailure(roomPubKeyHex); + } + await _persistStates(); + notifyListeners(); + } + + bool get _roomSyncEnabled => + _appSettingsService?.settings.roomSyncEnabled ?? true; + bool get _roomSyncAutoLoginEnabled => + _appSettingsService?.settings.roomSyncAutoLoginEnabled ?? true; + Duration get _defaultSyncInterval => Duration( + seconds: _appSettingsService?.settings.roomSyncIntervalSeconds ?? 90, + ); + Duration get _maxSyncInterval => Duration( + seconds: _appSettingsService?.settings.roomSyncMaxIntervalSeconds ?? 600, + ); + Duration get _syncTimeout => Duration( + seconds: _appSettingsService?.settings.roomSyncTimeoutSeconds ?? 15, + ); + Duration get _staleAfter => Duration( + minutes: _appSettingsService?.settings.roomSyncStaleMinutes ?? 15, + ); +} diff --git a/lib/storage/room_sync_store.dart b/lib/storage/room_sync_store.dart new file mode 100644 index 00000000..ca082083 --- /dev/null +++ b/lib/storage/room_sync_store.dart @@ -0,0 +1,115 @@ +import 'dart:convert'; + +import 'prefs_manager.dart'; + +class RoomSyncStateRecord { + final String roomPubKeyHex; + final bool autoSyncEnabled; + final int? lastLoginAttemptAtMs; + final int? lastLoginSuccessAtMs; + final int? lastSuccessfulSyncAtMs; + final int? lastLoginServerTimestamp; + final int? lastAclPermissions; + final int? lastLoginFirmwareLevel; + final int? lastFailureAtMs; + final int consecutiveFailures; + + const RoomSyncStateRecord({ + required this.roomPubKeyHex, + this.autoSyncEnabled = true, + this.lastLoginAttemptAtMs, + this.lastLoginSuccessAtMs, + this.lastSuccessfulSyncAtMs, + this.lastLoginServerTimestamp, + this.lastAclPermissions, + this.lastLoginFirmwareLevel, + this.lastFailureAtMs, + this.consecutiveFailures = 0, + }); + + RoomSyncStateRecord copyWith({ + bool? autoSyncEnabled, + int? lastLoginAttemptAtMs, + int? lastLoginSuccessAtMs, + int? lastSuccessfulSyncAtMs, + int? lastLoginServerTimestamp, + int? lastAclPermissions, + int? lastLoginFirmwareLevel, + int? lastFailureAtMs, + int? consecutiveFailures, + }) { + return RoomSyncStateRecord( + roomPubKeyHex: roomPubKeyHex, + autoSyncEnabled: autoSyncEnabled ?? this.autoSyncEnabled, + lastLoginAttemptAtMs: lastLoginAttemptAtMs ?? this.lastLoginAttemptAtMs, + lastLoginSuccessAtMs: lastLoginSuccessAtMs ?? this.lastLoginSuccessAtMs, + lastSuccessfulSyncAtMs: + lastSuccessfulSyncAtMs ?? this.lastSuccessfulSyncAtMs, + lastLoginServerTimestamp: + lastLoginServerTimestamp ?? this.lastLoginServerTimestamp, + lastAclPermissions: lastAclPermissions ?? this.lastAclPermissions, + lastLoginFirmwareLevel: + lastLoginFirmwareLevel ?? this.lastLoginFirmwareLevel, + lastFailureAtMs: lastFailureAtMs ?? this.lastFailureAtMs, + consecutiveFailures: consecutiveFailures ?? this.consecutiveFailures, + ); + } + + Map toJson() { + return { + 'roomPubKeyHex': roomPubKeyHex, + 'autoSyncEnabled': autoSyncEnabled, + 'lastLoginAttemptAtMs': lastLoginAttemptAtMs, + 'lastLoginSuccessAtMs': lastLoginSuccessAtMs, + 'lastSuccessfulSyncAtMs': lastSuccessfulSyncAtMs, + 'lastLoginServerTimestamp': lastLoginServerTimestamp, + 'lastAclPermissions': lastAclPermissions, + 'lastLoginFirmwareLevel': lastLoginFirmwareLevel, + 'lastFailureAtMs': lastFailureAtMs, + 'consecutiveFailures': consecutiveFailures, + }; + } + + static RoomSyncStateRecord fromJson(Map json) { + return RoomSyncStateRecord( + roomPubKeyHex: json['roomPubKeyHex'] as String, + autoSyncEnabled: json['autoSyncEnabled'] as bool? ?? true, + lastLoginAttemptAtMs: json['lastLoginAttemptAtMs'] as int?, + lastLoginSuccessAtMs: json['lastLoginSuccessAtMs'] as int?, + lastSuccessfulSyncAtMs: json['lastSuccessfulSyncAtMs'] as int?, + lastLoginServerTimestamp: json['lastLoginServerTimestamp'] as int?, + lastAclPermissions: json['lastAclPermissions'] as int?, + lastLoginFirmwareLevel: json['lastLoginFirmwareLevel'] as int?, + lastFailureAtMs: json['lastFailureAtMs'] as int?, + consecutiveFailures: json['consecutiveFailures'] as int? ?? 0, + ); + } +} + +class RoomSyncStore { + static const String _roomSyncStateKey = 'room_sync_state_v1'; + + Future> load() async { + final prefs = PrefsManager.instance; + final raw = prefs.getString(_roomSyncStateKey); + if (raw == null || raw.isEmpty) return {}; + + try { + final decoded = jsonDecode(raw) as Map; + return decoded.map((key, value) { + return MapEntry( + key, + RoomSyncStateRecord.fromJson(value as Map), + ); + }); + } catch (_) { + return {}; + } + } + + Future save(Map states) async { + final prefs = PrefsManager.instance; + final payload = states.map((key, value) => MapEntry(key, value.toJson())); + await prefs.setString(_roomSyncStateKey, jsonEncode(payload)); + } +} diff --git a/lib/widgets/room_login_dialog.dart b/lib/widgets/room_login_dialog.dart index 7324f442..e22118ef 100644 --- a/lib/widgets/room_login_dialog.dart +++ b/lib/widgets/room_login_dialog.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:provider/provider.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; +import '../services/room_sync_service.dart'; import '../services/storage_service.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; @@ -81,6 +82,7 @@ class _RoomLoginDialogState extends State { try { final password = _passwordController.text; + final roomSyncService = context.read(); final room = _resolveRepeater(_connector); appLogger.info( 'Login started for ${room.name} (${room.publicKeyHex})', @@ -153,6 +155,8 @@ class _RoomLoginDialogState extends State { await _storage.removeRepeaterPassword(widget.room.publicKeyHex); } + await roomSyncService.registerManualRoomLogin(widget.room.publicKeyHex); + if (mounted) { Navigator.pop(context, password); Future.microtask(() => widget.onLogin(password));