diff --git a/mobile/lib/shared/read_state/read_state_manager.dart b/mobile/lib/shared/read_state/read_state_manager.dart index 5e4fc01553..bd3580c263 100644 --- a/mobile/lib/shared/read_state/read_state_manager.dart +++ b/mobile/lib/shared/read_state/read_state_manager.dart @@ -147,6 +147,9 @@ class ReadStateManager { Future _saved = Future.value(); bool _saveScheduled = false; Object? syncError; + // Whether the latest snapshot read succeeded. A publish that skips its own + // snapshot clears [syncError] only on this evidence. + bool _lastSnapshotSucceeded = false; final int Function() _now; // Override keys already in this device's own slot, carried unchanged. // See [isOverrideContext]. @@ -376,11 +379,13 @@ class ReadStateManager { // Snapshot transport validates completeness and fences retired sessions. final events = await _relaySession!.fetchReadStateSnapshot(); if (_disposed || !_servesThisCommunity) return false; + _lastSnapshotSucceeded = true; _mergeEvents(events); await _persistLocalState(); _onChanged(); return true; } catch (error) { + _lastSnapshotSucceeded = false; syncError = error; debugPrint('[ReadStateManager] snapshot failed: $error'); return false; @@ -546,6 +551,39 @@ class ReadStateManager { } } + /// Whether a publish would send anything: a pending event to retry, or a + /// slot whose packed contexts differ from what was last published. Mirrors + /// the packing in [_publishOnce] without changing any state. + bool _hasUnpublishedState() { + if (_pending.isNotEmpty) return true; + final primary = {..._carriedOverrides}; + for (final key in overrideGroupFrontierKeys(_carriedOverrides)) { + if (_effectiveState[key] case final value?) primary[key] = value; + } + final horizon = _now() - readStateHorizonSeconds; + final List> placement; + try { + placement = packReadStateContexts( + { + for (final entry in _localContexts.entries) + if (entry.value >= horizon) entry.key: entry.value, + }, + clientId: _clientId, + previous: _placement, + primary: primary, + ); + } on StateError { + // Let the real publish surface the capacity error. + return true; + } + if (placement.length > _slots.length) return true; + for (var index = 0; index < placement.length; index++) { + final published = _published[_slots[index]] ?? const {}; + if (!mapEquals(published, placement[index])) return true; + } + return false; + } + Future _publishOnce( SignedEventRelay signedEventRelay, { required bool allowDisposed, @@ -553,10 +591,20 @@ class ReadStateManager { try { if (!_servesThisCommunity || !_ownsJournal) return; await _persistLocalState(); + // The snapshot below is the whole read state (megabytes on a busy + // account), so skip it when there is nothing to send. Each resume + // schedules a publish after its re-sync whether or not anything changed. + if (!_hasUnpublishedState()) { + // Nothing pending and every slot published, so after a good snapshot + // this device is in sync, as a full publish would have concluded. + if (_lastSnapshotSucceeded) syncError = null; + return; + } final checkConnection = _relaySession?.captureConnectionFence(); var snapshot = []; if (_relaySession != null) { final events = await _relaySession.fetchReadStateSnapshot(); + _lastSnapshotSucceeded = true; if (!_servesThisCommunity || !_ownsJournal || (_disposed && !allowDisposed)) { diff --git a/mobile/test/features/channels/read_state/read_state_manager_test.dart b/mobile/test/features/channels/read_state/read_state_manager_test.dart index 5027195362..b65f0389ad 100644 --- a/mobile/test/features/channels/read_state/read_state_manager_test.dart +++ b/mobile/test/features/channels/read_state/read_state_manager_test.dart @@ -366,6 +366,86 @@ void main() { } }); + test('a publish with nothing new skips the snapshot', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final keychain = nostr.Keys.generate(); + final crypto = ReadStateCrypto.tryCreate( + nsec: keychain.nsec, + pubkey: keychain.public, + )!; + final relay = _FakeRelaySession(); + final signer = _FakeSignedEventRelay(); + final manager = ReadStateManager( + pubkey: keychain.public, + relayUrl: _relayUrl, + prefs: prefs, + crypto: crypto, + relaySession: relay, + signedEventRelay: signer, + remoteEnabled: true, + onChanged: () {}, + now: () => 2000, + ); + + manager.markContextRead('a', 10); + await manager.flush(); + expect(relay.fetchCount, 1); + expect(signer.submitCount, 1); + + // Each resume schedules a publish after its re-sync. With nothing + // unpublished it must not download the whole read state again. + await manager.flush(); + expect(relay.fetchCount, 1); + expect(signer.submitCount, 1); + + manager.markContextRead('b', 20); + await manager.flush(); + expect(relay.fetchCount, 2); + expect(signer.submitCount, 2); + final blob = decodeReadStateBlob(crypto.decrypt(signer.contents.last))!; + expect(blob.contexts, {'a': 10, 'b': 20}); + + manager.dispose(flushPending: false); + }); + + test( + 'a skipped publish clears an error a later snapshot recovered', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final keychain = nostr.Keys.generate(); + final crypto = ReadStateCrypto.tryCreate( + nsec: keychain.nsec, + pubkey: keychain.public, + )!; + final relay = _FakeRelaySession()..failNextFetch = true; + final manager = ReadStateManager( + pubkey: keychain.public, + relayUrl: _relayUrl, + prefs: prefs, + crypto: crypto, + relaySession: relay, + signedEventRelay: _FakeSignedEventRelay(), + remoteEnabled: true, + onChanged: () {}, + ); + + await manager.initialize(); + expect(manager.syncError, isNotNull); + // A publish with nothing to send keeps the error without new evidence. + await manager.flush(); + expect(manager.syncError, isNotNull); + + await manager.reinitializeRemote(); + await manager.flush(); + expect(manager.syncError, isNull); + expect(relay.fetchCount, 2); + + manager.dispose(flushPending: false); + }, + ); + test( 'retains recent frontiers without a count eviction across restart', () async {