Files
mantra-kmp/composeApp
Kgothatso Ngako 0ef0a33350 feat(chat): close live subscriptions in the background, catch up on return
Subscriptions that stay open are only free while the app is on screen. Holding a
socket open behind a doze window achieves nothing but battery, and the relay
drops the subscription anyway — so this ties them to the app lifecycle and adds
the reconciliation that covers the gap.

Nothing in this app observed the app lifecycle at all: MainActivity only calls
setContent. AppLifecycle is a small singleton holding an isForeground StateFlow,
fed from a LifecycleEventObserver in MantraNavHost (ON_START/ON_STOP), and read
by LiveSubscriptionManager. A singleton rather than something threaded through
the composition because the consumers are not composables — they are
application-scoped coroutines started before any screen exists and outliving all
of them. It defaults to foreground: on a platform that has not wired the
observer up, "always on" is the behaviour that predates this file, and a
subscription that never opens is a far worse failure than one that stays open
too long.

collectLatest over that flow is the entire mechanism. Backgrounding cancels the
block holding the subscriptions, and each one's finally sends its CLOSE and
releases the retained REQ on the way out — which is also what tells the socket
it no longer has a reason to reconnect.

On the way back:

**Reconnect before asking for anything.** RelayPool.reconnectAll tears every
socket down and immediately rebuilds it. Trusting the connection is the mistake
here: a socket that was open when the OS suspended the process reports itself
connected on the way back while being functionally dead. Re-opening eagerly
rather than leaving it to the next send is deliberate — it is what makes this a
RE-connection, so retained subscriptions are replayed and any collector still
attached from before the gap starts receiving again. That also covers queue
requests that were mid-flight when we went away, which would otherwise sit until
SUBSCRIPTION_TIMEOUT.

**Then a catch-up reconciliation.** A live subscription answers "what is new
since I connected"; negentropy answers "what do you have that I don't". Coming
back from a gap is exactly the question only the second can answer — `limit` on
the re-opened subscriptions is a window, not a guarantee. queueCatchUpSynchronization
queues the same two negentropy requests ChatRoomListViewModel queues on open:
gift wraps p-tagged to us, and group events h-tagged with every group we are in.

Deliberately the same filter shape as the screen's, down to limit=50. A
negentropy request is stored under a hash of its filter, so an identical shape
collapses into one row instead of queueing the same reconciliation twice while
both callers exist. The value itself barely matters — the negentropy pump drops
`limit` outright and it only survives into the plain-REQ fallback.

The room-to-group-id rule (has MLS state, not left, not deleted, sorted) now
lives in one place, since the catch-up and the subscription reconcile have to
agree on what "a group we are in" means.

Not covered here: connectivity changes. A network switch mid-foreground is still
only noticed by the socket's own reconnect loop, which handles the common case
but cannot know the network changed underneath it. That wants a platform
connectivity observer, and is its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 16:15:51 +02:00
..