Skip to content

Commit 9a5d189

Browse files
committed
Tune room sync for low-traffic operation and persist login metadata
1 parent 881bf96 commit 9a5d189

File tree

5 files changed

+208
-29
lines changed

5 files changed

+208
-29
lines changed

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,10 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
6565

6666
### Room Server Catch-Up
6767

68-
- **Auto Login + Catch-Up Sync**: Optional automatic login and periodic backlog sync for room servers with saved credentials
69-
- **Per-Room Sync Control**: Enable or disable auto-sync per room server from the room options sheet
68+
- **Auto Login + Catch-Up Sync**: Optional automatic login with push-priority backlog sync and periodic fallback for room servers with saved credentials
69+
- **Per-Room Sync Control**: Auto-sync is opt-in per room server from the room options sheet
7070
- **Sync Health Indicators**: Room status in contacts/chat (`synced`, `syncing`, `stale`, `not logged in`, `sync disabled`)
71-
- **Tunable Sync Parameters**: Configure base interval, max backoff, timeout, and stale threshold in app settings
71+
- **Tunable Sync Parameters**: Configure base interval, max backoff, timeout, and stale threshold in app settings (conservative defaults)
7272

7373
## Technical Details
7474

@@ -200,7 +200,7 @@ Messages are transmitted as binary frames using a custom protocol optimized for
200200
- **Notifications**: Configurable for messages, channels, and node advertisements
201201
- **Battery Chemistry**: Support for NMC, LiFePO4, and LiPo battery types
202202
- **Message Retry**: Automatic retry with configurable path clearing
203-
- **Room Sync**: Global enable or disable, auto-login toggle, interval/backoff/timeout/stale thresholds
203+
- **Room Sync**: Global enable or disable, auto-login toggle, per-room opt-in, interval/backoff/timeout/stale thresholds
204204

205205
### Room Sync Guide
206206

docs/ROOM_SYNC.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ This document describes the room server auto-sync feature and the minimum valida
77
Room sync adds app-side reliability for room server catch-up:
88

99
- Optional auto-login to room servers with saved passwords
10-
- Periodic queued message sync with timeout and exponential backoff
11-
- Per-room auto-sync control (enable one room, disable others)
10+
- Push-priority queued message sync with timeout and exponential backoff fallback
11+
- Per-room auto-sync control (rooms are opt-in by default)
1212
- Room sync status indicators in contacts and room chat header
1313
- Global room sync tuning in app settings
1414

@@ -25,13 +25,24 @@ App settings include:
2525
- Sync timeout (seconds)
2626
- Stale threshold (minutes)
2727

28+
Default tuning is conservative for low traffic:
29+
30+
- Base interval: `300s`
31+
- Max backoff: `3600s`
32+
- Sync timeout: `20s`
33+
- Stale threshold: `45m`
34+
35+
When firmware emits `PUSH_CODE_MSG_WAITING`, room sync schedules an immediate catch-up (throttled) for enabled active rooms.
36+
2837
### Per-room control
2938

3039
From Contacts, long-press a room server and use:
3140

3241
- `Auto-sync this room` switch
3342

34-
When disabled, that room is excluded from auto-login and periodic sync.
43+
Auto-sync is disabled by default for newly discovered rooms. When disabled, that room is excluded from auto-login and periodic sync.
44+
45+
Saving and using a successful manual room login will automatically opt that room into auto-sync unless it was explicitly disabled.
3546

3647
## Status Meanings
3748

