@@ -103,6 +103,7 @@ open class SessionRepository(
103 val fresh = runCatchingCancellable { api.listMessages(sessionId) }
103 val fresh = runCatchingCancellable { api.listMessages(sessionId) }
104 .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
104 .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
105 .getOrDefault(emptyList())
105 .getOrDefault(emptyList())
106+ var seedChanged = false
106 lock.withLock {
107 lock.withLock {
107 if (generation == seedGeneration) {
108 if (generation == seedGeneration) {
108 // Merge without pruning: SSE events arriving during the
109 // Merge without pruning: SSE events arriving during the
@@ -111,10 +112,10 @@ open class SessionRepository(
111 // Stale messages from deletions during the disconnect
112 // Stale messages from deletions during the disconnect
112 // gap are eventually removed via MessageRemoved events
113 // gap are eventually removed via MessageRemoved events
113 // or on the next app restart.
114 // or on the next app restart.
114− store.seed(fresh, prune = false)
115+ seedChanged = store.seed(fresh, prune = false)
117− publish()
118+ if (seedChanged) publish()
119 hasConnectedBefore = true
120 hasConnectedBefore = true
@@ -159,7 +160,8 @@ internal class MessageStore {
160 fun snapshot(): List<MessageWithParts> = messages.values.toList()
161 fun snapshot(): List<MessageWithParts> = messages.values.toList()
162− fun seed(initial: List<MessageWithParts>, prune: Boolean = false) {
163+ fun seed(initial: List<MessageWithParts>, prune: Boolean = false): Boolean {
163 // On re-seed (after SSE reconnect), remove messages that are no longer in the
165 // On re-seed (after SSE reconnect), remove messages that are no longer in the
164 // server snapshot (e.g. deleted during the disconnection gap). This keeps the
166 // server snapshot (e.g. deleted during the disconnection gap). This keeps the
165 // in-memory state in sync with the authoritative REST snapshot rather than
167 // in-memory state in sync with the authoritative REST snapshot rather than
@@ -167,13 +169,16 @@ internal class MessageStore {
167 // racing with just-arrived SSE events whose messages may not yet be in REST.
169 // racing with just-arrived SSE events whose messages may not yet be in REST.
168 if (prune) {
170 if (prune) {
169 val incomingIds = initial.mapTo(mutableSetOf()) { it.info.id }
171 val incomingIds = initial.mapTo(mutableSetOf()) { it.info.id }
172+ val before = messages.size
170 messages.keys.retainAll(incomingIds)
173 messages.keys.retainAll(incomingIds)
174+ if (messages.size != before) changed = true
173 for (m in initial) {
177 for (m in initial) {
174 val existing = messages[m.info.id]
178 val existing = messages[m.info.id]
175 if (existing == null) {
179 if (existing == null) {
176 messages[m.info.id] = m
180 messages[m.info.id] = m
178 // A part streamed in between subscribe and this initial load may already
183 // A part streamed in between subscribe and this initial load may already
179 // have populated this message (see observeMessages). Merge instead of
184 // have populated this message (see observeMessages). Merge instead of
@@ -187,10 +192,15 @@ internal class MessageStore {
187 if (p.id !in snapshotIds) ordered.add(p)
192 if (p.id !in snapshotIds) ordered.add(p)
189 val info = if (existing.info is UnknownMessage) m.info else existing.info
194 val info = if (existing.info is UnknownMessage) m.info else existing.info
195+ val merged = MessageWithParts(info = info, parts = ordered)
196+ if (merged != existing) {
190− messages[m.info.id] = MessageWithParts(info = info, parts = ordered)
197+ messages[m.info.id] = merged
193− evictOldMessages()
202+ if (evictOldMessages()) changed = true
196 /** Returns true if the state changed (and a new snapshot should be published). */
206 /** Returns true if the state changed (and a new snapshot should be published). */
@@ -280,11 +290,15 @@ internal class MessageStore {
280 return true
290 return true
283− /** Evict the oldest messages when the store exceeds [maxMessages], keeping memory bounded. */
293+ /** Evict the oldest messages when the store exceeds [maxMessages], keeping memory bounded.
294+ * Returns true if any messages were evicted. */
284− private fun evictOldMessages() {
295+ private fun evictOldMessages(): Boolean {
285 while (messages.size > maxMessages) {
297 while (messages.size > maxMessages) {
286 val oldestKey = messages.keys.iterator().next()
298 val oldestKey = messages.keys.iterator().next()
287 messages.remove(oldestKey)
299 messages.remove(oldestKey)