Relays impose no ordering, so a kind:445 can turn up before the group can read it: an application message encrypted under an epoch whose commit has not landed, or a commit for an epoch ahead of the local one. Both are stored and then dropped -- MarmotInboundManager refuses an out-of-epoch commit precisely so it does not half-mutate the group -- and nothing goes back for them once the missing event fills the gap. The message is on disk, readable, and never read. A "Reindex Events" button at the bottom of the group's detail screen is that second look. Only events with nothing to show for them are replayed: no chat line at all, or one of the two placeholder types. A room where nothing went wrong is left exactly as it was, which is what makes the button safe to press on a hunch. Passes repeat while a pass recovers something, because created_at order is not epoch order and a commit recovered by one pass is what lets the next read the messages that were waiting on it. **Replaying was not safe as it stood.** Every row the path writes is keyed on an event id and upserts in place -- MarmotGroupEvent, MarmotInnerEvent, and the nip30303 entities -- with one exception. ChatMessage's primary key is autogenerated, so writing a freshly built line always inserts, and a re-read would have left the room showing each recovered message twice, once as "Undecryptable Message" and once as itself. ChatMessage.reconcileMarmotLine matches on the group event id instead, so a re-read is an update, and refuses to let a placeholder overwrite a line that says something. That last rule is what protects the line this device wrote on the way out for a message it sent: our own kind:445 cannot be read back, since the sender ratchet has consumed the generation, and without the rule a replay would have replaced our words with "Undecryptable Message". The MLS group itself was already safe to replay against, which is worth saying because it is the part that looks dangerous: a commit behind the current epoch is rejected as a duplicate before it touches the group, one ahead is refused, and a consumed ratchet generation throws before mutating anything. The exception was quartz's EpochCommitTracker, which does not dedupe and only empties when a commit applies -- so replaying a held commit just grew the list and left it pending forever. forgetPendingCommits drops the room's entries first, and the sweep feeds the events back in the order CommitOrdering picks a winner in, so a contested epoch resolves the same way it would have on every other device. **What is testable, and what is not.** The DAO is not: testDebugUnitTest is plain JVM and Room's in-memory builder wants an Android Context. So the two pieces carrying decisions are lifted out where they can be run without one -- MarmotReindexSweep for the stopping rule, and reconcileMarmotLine for which of two lines wins -- and the DAO is left as query, sweep, write. The filter tests pin why the query's `tags LIKE` is a prefilter and not a test: an event belonging to another room can mention this one in a q tag, and its own h tag is what rejects it. **Not recovered by any of this.** A message whose key is gone -- one the ratchet has already advanced past, or one from an epoch predating this device's join. And events that never reached disk at all: storeNostrEvent is a single transaction, so a kind:445 arriving before its room exists rolls back its own insert along with the failed indexing, and only a re-sync brings it back. 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…