lib/models/app_settings.dart

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,10 @@ class AppSettings {
5656
Map<String, String>? batteryChemistryByDeviceId,
5757
this.roomSyncEnabled = true,
5858
this.roomSyncAutoLoginEnabled = true,
59-
this.roomSyncIntervalSeconds = 90,
60-
this.roomSyncMaxIntervalSeconds = 600,
61-
this.roomSyncTimeoutSeconds = 15,
62-
this.roomSyncStaleMinutes = 15,
59+
this.roomSyncIntervalSeconds = 300,
60+
this.roomSyncMaxIntervalSeconds = 3600,
61+
this.roomSyncTimeoutSeconds = 20,
62+
this.roomSyncStaleMinutes = 45,
6363
this.defaultRadioProfile = 'region_auto',
6464
this.contactsCompactView = false,
6565
this.defaultMessageScopeEnabled = false,
@@ -137,11 +137,12 @@ class AppSettings {
137137
roomSyncEnabled: json['room_sync_enabled'] as bool? ?? true,
138138
roomSyncAutoLoginEnabled:
139139
json['room_sync_auto_login_enabled'] as bool? ?? true,
140-
roomSyncIntervalSeconds: json['room_sync_interval_seconds'] as int? ?? 90,
140+
roomSyncIntervalSeconds:
141+
json['room_sync_interval_seconds'] as int? ?? 300,
141142
roomSyncMaxIntervalSeconds:
142-
json['room_sync_max_interval_seconds'] as int? ?? 600,
143-
roomSyncTimeoutSeconds: json['room_sync_timeout_seconds'] as int? ?? 15,
144-
roomSyncStaleMinutes: json['room_sync_stale_minutes'] as int? ?? 15,
143+
json['room_sync_max_interval_seconds'] as int? ?? 3600,
144+
roomSyncTimeoutSeconds: json['room_sync_timeout_seconds'] as int? ?? 20,
145+
roomSyncStaleMinutes: json['room_sync_stale_minutes'] as int? ?? 45,
145146
defaultRadioProfile:
146147
json['default_radio_profile'] as String? ?? 'region_auto',
147148
contactsCompactView: json['contacts_compact_view'] as bool? ?? false,

lib/services/room_sync_service.dart

Lines changed: 161 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'dart:async';
2+
import 'dart:math';
23

34
import 'package:flutter/foundation.dart';
45

@@ -21,9 +22,24 @@ enum RoomSyncStatus {
2122
notSynced,
2223
}
2324

25+
class _PendingRoomLogin {
26+
final String roomPubKeyHex;
27+
final Completer<bool?> completer;
28+
29+
const _PendingRoomLogin({
30+
required this.roomPubKeyHex,
31+
required this.completer,
32+
});
33+
}
34+
2435
class RoomSyncService extends ChangeNotifier {
2536
static const Duration _loginTimeoutFallback = Duration(seconds: 12);
2637
static const int _maxAutoLoginAttempts = 3;
38+
static const int _autoLoginBurstSize = 2;
39+
static const Duration _autoLoginBurstPause = Duration(seconds: 4);
40+
static const int _autoLoginJitterMinMs = 350;
41+
static const int _autoLoginJitterMaxMs = 1250;
42+
static const Duration _pushSyncThrottle = Duration(seconds: 20);
2743

2844
final RoomSyncStore _roomSyncStore;
2945
final StorageService _storageService;
@@ -35,16 +51,18 @@ class RoomSyncService extends ChangeNotifier {
3551
Timer? _nextSyncTimer;
3652
Timer? _syncTimeoutTimer;
3753

38-
final Map<String, Completer<bool>> _pendingLoginByPrefix = {};
54+
final Map<String, _PendingRoomLogin> _pendingLoginByPrefix = {};
3955
final Set<String> _activeRoomSessions = {};
4056
final Map<String, RoomSyncStateRecord> _states = {};
57+
final Random _random = Random();
4158

4259
MeshCoreConnectionState? _lastConnectionState;
4360
Duration _currentInterval = Duration.zero;
4461
bool _started = false;
4562
bool _syncInFlight = false;
4663
bool _autoLoginInProgress = false;
4764
bool _lastRoomSyncEnabled = true;
65+
DateTime? _lastPushTriggeredSyncAt;
4866

4967
RoomSyncService({
5068
required RoomSyncStore roomSyncStore,
@@ -55,7 +73,15 @@ class RoomSyncService extends ChangeNotifier {
5573
Map<String, RoomSyncStateRecord> get states => Map.unmodifiable(_states);
5674

5775
bool isRoomAutoSyncEnabled(String roomPubKeyHex) {
58-
return _states[roomPubKeyHex]?.autoSyncEnabled ?? true;
76+
return _states[roomPubKeyHex]?.autoSyncEnabled ?? false;
77+
}
78+
79+
int? roomAclPermissions(String roomPubKeyHex) {
80+
return _states[roomPubKeyHex]?.lastAclPermissions;
81+
}
82+
83+
int? roomFirmwareLevel(String roomPubKeyHex) {
84+
return _states[roomPubKeyHex]?.lastLoginFirmwareLevel;
5985
}
6086

6187
Future<void> setRoomAutoSyncEnabled(
@@ -195,7 +221,27 @@ class RoomSyncService extends ChangeNotifier {
195221
.toList();
196222
if (roomContacts.isEmpty) return;
197223

224+
roomContacts.sort((a, b) {
225+
final aState = _states[a.publicKeyHex];
226+
final bState = _states[b.publicKeyHex];
227+
final aScore =
228+
aState?.lastSuccessfulSyncAtMs ?? aState?.lastLoginSuccessAtMs ?? 0;
229+
final bScore =
230+
bState?.lastSuccessfulSyncAtMs ?? bState?.lastLoginSuccessAtMs ?? 0;
231+
return bScore.compareTo(aScore);
232+
});
233+
234+
int processed = 0;
198235
for (final room in roomContacts) {
236+
final delay = _nextAutoLoginDelay(processed);
237+
if (delay > Duration.zero) {
238+
await Future.delayed(delay);
239+
}
240+
if (!connector.isConnected ||
241+
!_roomSyncEnabled ||
242+
!_roomSyncAutoLoginEnabled) {
243+
break;
244+
}
199245
final password = savedPasswords[room.publicKeyHex];
200246
if (password == null || password.isEmpty) continue;
201247
final success = await _loginRoomWithRetries(room, password);
@@ -205,6 +251,7 @@ class RoomSyncService extends ChangeNotifier {
205251
} else {
206252
_recordFailure(room.publicKeyHex);
207253
}
254+
processed++;
208255
}
209256
} finally {
210257
_autoLoginInProgress = false;
@@ -216,15 +263,18 @@ class RoomSyncService extends ChangeNotifier {
216263
Future<bool> _loginRoomWithRetries(Contact room, String password) async {
217264
if (!isRoomAutoSyncEnabled(room.publicKeyHex)) return false;
218265
for (int attempt = 0; attempt < _maxAutoLoginAttempts; attempt++) {
219-
if (!await _loginRoom(room, password)) continue;
220-
return true;
266+
final result = await _loginRoom(room, password);
267+
if (result == true) return true;
268+
if (result == false) return false;
269+
// null indicates timeout/transport failure, so retry.
270+
continue;
221271
}
222272
return false;
223273
}
224274

225-
Future<bool> _loginRoom(Contact room, String password) async {
275+
Future<bool?> _loginRoom(Contact room, String password) async {
226276
final connector = _connector;
227-
if (connector == null || !connector.isConnected) return false;
277+
if (connector == null || !connector.isConnected) return null;
228278
if (!isRoomAutoSyncEnabled(room.publicKeyHex)) return false;
229279
_recordLoginAttempt(room.publicKeyHex);
230280

@@ -240,27 +290,38 @@ class RoomSyncService extends ChangeNotifier {
240290
: _loginTimeoutFallback;
241291

242292
final prefix = _prefixHex(room.publicKey.sublist(0, 6));
243-
final completer = Completer<bool>();
244-
_pendingLoginByPrefix[prefix] = completer;
293+
final completer = Completer<bool?>();
294+
_pendingLoginByPrefix[prefix] = _PendingRoomLogin(
295+
roomPubKeyHex: room.publicKeyHex,
296+
completer: completer,
297+
);
245298

246299
try {
247300
await connector.sendFrame(frame);
248301
final result = await completer.future.timeout(
249302
timeout,
250-
onTimeout: () => false,
303+
onTimeout: () => null,
251304
);
252305
return result;
253306
} catch (_) {
254-
return false;
307+
return null;
255308
} finally {
256-
_pendingLoginByPrefix.remove(prefix);
309+
final currentPending = _pendingLoginByPrefix[prefix];
310+
if (currentPending != null &&
311+
identical(currentPending.completer, completer)) {
312+
_pendingLoginByPrefix.remove(prefix);
313+
}
257314
}
258315
}
259316

260317
void _handleFrame(Uint8List frame) {
261318
if (frame.isEmpty) return;
262319
final code = frame[0];
263320

321+
if (code == pushCodeMsgWaiting) {
322+
_handleQueuedMessagesHint();
323+
}
324+
264325
if (code == pushCodeLoginSuccess || code == pushCodeLoginFail) {
265326
_handleLoginResponseFrame(frame, code == pushCodeLoginSuccess);
266327
return;
@@ -271,12 +332,48 @@ class RoomSyncService extends ChangeNotifier {
271332
_markSyncSuccess();
272333
}
273334

335+
void _handleQueuedMessagesHint() {
336+
final connector = _connector;
337+
if (connector == null || !connector.isConnected) return;
338+
if (!_roomSyncEnabled) return;
339+
if (_syncInFlight) return;
340+
341+
final hasEnabledActiveRooms = _activeRoomSessions.any(
342+
isRoomAutoSyncEnabled,
343+
);
344+
if (!hasEnabledActiveRooms) return;
345+
346+
final lastTrigger = _lastPushTriggeredSyncAt;
347+
final now = DateTime.now();
348+
if (lastTrigger != null &&
349+
now.difference(lastTrigger) < _pushSyncThrottle) {
350+
return;
351+
}
352+
353+
_lastPushTriggeredSyncAt = now;
354+
_scheduleNextSync(Duration.zero);
355+
}
356+
274357
void _handleLoginResponseFrame(Uint8List frame, bool success) {
275358
if (frame.length < 8) return;
276359
final prefix = _prefixHex(frame.sublist(2, 8));
277360
final pending = _pendingLoginByPrefix[prefix];
278-
if (pending != null && !pending.isCompleted) {
279-
pending.complete(success);
361+
if (pending != null && !pending.completer.isCompleted) {
362+
if (success) {
363+
_recordLoginMetadataFromFrame(pending.roomPubKeyHex, frame);
364+
}
365+
pending.completer.complete(success);
366+
return;
367+
}
368+
if (!success) return;
369+
370+
// Manual room logins are handled outside RoomSyncService; in that path we can
371+
// still capture metadata if the prefix resolves uniquely to a room contact.
372+
final roomPubKeyHex = _resolveRoomPubKeyHexByPrefix(prefix);
373+
if (roomPubKeyHex != null) {
374+
_recordLoginMetadataFromFrame(roomPubKeyHex, frame);
375+
unawaited(_persistStates());
376+
notifyListeners();
280377
}
281378
}
282379

@@ -378,7 +475,15 @@ class RoomSyncService extends ChangeNotifier {
378475
}
379476

380477
Future<void> registerManualRoomLogin(String roomPubKeyHex) async {
381-
if (!isRoomAutoSyncEnabled(roomPubKeyHex)) return;
478+
final existing = _states[roomPubKeyHex];
479+
if (existing == null) {
480+
_states[roomPubKeyHex] = RoomSyncStateRecord(
481+
roomPubKeyHex: roomPubKeyHex,
482+
autoSyncEnabled: true,
483+
);
484+
} else if (!existing.autoSyncEnabled) {
485+
return;
486+
}
382487
_activeRoomSessions.add(roomPubKeyHex);
383488
_recordLoginSuccess(roomPubKeyHex);
384489
await _persistStates();
@@ -407,6 +512,20 @@ class RoomSyncService extends ChangeNotifier {
407512
);
408513
}
409514

515+
void _recordLoginMetadataFromFrame(String roomPubKeyHex, Uint8List frame) {
516+
final existing =
517+
_states[roomPubKeyHex] ??
518+
RoomSyncStateRecord(roomPubKeyHex: roomPubKeyHex);
519+
final serverTimestamp = frame.length >= 12 ? readUint32LE(frame, 8) : null;
520+
final aclPermissions = frame.length >= 13 ? frame[12] : null;
521+
final firmwareLevel = frame.length >= 14 ? frame[13] : null;
522+
_states[roomPubKeyHex] = existing.copyWith(
523+
lastLoginServerTimestamp: serverTimestamp,
524+
lastAclPermissions: aclPermissions,
525+
lastLoginFirmwareLevel: firmwareLevel,
526+
);
527+
}
528+
410529
void _recordFailure(String roomPubKeyHex) {
411530
final existing =
412531
_states[roomPubKeyHex] ??
@@ -422,6 +541,18 @@ class RoomSyncService extends ChangeNotifier {
422541
);
423542
}
424543

544+
Duration _nextAutoLoginDelay(int processedCount) {
545+
if (processedCount <= 0) return Duration.zero;
546+
if (processedCount % _autoLoginBurstSize == 0) {
547+
return _autoLoginBurstPause;
548+
}
549+
final range = _autoLoginJitterMaxMs - _autoLoginJitterMinMs;
550+
final jitterMs = range <= 0
551+
? _autoLoginJitterMinMs
552+
: _autoLoginJitterMinMs + _random.nextInt(range + 1);
553+
return Duration(milliseconds: jitterMs);
554+
}
555+
425556
Future<void> _persistStates() async {
426557
await _roomSyncStore.save(_states);
427558
}
@@ -430,6 +561,22 @@ class RoomSyncService extends ChangeNotifier {
430561
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
431562
}
432563

564+
String? _resolveRoomPubKeyHexByPrefix(String prefixHex) {
565+
final connector = _connector;
566+
if (connector == null) return null;
567+
final matches = connector.contacts
568+
.where((contact) {
569+
if (contact.type != advTypeRoom) return false;
570+
if (contact.publicKey.length < 6) return false;
571+
return _prefixHex(contact.publicKey.sublist(0, 6)) == prefixHex;
572+
})
573+
.map((contact) => contact.publicKeyHex)
574+
.toSet()
575+
.toList();
576+
if (matches.length != 1) return null;
577+
return matches.first;
578+
}
579+
433580
Future<void> _tryLoginRoomByPubKey(String roomPubKeyHex) async {
434581
final connector = _connector;
435582
if (connector == null || !connector.isConnected) return;

0 commit comments

Comments
 (0)