Skip to content
Prev Previous commit
Next Next commit
Tune room sync for low-traffic operation and persist login metadata
(cherry picked from commit 9a5d189)
  • Loading branch information
Specter242 committed Feb 21, 2026
commit 4b04acfdb27319747fc8e58b9629b4feee737375
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
17 changes: 14 additions & 3 deletions docs/ROOM_SYNC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
17 changes: 9 additions & 8 deletions lib/models/app_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@ class AppSettings {
Map<String, String>? 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<String, dynamic> toJson() {
Expand Down Expand Up @@ -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,
);
}

Expand Down
175 changes: 161 additions & 14 deletions lib/services/room_sync_service.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math';

import 'package:flutter/foundation.dart';

Expand All @@ -20,9 +21,24 @@ enum RoomSyncStatusKind {
notLoggedIn,
}

class _PendingRoomLogin {
final String roomPubKeyHex;
final Completer<bool?> 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;
Expand All @@ -34,15 +50,17 @@ class RoomSyncService extends ChangeNotifier {
Timer? _nextSyncTimer;
Timer? _syncTimeoutTimer;

final Map<String, Completer<bool>> _pendingLoginByPrefix = {};
final Map<String, _PendingRoomLogin> _pendingLoginByPrefix = {};
final Set<String> _activeRoomSessions = {};
final Map<String, RoomSyncStateRecord> _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,
Expand All @@ -53,7 +71,15 @@ class RoomSyncService extends ChangeNotifier {
Map<String, RoomSyncStateRecord> 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<void> setRoomAutoSyncEnabled(
Expand Down Expand Up @@ -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);
Expand All @@ -178,6 +224,7 @@ class RoomSyncService extends ChangeNotifier {
} else {
_recordFailure(room.publicKeyHex);
}
processed++;
}
} finally {
_autoLoginInProgress = false;
Expand All @@ -189,15 +236,18 @@ class RoomSyncService extends ChangeNotifier {
Future<bool> _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<bool> _loginRoom(Contact room, String password) async {
Future<bool?> _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);

Expand All @@ -213,27 +263,38 @@ class RoomSyncService extends ChangeNotifier {
: _loginTimeoutFallback;

final prefix = _prefixHex(room.publicKey.sublist(0, 6));
final completer = Completer<bool>();
_pendingLoginByPrefix[prefix] = completer;
final completer = Completer<bool?>();
_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);
}
}
}

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;
Expand All @@ -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();
}
}

Expand Down Expand Up @@ -356,7 +453,15 @@ class RoomSyncService extends ChangeNotifier {
}

Future<void> 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();
Expand Down Expand Up @@ -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] ??
Expand All @@ -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<void> _persistStates() async {
await _roomSyncStore.save(_states);
}
Expand All @@ -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<void> _tryLoginRoomByPubKey(String roomPubKeyHex) async {
final connector = _connector;
if (connector == null || !connector.isConnected) return;
Expand Down
Loading