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>
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 that’s 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 Apple’s 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 you’re 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 IDE’s 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 IDE’s 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 IDE’s toolbar or open the /iosApp directory in Xcode and run it from there.
Learn more about Kotlin Multiplatform…