9107b81left the unsigned Nostr event queue alone on the grounds that it carries account traffic rather than group messages. It has the same defect, and unlike the other two it is not a latent one: NostrDao carries a written account of it having already happened. **The queue.** UnsignedNostrEventDao.observeUnsignedNostrEvents selected every unsigned row for a key and returned `Flow<UnsignedNostrEvent?>`, so Room handed back the first and dropped the rest: SELECT * FROM UnsignedNostrEvent WHERE pubKey = :publicKey AND signedAt IS NULL ORDER BY kind ASC The only exit is a successful publish. NostrDao.commitPublishedNostrEvent stamps `signedAt`, stores the signed event and queues a BroadcastNostrEventRequest per relay, all in one @Transaction. Nothing else clears it -- no attempt count, no failure status, no sweep -- so a row that cannot be published is selected again at the head of every later emission. **It has already happened.** The comment on commitPublishedNostrEvent is the report: indexing used to share that transaction, so any throw in it rolled `signedAt` back, "leaving the notary to re-select the same unsigned row forever and never sign anything queued behind it, including the MLS key package that goes last". That was closed by giving indexing its own transaction, and NostrDaoJvmTest pins it. What it did not close is the queue: it fixed the one known way to produce a stuck row and left the queue as narrow as it was, so the next way in has the same consequence. **The frozen variant.** Both other queues sat behind distinctUntilChanged too, and9107b81recorded that they failed by different mechanics depending on whether the row's `equals` was honest. UnsignedNostrEvent.equals is a plain value comparison with no @Ignore'd Logger in it, so this is the worse one: the stuck row's re-emission compared equal to the last, was dropped as no change, and the collector saw nothing again for the life of the session. Not a retry loop that never advances -- a collector that has stopped, while rows keep piling up behind a head nobody is looking at. **What sits behind the head.** The kinds matter here in a way they did not for the other two, because `kind ASC` is not an arbitrary order. An account queues 0 metadata, 3 contacts, 10007 search relays, 10012 relay feeds, 10050 DM relays, 10051 key package relays, and later 30443, the MLS key package -- which is what "goes last" means, since 30443 is the highest of them. Sitting in the middle is 10012, the one row of that burst carrying `privateTags`, and therefore the only one whose publish runs a NIP-44 encryption before it signs. A throw there takes 10050, 10051 and 30443 with it: both relay lists a peer needs to find this user, and the key package they need to invite them into a marmot group. The device looks fine to its owner -- the profile is announced, the gate has opened -- and is unreachable to everyone else. That is the shape of the next stall rather than a hypothetical one, which is why it is written on the query. **The fix.** Same as the other two. The query returns the backlog, NotaryViewModel walks it serially and keeps guardNotarization per row, and distinctUntilChanged goes. A publish that throws rolls back its own transaction and nothing else, so the row stays queued for the next pass while the rest of the account's events go out. `kind ASC` is kept, and now says why: kind 0 sorts first and NavigationViewModel holds the user on "announcing your profile" until it lands, so the order is load-bearing rather than incidental. `id ASC` is added as the tiebreak -- it is the autogenerated row id, so two events of one kind publish in the order they were queued, which for a replaceable kind is the difference between the newest version standing and an older one being published last and winning. Nothing about the navigation gate changes. It reads the kind-0 LocalAccount's relations, not the queue's shape, and kind 0 is still the first row of the first pass. With this the notary has no single-row queue left. The one remaining `distinctUntilChanged` in it, on observeActiveMarmotKeyPackageBundle, is correct: that flow is a state observation -- null means "no active bundle, make one" -- not a backlog, and re-running the creation on every unrelated emission is exactly what it is there to prevent. **Tests.** UnsignedNostrEventQueueJvmTest, seven of them, Room-backed. The account's real kinds are used rather than an inert one, because their sort order is the whole reason a stall in the middle of that burst leaves a user nobody can reach. "an event that cannot be published no longer hides the ones behind it" fails the 10012 row and asserts the metadata ahead of it and both relay lists and the key package behind it are all signed, with the stopper still queued and still unsigned; "an event past the stopper is queued for every relay" then checks the key package got a pending BroadcastNostrEventRequest per relay, since per0211764a row with `signedAt` and no request is the same silence in a different place. The rest pin the backlog arriving whole and lowest-kind first, one kind's rows keeping their insertion order across two passes, and one key's queue not containing another's. **Not covered.** The drain in the test is shaped like NotaryViewModel's loop but is the test's own, so these pin the DAO's half -- the backlog arrives whole, publishing some rows neither disturbs nor depends on the others -- and not the collector. Standing that up wants an ActiveWallet StateFlow and the whole ViewModel with it. A row that can never be published is still never published; as with9107b81it is only no longer contagious, and nothing yet tells the user which of their events is stuck or why. Verified: :composeApp:compileDebugKotlinAndroid succeeds; 518 tests pass, 511 before these seven. 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…