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
2026-07-05 23:21:02 +02:00
2026-07-28 09:10:20 +02:00
2026-03-24 04:47:19 +02:00
2026-03-23 01:41:39 +02:00
2026-03-23 01:41:39 +02:00
2026-03-23 01:41:39 +02:00

This is a Kotlin Multiplatform project targeting Android, iOS, Desktop (JVM).

  • /composeApp is for code that will be shared across your Compose Multiplatform applications. It contains several subfolders:

    • commonMain is for code thats common for all targets.
    • Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name. For example, if you want to use Apples CoreCrypto for the iOS part of your Kotlin app, the iosMain folder would be the right place for such calls. Similarly, if you want to edit the Desktop (JVM) specific part, the jvmMain folder is the appropriate location.
  • /iosApp contains iOS applications. Even if youre sharing your UI with Compose Multiplatform, you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.

Build and Run Android Application

To build and run the development version of the Android app, use the run configuration from the run widget in your IDEs toolbar or build it directly from the terminal:

  • on macOS/Linux
    ./gradlew :composeApp:assembleDebug
    
  • on Windows
    .\gradlew.bat :composeApp:assembleDebug
    

Build and Run Desktop (JVM) Application

To build and run the development version of the desktop app, use the run configuration from the run widget in your IDEs toolbar or run it directly from the terminal:

  • on macOS/Linux
    ./gradlew :composeApp:run
    
  • on Windows
    .\gradlew.bat :composeApp:run
    

Build and Run iOS Application

To build and run the development version of the iOS app, use the run configuration from the run widget in your IDEs toolbar or open the /iosApp directory in Xcode and run it from there.


Learn more about Kotlin Multiplatform

Description
Mantra as a kotlin multiplatform project
https://mantra.press
Readme 8.2 MiB
Languages
Kotlin 100%