From c890afbb7d3c17b014a7065483cda36b3888fc0a Mon Sep 17 00:00:00 2001 From: Specter242 Date: Sat, 14 Feb 2026 11:28:21 -0500 Subject: [PATCH 1/7] Add room sync controls and visibility. Introduce app-side room auto-sync orchestration with per-room enablement, sync status indicators, and configurable timing controls so users can keep only selected room servers up to date. Co-authored-by: Cursor --- README.md | 19 +- docs/ROOM_SYNC.md | 62 ++++ lib/main.dart | 15 + lib/models/app_settings.dart | 42 +++ lib/screens/app_settings_screen.dart | 164 ++++++++++ lib/screens/chat_screen.dart | 12 +- lib/screens/contacts_screen.dart | 115 +++++++ lib/services/app_settings_service.dart | 26 ++ lib/services/room_sync_service.dart | 428 +++++++++++++++++++++++++ lib/storage/room_sync_store.dart | 95 ++++++ 10 files changed, 973 insertions(+), 5 deletions(-) create mode 100644 docs/ROOM_SYNC.md create mode 100644 lib/services/room_sync_service.dart create mode 100644 lib/storage/room_sync_store.dart diff --git a/README.md b/README.md index bad9b6ca..bd342fe3 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 and periodic backlog sync for room servers with saved credentials +- **Per-Room Sync Control**: Enable or disable auto-sync 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 + ## Technical Details ### Architecture @@ -160,11 +167,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 @@ -191,6 +200,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, interval/backoff/timeout/stale thresholds + +### Room Sync Guide + +Detailed room sync behavior and validation steps are documented in `docs/ROOM_SYNC.md`. ### Device Settings @@ -210,6 +224,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..cb8733d4 --- /dev/null +++ b/docs/ROOM_SYNC.md @@ -0,0 +1,62 @@ +# 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 +- Periodic queued message sync with timeout and exponential backoff +- Per-room auto-sync control (enable one room, disable others) +- 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) + +### Per-room control + +From Contacts, long-press a room server and use: + +- `Auto-sync this room` switch + +When disabled, that room is excluded from auto-login and periodic sync. + +## 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 8ee0ca47..4cc4ef53 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,8 @@ import 'services/ble_debug_log_service.dart'; import 'services/app_debug_log_service.dart'; import 'services/background_service.dart'; import 'services/map_tile_cache_service.dart'; +import 'services/room_sync_service.dart'; +import 'storage/room_sync_store.dart'; import 'storage/prefs_manager.dart'; import 'utils/app_logger.dart'; @@ -33,6 +35,10 @@ void main() async { final appDebugLogService = AppDebugLogService(); final backgroundService = BackgroundService(); final mapTileCacheService = MapTileCacheService(); + final roomSyncService = RoomSyncService( + roomSyncStore: RoomSyncStore(), + storageService: storage, + ); // Load settings await appSettingsService.loadSettings(); @@ -65,6 +71,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( @@ -76,6 +87,7 @@ void main() async { bleDebugLogService: bleDebugLogService, appDebugLogService: appDebugLogService, mapTileCacheService: mapTileCacheService, + roomSyncService: roomSyncService, ), ); } @@ -89,6 +101,7 @@ class MeshCoreApp extends StatelessWidget { final BleDebugLogService bleDebugLogService; final AppDebugLogService appDebugLogService; final MapTileCacheService mapTileCacheService; + final RoomSyncService roomSyncService; const MeshCoreApp({ super.key, @@ -100,6 +113,7 @@ class MeshCoreApp extends StatelessWidget { required this.bleDebugLogService, required this.appDebugLogService, required this.mapTileCacheService, + required this.roomSyncService, }); @override @@ -112,6 +126,7 @@ class MeshCoreApp extends StatelessWidget { ChangeNotifierProvider.value(value: appSettingsService), ChangeNotifierProvider.value(value: bleDebugLogService), ChangeNotifierProvider.value(value: appDebugLogService), + 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 3edb68fa..d6e10982 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -21,6 +21,12 @@ class AppSettings { final String? languageOverride; // null = system default final bool appDebugLogEnabled; final Map batteryChemistryByDeviceId; + final bool roomSyncEnabled; + final bool roomSyncAutoLoginEnabled; + final int roomSyncIntervalSeconds; + final int roomSyncMaxIntervalSeconds; + final int roomSyncTimeoutSeconds; + final int roomSyncStaleMinutes; AppSettings({ this.clearPathOnMaxRetry = false, @@ -43,6 +49,12 @@ class AppSettings { this.languageOverride, this.appDebugLogEnabled = false, Map? batteryChemistryByDeviceId, + this.roomSyncEnabled = true, + this.roomSyncAutoLoginEnabled = true, + this.roomSyncIntervalSeconds = 90, + this.roomSyncMaxIntervalSeconds = 600, + this.roomSyncTimeoutSeconds = 15, + this.roomSyncStaleMinutes = 15, }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}; Map toJson() { @@ -67,6 +79,12 @@ class AppSettings { 'language_override': languageOverride, 'app_debug_log_enabled': appDebugLogEnabled, 'battery_chemistry_by_device_id': batteryChemistryByDeviceId, + '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, }; } @@ -101,6 +119,14 @@ class AppSettings { (key, value) => MapEntry(key.toString(), value.toString()), ) ?? {}, + 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? ?? 90, + roomSyncMaxIntervalSeconds: + json['room_sync_max_interval_seconds'] as int? ?? 600, + roomSyncTimeoutSeconds: json['room_sync_timeout_seconds'] as int? ?? 15, + roomSyncStaleMinutes: json['room_sync_stale_minutes'] as int? ?? 15, ); } @@ -125,6 +151,12 @@ class AppSettings { Object? languageOverride = _unset, bool? appDebugLogEnabled, Map? batteryChemistryByDeviceId, + bool? roomSyncEnabled, + bool? roomSyncAutoLoginEnabled, + int? roomSyncIntervalSeconds, + int? roomSyncMaxIntervalSeconds, + int? roomSyncTimeoutSeconds, + int? roomSyncStaleMinutes, }) { return AppSettings( clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry, @@ -154,6 +186,16 @@ class AppSettings { appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled, batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId, + roomSyncEnabled: roomSyncEnabled ?? this.roomSyncEnabled, + roomSyncAutoLoginEnabled: + roomSyncAutoLoginEnabled ?? this.roomSyncAutoLoginEnabled, + roomSyncIntervalSeconds: + roomSyncIntervalSeconds ?? this.roomSyncIntervalSeconds, + roomSyncMaxIntervalSeconds: + roomSyncMaxIntervalSeconds ?? this.roomSyncMaxIntervalSeconds, + roomSyncTimeoutSeconds: + roomSyncTimeoutSeconds ?? this.roomSyncTimeoutSeconds, + roomSyncStaleMinutes: roomSyncStaleMinutes ?? this.roomSyncStaleMinutes, ); } } diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index 4e317335..0717eaca 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -30,6 +30,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), @@ -384,6 +386,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, @@ -706,6 +822,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), + ), + ], + ), + ); + } + Widget _buildDebugCard( BuildContext context, AppSettingsService settingsService, diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index f00f242a..1cfb8ab6 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -20,6 +20,7 @@ import '../models/channel_message.dart'; import '../models/contact.dart'; import '../models/message.dart'; import '../services/path_history_service.dart'; +import '../services/room_sync_service.dart'; import 'channel_message_path_screen.dart'; import 'map_screen.dart'; import '../utils/emoji_utils.dart'; @@ -91,14 +92,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 = @@ -116,7 +120,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 6799d695..3950c51f 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -23,6 +23,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'; @@ -371,6 +372,7 @@ class _ContactsScreenState extends State Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) { final contacts = connector.contacts; + final hasRoomServers = contacts.any((c) => c.type == advTypeRoom); if (contacts.isEmpty && connector.isLoadingContacts && _groups.isEmpty) { return const Center(child: CircularProgressIndicator()); @@ -433,6 +435,11 @@ class _ContactsScreenState extends State }, ), ), + if (hasRoomServers) + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: _buildRoomSyncLegend(context), + ), Expanded( child: filteredAndSorted.isEmpty && filteredGroups.isEmpty ? Center( @@ -480,6 +487,53 @@ class _ContactsScreenState extends State ); } + Widget _buildRoomSyncLegend(BuildContext context) { + final textColor = Theme.of(context).colorScheme.onSurfaceVariant; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + child: Wrap( + spacing: 12, + runSpacing: 8, + children: [ + _RoomSyncLegendItem( + icon: Icons.check_circle_outline, + label: 'Synced', + color: Colors.green[700]!, + textColor: textColor, + ), + _RoomSyncLegendItem( + icon: Icons.sync, + label: 'Syncing', + color: Colors.blue[700]!, + textColor: textColor, + ), + _RoomSyncLegendItem( + icon: Icons.warning_amber_outlined, + label: 'Stale', + color: Colors.orange[700]!, + textColor: textColor, + ), + _RoomSyncLegendItem( + icon: Icons.sync_disabled, + label: 'Sync Disabled', + color: Colors.grey[700]!, + textColor: textColor, + ), + _RoomSyncLegendItem( + icon: Icons.link_off, + label: 'Not Logged In', + color: Colors.grey[700]!, + textColor: textColor, + ), + ], + ), + ); + } + List _filterAndSortGroups( List groups, List contacts, @@ -968,6 +1022,7 @@ class _ContactsScreenState extends State ) { final isRepeater = contact.type == advTypeRepeater; final isRoom = contact.type == advTypeRoom; + final roomSyncService = context.read(); showModalBottomSheet( context: context, @@ -1031,6 +1086,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, @@ -1155,6 +1226,19 @@ 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 = (() { + if (roomStatus == null) return Colors.grey[600]; + if (roomStatus.contains('Syncing')) return Colors.blue[700]; + if (roomStatus.contains('synced')) return Colors.green[700]; + if (roomStatus.contains('disabled')) return Colors.grey[700]; + if (roomStatus.contains('Not logged in')) return Colors.grey[700]; + return Colors.orange[700]; + })(); + return ListTile( leading: CircleAvatar( backgroundColor: _getTypeColor(contact.type), @@ -1165,6 +1249,11 @@ class _ContactTile extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(contact.pathLabel), + if (roomStatus != null) + Text( + roomStatus, + style: TextStyle(fontSize: 12, color: roomStatusColor), + ), Text(contact.shortPubKeyHex, style: TextStyle(fontSize: 12)), ], ), @@ -1263,3 +1352,29 @@ class _ContactTile extends StatelessWidget { : context.l10n.contacts_lastSeenDaysAgo(days); } } + +class _RoomSyncLegendItem extends StatelessWidget { + final IconData icon; + final String label; + final Color color; + final Color textColor; + + const _RoomSyncLegendItem({ + required this.icon, + required this.label, + required this.color, + required this.textColor, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 15, color: color), + const SizedBox(width: 4), + Text(label, style: TextStyle(fontSize: 12, color: textColor)), + ], + ); + } +} diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index c1e8fc62..d90da19c 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -132,4 +132,30 @@ class AppSettingsService extends ChangeNotifier { _settings.copyWith(batteryChemistryByDeviceId: updated), ); } + + 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)); + } } diff --git a/lib/services/room_sync_service.dart b/lib/services/room_sync_service.dart new file mode 100644 index 00000000..a48b3cd1 --- /dev/null +++ b/lib/services/room_sync_service.dart @@ -0,0 +1,428 @@ +import 'dart:async'; + +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'; + +class RoomSyncService extends ChangeNotifier { + static const Duration _loginTimeoutFallback = Duration(seconds: 12); + static const int _maxAutoLoginAttempts = 3; + + 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 = {}; + + MeshCoreConnectionState? _lastConnectionState; + Duration _currentInterval = Duration.zero; + bool _started = false; + bool _syncInFlight = false; + bool _autoLoginInProgress = false; + + 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 ?? true; + } + + 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); + _started = true; + notifyListeners(); + } + + @override + void dispose() { + _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 if (state == MeshCoreConnectionState.disconnected) { + _onDisconnected(); + } + } + + void _onConnected() { + if (!_roomSyncEnabled) return; + _currentInterval = _defaultSyncInterval; + _scheduleNextSync(_defaultSyncInterval); + unawaited(_autoLoginSavedRooms()); + } + + void _onDisconnected() { + _syncInFlight = false; + _nextSyncTimer?.cancel(); + _syncTimeoutTimer?.cancel(); + _pendingLoginByPrefix.clear(); + _activeRoomSessions.clear(); + 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; + + for (final room in roomContacts) { + 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); + } + } + } 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++) { + if (!await _loginRoom(room, password)) continue; + return true; + } + return false; + } + + Future _loginRoom(Contact room, String password) async { + final connector = _connector; + if (connector == null || !connector.isConnected) return false; + 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] = completer; + + try { + await connector.sendFrame(frame); + final result = await completer.future.timeout( + timeout, + onTimeout: () => false, + ); + return result; + } catch (_) { + return false; + } finally { + _pendingLoginByPrefix.remove(prefix); + } + } + + void _handleFrame(Uint8List frame) { + if (frame.isEmpty) return; + final code = frame[0]; + + if (code == pushCodeLoginSuccess || code == pushCodeLoginFail) { + _handleLoginResponseFrame(frame, code == pushCodeLoginSuccess); + return; + } + + if (!_syncInFlight) return; + final syncProgressCode = + code == respCodeNoMoreMessages || + code == respCodeContactMsgRecv || + code == respCodeContactMsgRecvV3 || + code == respCodeChannelMsgRecv || + code == respCodeChannelMsgRecvV3; + if (!syncProgressCode) return; + _markSyncSuccess(); + } + + 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.isCompleted) { + pending.complete(success); + } + } + + 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); + } + + String? roomStatusLabel(String roomPubKeyHex) { + if (!_roomSyncEnabled) return 'Room sync off'; + if (!isRoomAutoSyncEnabled(roomPubKeyHex)) return 'Sync disabled'; + if (_syncInFlight) return 'Syncing...'; + final state = _states[roomPubKeyHex]; + if (_activeRoomSessions.contains(roomPubKeyHex)) { + if (state?.lastSuccessfulSyncAtMs == null) { + return 'Connected, waiting sync'; + } + return isRoomStale(roomPubKeyHex) + ? 'Connected, stale' + : 'Connected, synced'; + } + if (state?.lastFailureAtMs != null) { + return 'Not logged in'; + } + return 'Not synced'; + } + + 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 _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', + ); + } + + Future _persistStates() async { + await _roomSyncStore.save(_states); + } + + String _prefixHex(Uint8List bytes) { + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } + + 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..a0beba9f --- /dev/null +++ b/lib/storage/room_sync_store.dart @@ -0,0 +1,95 @@ +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? lastFailureAtMs; + final int consecutiveFailures; + + const RoomSyncStateRecord({ + required this.roomPubKeyHex, + this.autoSyncEnabled = true, + this.lastLoginAttemptAtMs, + this.lastLoginSuccessAtMs, + this.lastSuccessfulSyncAtMs, + this.lastFailureAtMs, + this.consecutiveFailures = 0, + }); + + RoomSyncStateRecord copyWith({ + bool? autoSyncEnabled, + int? lastLoginAttemptAtMs, + int? lastLoginSuccessAtMs, + int? lastSuccessfulSyncAtMs, + int? lastFailureAtMs, + int? consecutiveFailures, + }) { + return RoomSyncStateRecord( + roomPubKeyHex: roomPubKeyHex, + autoSyncEnabled: autoSyncEnabled ?? this.autoSyncEnabled, + lastLoginAttemptAtMs: lastLoginAttemptAtMs ?? this.lastLoginAttemptAtMs, + lastLoginSuccessAtMs: lastLoginSuccessAtMs ?? this.lastLoginSuccessAtMs, + lastSuccessfulSyncAtMs: + lastSuccessfulSyncAtMs ?? this.lastSuccessfulSyncAtMs, + lastFailureAtMs: lastFailureAtMs ?? this.lastFailureAtMs, + consecutiveFailures: consecutiveFailures ?? this.consecutiveFailures, + ); + } + + Map toJson() { + return { + 'roomPubKeyHex': roomPubKeyHex, + 'autoSyncEnabled': autoSyncEnabled, + 'lastLoginAttemptAtMs': lastLoginAttemptAtMs, + 'lastLoginSuccessAtMs': lastLoginSuccessAtMs, + 'lastSuccessfulSyncAtMs': lastSuccessfulSyncAtMs, + '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?, + 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)); + } +} From 97fb750636fefb2f9c732d09c013c33a913a919d Mon Sep 17 00:00:00 2001 From: Specter242 Date: Sat, 14 Feb 2026 15:03:43 -0500 Subject: [PATCH 2/7] Polish room server status UI clarity. This replaces fragile string-based room sync status coloring with explicit status kinds and removes the redundant contacts legend so room status text remains clear and consistent. Co-authored-by: Cursor --- lib/screens/contacts_screen.dart | 98 ++++------------------------- lib/services/room_sync_service.dart | 48 +++++++++++--- 2 files changed, 49 insertions(+), 97 deletions(-) diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 3950c51f..df9b2f93 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -372,7 +372,6 @@ class _ContactsScreenState extends State Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) { final contacts = connector.contacts; - final hasRoomServers = contacts.any((c) => c.type == advTypeRoom); if (contacts.isEmpty && connector.isLoadingContacts && _groups.isEmpty) { return const Center(child: CircularProgressIndicator()); @@ -435,11 +434,6 @@ class _ContactsScreenState extends State }, ), ), - if (hasRoomServers) - Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), - child: _buildRoomSyncLegend(context), - ), Expanded( child: filteredAndSorted.isEmpty && filteredGroups.isEmpty ? Center( @@ -487,53 +481,6 @@ class _ContactsScreenState extends State ); } - Widget _buildRoomSyncLegend(BuildContext context) { - final textColor = Theme.of(context).colorScheme.onSurfaceVariant; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(10), - ), - child: Wrap( - spacing: 12, - runSpacing: 8, - children: [ - _RoomSyncLegendItem( - icon: Icons.check_circle_outline, - label: 'Synced', - color: Colors.green[700]!, - textColor: textColor, - ), - _RoomSyncLegendItem( - icon: Icons.sync, - label: 'Syncing', - color: Colors.blue[700]!, - textColor: textColor, - ), - _RoomSyncLegendItem( - icon: Icons.warning_amber_outlined, - label: 'Stale', - color: Colors.orange[700]!, - textColor: textColor, - ), - _RoomSyncLegendItem( - icon: Icons.sync_disabled, - label: 'Sync Disabled', - color: Colors.grey[700]!, - textColor: textColor, - ), - _RoomSyncLegendItem( - icon: Icons.link_off, - label: 'Not Logged In', - color: Colors.grey[700]!, - textColor: textColor, - ), - ], - ), - ); - } - List _filterAndSortGroups( List groups, List contacts, @@ -1230,14 +1177,17 @@ class _ContactTile extends StatelessWidget { final roomStatus = contact.type == advTypeRoom ? roomSync.roomStatusLabel(contact.publicKeyHex) : null; - final roomStatusColor = (() { - if (roomStatus == null) return Colors.grey[600]; - if (roomStatus.contains('Syncing')) return Colors.blue[700]; - if (roomStatus.contains('synced')) return Colors.green[700]; - if (roomStatus.contains('disabled')) return Colors.grey[700]; - if (roomStatus.contains('Not logged in')) return Colors.grey[700]; - return Colors.orange[700]; - })(); + 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( @@ -1352,29 +1302,3 @@ class _ContactTile extends StatelessWidget { : context.l10n.contacts_lastSeenDaysAgo(days); } } - -class _RoomSyncLegendItem extends StatelessWidget { - final IconData icon; - final String label; - final Color color; - final Color textColor; - - const _RoomSyncLegendItem({ - required this.icon, - required this.label, - required this.color, - required this.textColor, - }); - - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 15, color: color), - const SizedBox(width: 4), - Text(label, style: TextStyle(fontSize: 12, color: textColor)), - ], - ); - } -} diff --git a/lib/services/room_sync_service.dart b/lib/services/room_sync_service.dart index a48b3cd1..7027120f 100644 --- a/lib/services/room_sync_service.dart +++ b/lib/services/room_sync_service.dart @@ -10,6 +10,16 @@ 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 RoomSyncService extends ChangeNotifier { static const Duration _loginTimeoutFallback = Duration(seconds: 12); static const int _maxAutoLoginAttempts = 3; @@ -325,23 +335,41 @@ class RoomSyncService extends ChangeNotifier { return Duration(milliseconds: doubledMs); } - String? roomStatusLabel(String roomPubKeyHex) { - if (!_roomSyncEnabled) return 'Room sync off'; - if (!isRoomAutoSyncEnabled(roomPubKeyHex)) return 'Sync disabled'; - if (_syncInFlight) return 'Syncing...'; + RoomSyncStatusKind roomStatusKind(String roomPubKeyHex) { + if (!_roomSyncEnabled) return RoomSyncStatusKind.syncOff; + if (!isRoomAutoSyncEnabled(roomPubKeyHex)) { + return RoomSyncStatusKind.syncDisabled; + } + if (_syncInFlight) return RoomSyncStatusKind.syncing; final state = _states[roomPubKeyHex]; if (_activeRoomSessions.contains(roomPubKeyHex)) { if (state?.lastSuccessfulSyncAtMs == null) { - return 'Connected, waiting sync'; + return RoomSyncStatusKind.connectedWaitingSync; } return isRoomStale(roomPubKeyHex) - ? 'Connected, stale' - : 'Connected, synced'; + ? RoomSyncStatusKind.connectedStale + : RoomSyncStatusKind.connectedSynced; } - if (state?.lastFailureAtMs != null) { - return 'Not logged in'; + return RoomSyncStatusKind.notLoggedIn; + } + + 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'; } - return 'Not synced'; } void _recordLoginAttempt(String roomPubKeyHex) { From 9b56a28a2a41b51528cae293165920d8e8e068ca Mon Sep 17 00:00:00 2001 From: Aaron Easterling Date: Thu, 19 Feb 2026 16:18:15 -0500 Subject: [PATCH 3/7] Fix room sync state after manual room login --- lib/services/room_sync_service.dart | 11 +++++++++++ lib/widgets/room_login_dialog.dart | 5 +++++ 2 files changed, 16 insertions(+) diff --git a/lib/services/room_sync_service.dart b/lib/services/room_sync_service.dart index 7027120f..961223c4 100644 --- a/lib/services/room_sync_service.dart +++ b/lib/services/room_sync_service.dart @@ -353,6 +353,17 @@ class RoomSyncService extends ChangeNotifier { return RoomSyncStatusKind.notLoggedIn; } + Future registerManualRoomLogin(String roomPubKeyHex) async { + if (!isRoomAutoSyncEnabled(roomPubKeyHex)) return; + _activeRoomSessions.add(roomPubKeyHex); + _recordLoginSuccess(roomPubKeyHex); + await _persistStates(); + notifyListeners(); + if (_roomSyncEnabled) { + _scheduleNextSync(Duration.zero); + } + } + String? roomStatusLabel(String roomPubKeyHex) { switch (roomStatusKind(roomPubKeyHex)) { case RoomSyncStatusKind.syncOff: diff --git a/lib/widgets/room_login_dialog.dart b/lib/widgets/room_login_dialog.dart index cba7bec7..b826da67 100644 --- a/lib/widgets/room_login_dialog.dart +++ b/lib/widgets/room_login_dialog.dart @@ -5,6 +5,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'; @@ -152,6 +153,10 @@ class _RoomLoginDialogState extends State { await _storage.removeRepeaterPassword(widget.room.publicKeyHex); } + await context.read().registerManualRoomLogin( + widget.room.publicKeyHex, + ); + if (mounted) { Navigator.pop(context, password); Future.microtask(() => widget.onLogin(password)); From 151fbb383f3e97a3f5926ecb773985d30829f0d3 Mon Sep 17 00:00:00 2001 From: Specter242 Date: Thu, 19 Feb 2026 21:13:02 -0500 Subject: [PATCH 4/7] Fix room sync connected status when transport is not active --- lib/services/room_sync_service.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/services/room_sync_service.dart b/lib/services/room_sync_service.dart index 961223c4..a7836e18 100644 --- a/lib/services/room_sync_service.dart +++ b/lib/services/room_sync_service.dart @@ -123,7 +123,7 @@ class RoomSyncService extends ChangeNotifier { _lastConnectionState = state; if (state == MeshCoreConnectionState.connected) { _onConnected(); - } else if (state == MeshCoreConnectionState.disconnected) { + } else { _onDisconnected(); } } @@ -341,8 +341,10 @@ class RoomSyncService extends ChangeNotifier { return RoomSyncStatusKind.syncDisabled; } if (_syncInFlight) return RoomSyncStatusKind.syncing; + final connector = _connector; + final isActivelyConnected = connector != null && connector.isConnected; final state = _states[roomPubKeyHex]; - if (_activeRoomSessions.contains(roomPubKeyHex)) { + if (isActivelyConnected && _activeRoomSessions.contains(roomPubKeyHex)) { if (state?.lastSuccessfulSyncAtMs == null) { return RoomSyncStatusKind.connectedWaitingSync; } From 9dc6f1b64a978cb0714f8b8c4572a98af5483183 Mon Sep 17 00:00:00 2001 From: Specter242 Date: Fri, 20 Feb 2026 00:03:42 -0500 Subject: [PATCH 5/7] Fix analyze warning in room login dialog --- lib/widgets/room_login_dialog.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/widgets/room_login_dialog.dart b/lib/widgets/room_login_dialog.dart index b826da67..4e65589b 100644 --- a/lib/widgets/room_login_dialog.dart +++ b/lib/widgets/room_login_dialog.dart @@ -81,6 +81,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,9 +154,7 @@ class _RoomLoginDialogState extends State { await _storage.removeRepeaterPassword(widget.room.publicKeyHex); } - await context.read().registerManualRoomLogin( - widget.room.publicKeyHex, - ); + await roomSyncService.registerManualRoomLogin(widget.room.publicKeyHex); if (mounted) { Navigator.pop(context, password); From 4b04acfdb27319747fc8e58b9629b4feee737375 Mon Sep 17 00:00:00 2001 From: Specter242 Date: Sat, 21 Feb 2026 14:33:49 -0500 Subject: [PATCH 6/7] Tune room sync for low-traffic operation and persist login metadata (cherry picked from commit 9a5d189b2b51fd4321c1ed257ffb45861bcff953) --- README.md | 8 +- docs/ROOM_SYNC.md | 17 ++- lib/models/app_settings.dart | 17 +-- lib/services/room_sync_service.dart | 175 +++++++++++++++++++++++++--- lib/storage/room_sync_store.dart | 20 ++++ 5 files changed, 208 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index bd342fe3..b7dd4f0b 100644 --- a/README.md +++ b/README.md @@ -65,10 +65,10 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh ### Room Server Catch-Up -- **Auto Login + Catch-Up Sync**: Optional automatic login and periodic backlog sync for room servers with saved credentials -- **Per-Room Sync Control**: Enable or disable auto-sync per room server from the room options sheet +- **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 +- **Tunable Sync Parameters**: Configure base interval, max backoff, timeout, and stale threshold in app settings (conservative defaults) ## Technical Details @@ -200,7 +200,7 @@ 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, interval/backoff/timeout/stale thresholds +- **Room Sync**: Global enable or disable, auto-login toggle, per-room opt-in, interval/backoff/timeout/stale thresholds ### Room Sync Guide diff --git a/docs/ROOM_SYNC.md b/docs/ROOM_SYNC.md index cb8733d4..902f984e 100644 --- a/docs/ROOM_SYNC.md +++ b/docs/ROOM_SYNC.md @@ -7,8 +7,8 @@ This document describes the room server auto-sync feature and the minimum valida Room sync adds app-side reliability for room server catch-up: - Optional auto-login to room servers with saved passwords -- Periodic queued message sync with timeout and exponential backoff -- Per-room auto-sync control (enable one room, disable others) +- 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 @@ -25,13 +25,24 @@ App settings include: - 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 -When disabled, that room is excluded from auto-login and periodic sync. +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 diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index d6e10982..1e4910af 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -51,10 +51,10 @@ class AppSettings { Map? batteryChemistryByDeviceId, this.roomSyncEnabled = true, this.roomSyncAutoLoginEnabled = true, - this.roomSyncIntervalSeconds = 90, - this.roomSyncMaxIntervalSeconds = 600, - this.roomSyncTimeoutSeconds = 15, - this.roomSyncStaleMinutes = 15, + this.roomSyncIntervalSeconds = 300, + this.roomSyncMaxIntervalSeconds = 3600, + this.roomSyncTimeoutSeconds = 20, + this.roomSyncStaleMinutes = 45, }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}; Map toJson() { @@ -122,11 +122,12 @@ class AppSettings { 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? ?? 90, + roomSyncIntervalSeconds: + json['room_sync_interval_seconds'] as int? ?? 300, roomSyncMaxIntervalSeconds: - json['room_sync_max_interval_seconds'] as int? ?? 600, - roomSyncTimeoutSeconds: json['room_sync_timeout_seconds'] as int? ?? 15, - roomSyncStaleMinutes: json['room_sync_stale_minutes'] as int? ?? 15, + 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, ); } diff --git a/lib/services/room_sync_service.dart b/lib/services/room_sync_service.dart index a7836e18..e3e02482 100644 --- a/lib/services/room_sync_service.dart +++ b/lib/services/room_sync_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math'; import 'package:flutter/foundation.dart'; @@ -20,9 +21,24 @@ enum RoomSyncStatusKind { 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; @@ -34,15 +50,17 @@ class RoomSyncService extends ChangeNotifier { Timer? _nextSyncTimer; Timer? _syncTimeoutTimer; - final Map> _pendingLoginByPrefix = {}; + final Map _pendingLoginByPrefix = {}; final Set _activeRoomSessions = {}; final Map _states = {}; + final Random _random = Random(); MeshCoreConnectionState? _lastConnectionState; Duration _currentInterval = Duration.zero; bool _started = false; bool _syncInFlight = false; bool _autoLoginInProgress = false; + DateTime? _lastPushTriggeredSyncAt; RoomSyncService({ required RoomSyncStore roomSyncStore, @@ -53,7 +71,15 @@ class RoomSyncService extends ChangeNotifier { Map get states => Map.unmodifiable(_states); bool isRoomAutoSyncEnabled(String roomPubKeyHex) { - return _states[roomPubKeyHex]?.autoSyncEnabled ?? true; + return _states[roomPubKeyHex]?.autoSyncEnabled ?? false; + } + + int? roomAclPermissions(String roomPubKeyHex) { + return _states[roomPubKeyHex]?.lastAclPermissions; + } + + int? roomFirmwareLevel(String roomPubKeyHex) { + return _states[roomPubKeyHex]?.lastLoginFirmwareLevel; } Future setRoomAutoSyncEnabled( @@ -168,7 +194,27 @@ class RoomSyncService extends ChangeNotifier { .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); @@ -178,6 +224,7 @@ class RoomSyncService extends ChangeNotifier { } else { _recordFailure(room.publicKeyHex); } + processed++; } } finally { _autoLoginInProgress = false; @@ -189,15 +236,18 @@ class RoomSyncService extends ChangeNotifier { Future _loginRoomWithRetries(Contact room, String password) async { if (!isRoomAutoSyncEnabled(room.publicKeyHex)) return false; for (int attempt = 0; attempt < _maxAutoLoginAttempts; attempt++) { - if (!await _loginRoom(room, password)) continue; - return true; + 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 { + Future _loginRoom(Contact room, String password) async { final connector = _connector; - if (connector == null || !connector.isConnected) return false; + if (connector == null || !connector.isConnected) return null; if (!isRoomAutoSyncEnabled(room.publicKeyHex)) return false; _recordLoginAttempt(room.publicKeyHex); @@ -213,20 +263,27 @@ class RoomSyncService extends ChangeNotifier { : _loginTimeoutFallback; final prefix = _prefixHex(room.publicKey.sublist(0, 6)); - final completer = Completer(); - _pendingLoginByPrefix[prefix] = completer; + final completer = Completer(); + _pendingLoginByPrefix[prefix] = _PendingRoomLogin( + roomPubKeyHex: room.publicKeyHex, + completer: completer, + ); try { await connector.sendFrame(frame); final result = await completer.future.timeout( timeout, - onTimeout: () => false, + onTimeout: () => null, ); return result; } catch (_) { - return false; + return null; } finally { - _pendingLoginByPrefix.remove(prefix); + final currentPending = _pendingLoginByPrefix[prefix]; + if (currentPending != null && + identical(currentPending.completer, completer)) { + _pendingLoginByPrefix.remove(prefix); + } } } @@ -234,6 +291,10 @@ class RoomSyncService extends ChangeNotifier { if (frame.isEmpty) return; final code = frame[0]; + if (code == pushCodeMsgWaiting) { + _handleQueuedMessagesHint(); + } + if (code == pushCodeLoginSuccess || code == pushCodeLoginFail) { _handleLoginResponseFrame(frame, code == pushCodeLoginSuccess); return; @@ -250,12 +311,48 @@ class RoomSyncService extends ChangeNotifier { _markSyncSuccess(); } + 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.isCompleted) { - pending.complete(success); + 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(); } } @@ -356,7 +453,15 @@ class RoomSyncService extends ChangeNotifier { } Future registerManualRoomLogin(String roomPubKeyHex) async { - if (!isRoomAutoSyncEnabled(roomPubKeyHex)) return; + 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(); @@ -404,6 +509,20 @@ class RoomSyncService extends ChangeNotifier { ); } + 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] ?? @@ -419,6 +538,18 @@ class RoomSyncService extends ChangeNotifier { ); } + 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); } @@ -427,6 +558,22 @@ class RoomSyncService extends ChangeNotifier { 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; diff --git a/lib/storage/room_sync_store.dart b/lib/storage/room_sync_store.dart index a0beba9f..ca082083 100644 --- a/lib/storage/room_sync_store.dart +++ b/lib/storage/room_sync_store.dart @@ -8,6 +8,9 @@ class RoomSyncStateRecord { final int? lastLoginAttemptAtMs; final int? lastLoginSuccessAtMs; final int? lastSuccessfulSyncAtMs; + final int? lastLoginServerTimestamp; + final int? lastAclPermissions; + final int? lastLoginFirmwareLevel; final int? lastFailureAtMs; final int consecutiveFailures; @@ -17,6 +20,9 @@ class RoomSyncStateRecord { this.lastLoginAttemptAtMs, this.lastLoginSuccessAtMs, this.lastSuccessfulSyncAtMs, + this.lastLoginServerTimestamp, + this.lastAclPermissions, + this.lastLoginFirmwareLevel, this.lastFailureAtMs, this.consecutiveFailures = 0, }); @@ -26,6 +32,9 @@ class RoomSyncStateRecord { int? lastLoginAttemptAtMs, int? lastLoginSuccessAtMs, int? lastSuccessfulSyncAtMs, + int? lastLoginServerTimestamp, + int? lastAclPermissions, + int? lastLoginFirmwareLevel, int? lastFailureAtMs, int? consecutiveFailures, }) { @@ -36,6 +45,11 @@ class RoomSyncStateRecord { 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, ); @@ -48,6 +62,9 @@ class RoomSyncStateRecord { 'lastLoginAttemptAtMs': lastLoginAttemptAtMs, 'lastLoginSuccessAtMs': lastLoginSuccessAtMs, 'lastSuccessfulSyncAtMs': lastSuccessfulSyncAtMs, + 'lastLoginServerTimestamp': lastLoginServerTimestamp, + 'lastAclPermissions': lastAclPermissions, + 'lastLoginFirmwareLevel': lastLoginFirmwareLevel, 'lastFailureAtMs': lastFailureAtMs, 'consecutiveFailures': consecutiveFailures, }; @@ -60,6 +77,9 @@ class RoomSyncStateRecord { 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, ); From 6af31ae12c99354b2fa9b4543947843bcb5a61ae Mon Sep 17 00:00:00 2001 From: Specter242 Date: Mon, 2 Mar 2026 11:35:34 -0500 Subject: [PATCH 7/7] Fix room sync service and room chat UX issues Address two Codex review items and two UX gaps: P1 (Codex): _markSyncSuccess() now only fires on respCodeNoMoreMessages. Intermediate message receipt codes (respCodeContactMsgRecv*, respCodeChannelMsgRecv*) extend the sync timeout instead, so a large backlog draining does not cause a premature success or a false timeout failure. P2 (Codex): RoomSyncService now subscribes to AppSettingsService changes via _handleSettingsChange(). Enabling room auto-sync while already connected starts the periodic timer and auto-login immediately; disabling it cancels both timers. The _lastRoomSyncEnabled flag tracks the transition so the handler is a no-op when the value has not changed. Unread badge clearing: markContactRead() for room contacts is now called at tile-tap time (matching regular contact behaviour) rather than inside the onLogin callback, so the badge clears consistently even if the user later cancels the login dialog. Login dialog bypass: _showRoomLogin() skips the login dialog for the chat destination when the room already has an active session (isRoomSessionActive()). The login dialog continues to appear when there is no active session or when opening Room Management (which needs the password for admin commands). Co-Authored-By: Claude Sonnet 4.6 --- lib/screens/contacts_screen.dart | 15 ++++++++- lib/services/room_sync_service.dart | 47 +++++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 98113e94..baab9fd5 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -661,6 +661,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); @@ -713,12 +714,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( diff --git a/lib/services/room_sync_service.dart b/lib/services/room_sync_service.dart index e3e02482..7f79b65a 100644 --- a/lib/services/room_sync_service.dart +++ b/lib/services/room_sync_service.dart @@ -58,6 +58,7 @@ class RoomSyncService extends ChangeNotifier { MeshCoreConnectionState? _lastConnectionState; Duration _currentInterval = Duration.zero; bool _started = false; + bool _lastRoomSyncEnabled = false; bool _syncInFlight = false; bool _autoLoginInProgress = false; DateTime? _lastPushTriggeredSyncAt; @@ -74,6 +75,13 @@ class RoomSyncService extends ChangeNotifier { 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; } @@ -127,12 +135,14 @@ class RoomSyncService extends ChangeNotifier { _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(); @@ -156,12 +166,14 @@ class RoomSyncService extends ChangeNotifier { void _onConnected() { if (!_roomSyncEnabled) return; + _lastRoomSyncEnabled = true; _currentInterval = _defaultSyncInterval; _scheduleNextSync(_defaultSyncInterval); unawaited(_autoLoginSavedRooms()); } void _onDisconnected() { + _lastRoomSyncEnabled = false; _syncInFlight = false; _nextSyncTimer?.cancel(); _syncTimeoutTimer?.cancel(); @@ -170,6 +182,24 @@ class RoomSyncService extends ChangeNotifier { 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; @@ -301,14 +331,19 @@ class RoomSyncService extends ChangeNotifier { } if (!_syncInFlight) return; - final syncProgressCode = - code == respCodeNoMoreMessages || - code == respCodeContactMsgRecv || + if (code == respCodeNoMoreMessages) { + _markSyncSuccess(); + return; + } + if (code == respCodeContactMsgRecv || code == respCodeContactMsgRecvV3 || code == respCodeChannelMsgRecv || - code == respCodeChannelMsgRecvV3; - if (!syncProgressCode) return; - _markSyncSuccess(); + 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() {