From 333d305c1ac5358a62805219e029f0202e05bb7f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 26 Sep 2026 15:47:25 -0400 Subject: [PATCH 1/2] fix(cordn): observe the two StateFlows these screens were reading flat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android Lint's `StateFlowValueCalledInComposition` on both, and in both cases the value it could not see changing is one the screen exists to show. `CordnCreateGroupScreen` read `runtime.groups.all.value` to count the groups this coordinator already carries for the exposure card. That count is the number the card is *about* — how much one operator can correlate — and creating a group is exactly what moves it, so a frozen read is wrong on the screen where it matters most. `CordnInvitationsScreen` read `runtime.coordinators.value` to choose between "you have no coordinators" and "nobody has invited you". Adding a coordinator elsewhere has to change which of those two sentences is on screen; otherwise it keeps telling you to add one you already added. Both now collect with `collectAsStateWithLifecycle`, which the create screen was already doing one line above for `coordinators`. Found running the Android Lint job locally; it was the only thing failing it. Co-Authored-By: Claude Opus 5 (1M context) --- .../chats/cordnGroup/CordnCreateGroupScreen.kt | 11 +++++++---- .../chats/cordnGroup/CordnInvitationsScreen.kt | 9 ++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt index 651859f69e..6deedde2d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt @@ -147,6 +147,12 @@ fun CordnCreateGroupScreen( } val known by runtime.coordinators.collectAsStateWithLifecycle() + // Collected, not read as `.value` below: the exposure card counts the + // groups this coordinator already carries, and reading the flow inside + // composition would leave that count frozen at whatever it was when the + // screen opened — the one number on the card that can change while you + // are looking at it, since creating a group is what changes it. + val rooms by runtime.groups.all.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() val me = accountViewModel.account.signer.pubKey @@ -491,10 +497,7 @@ fun CordnCreateGroupScreen( // serves for this account: the disclosure is about what // one coordinator can correlate, so a second group on // the same one widens it. - linkedGroupCount = - 1 + - runtime.groups.all.value - .count { room -> room.coordinatorPubKey == it.pubKey }, + linkedGroupCount = 1 + rooms.count { room -> room.coordinatorPubKey == it.pubKey }, joinedFromShareLink = false, publishedKeyPackage = false, encryptionPinned = true, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnInvitationsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnInvitationsScreen.kt index 147527e9ea..a1292ad0e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnInvitationsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnInvitationsScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -155,6 +156,12 @@ fun CordnInvitationsScreen( return@Column } + // Collected rather than read as `.value` further down: the empty + // state says either "you have no coordinators" or "nobody has + // invited you", and adding a coordinator in another tab has to + // change which of those two this screen is showing. + val coordinators by runtime.coordinators.collectAsStateWithLifecycle() + Text( text = stringRes(R.string.cordn_invitations_explainer), style = MaterialTheme.typography.bodyMedium, @@ -227,7 +234,7 @@ fun CordnInvitationsScreen( if (loaded.isEmpty) { Text( text = - if (runtime.coordinators.value.isEmpty()) { + if (coordinators.isEmpty()) { stringRes(R.string.cordn_invitations_no_coordinators) } else { stringRes(R.string.cordn_invitations_none) From 69c2feab3ac35c0f2153e444c99136bb5e04324b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 26 Sep 2026 15:59:18 -0400 Subject: [PATCH 2/2] fix(quartz): drop the commas Kotlin/Native will not take in a test name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `:quartz:compileTestKotlinLinuxX64` and `compileTestKotlinIosSimulatorArm64` both fail with `Name contains illegal characters: ","`. A backtick-quoted identifier may hold a comma on the JVM and may not on Native, and these are `commonTest`, so they build for every target. Sixteen names across seven cordn/contextvm test files, all of which arrived with #4201 and none of which had ever been compiled for a native target. Reworded rather than stripped: "a failed negotiation, not a downgrade" becomes "rather than a downgrade" and the three "needs kind N, an e tag, and text" names become "with an e tag and text", so each still reads as the sentence it was. Hyphens and apostrophes are fine on Native and are left alone. Checked the whole of commonTest for the rest of the illegal set as well (. ; : [ ] / < > \) — the commas were all of it. Co-Authored-By: Claude Opus 5 (1M context) --- .../quartz/contextvm/cep08Payments/PaymentTest.kt | 2 +- .../cep22OversizedTransfer/OversizedTransferTest.kt | 2 +- .../quartz/contextvm/core/CvmMessageEventTest.kt | 4 ++-- .../quartz/cordn/appEncryptedMedia/CordnBlobUploadTest.kt | 6 +++--- .../cordn/appEncryptedMedia/CordnMediaEncryptionTest.kt | 6 +++--- .../cordn/spec02Envelopes/CordnAnnotationIndexTest.kt | 4 ++-- .../quartz/cordn/spec02Envelopes/CordnMessageKindsTest.kt | 8 ++++---- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentTest.kt index da76d4711c..2225f18361 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentTest.kt @@ -184,7 +184,7 @@ class PaymentTest { } @Test - fun `CVM-8-22 a server that ignores the request is a failed negotiation, not a downgrade`() { + fun `CVM-8-22 a server that ignores the request is a failed negotiation rather than a downgrade`() { val session = PaymentSession(requested = PaymentInteraction.EXPLICIT_GATING) session.observeServerTags(emptyArray()) assertTrue(session.negotiationFailed, "silent fallback must be visible to the caller") diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferTest.kt index eb1f15a2e2..dfb9dc02a2 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferTest.kt @@ -76,7 +76,7 @@ class OversizedTransferTest { } @Test - fun `CVM-22-02 assembles out-of-order chunks by progress, not arrival order`() { + fun `CVM-22-02 assembles out-of-order chunks by progress rather than arrival order`() { // Relays may reorder. `progress` is the canonical assembly index. val frames = sender().frame(token, payload) val start = frames.first() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmMessageEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmMessageEventTest.kt index 61cbd5490e..3ce2747447 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmMessageEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmMessageEventTest.kt @@ -43,7 +43,7 @@ class CvmMessageEventTest { private val ping = JsonRpcRequest(JsonRpcId.Num(1), "ping") @Test - fun `CVM-CORE-02 content is a stringified JSON-RPC message, not an embedded object`() { + fun `CVM-CORE-02 content is a stringified JSON-RPC message rather than an embedded object`() { val template = CvmMessageEvent.build(ping, serverPubKey) // The spec's examples print `content` unstringified for readability, @@ -91,7 +91,7 @@ class CvmMessageEventTest { } @Test - fun `CVM-CORE-06 kind 25910 is ephemeral, so delivery has no replay`() { + fun `CVM-CORE-06 kind 25910 is ephemeral so delivery has no replay`() { // Consequence, not decoration: relays do not retain this kind, so a // subscription must be live before the peer publishes. The transport's // request API is built around this and the property is worth pinning. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnBlobUploadTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnBlobUploadTest.kt index 2852d6c080..7d1be0ed02 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnBlobUploadTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnBlobUploadTest.kt @@ -44,7 +44,7 @@ class CordnBlobUploadTest { private val sealed = CordnMediaEncryption.encrypt(file, key, "image/jpeg", "holiday.jpg") @Test - fun `the host is given the ciphertext, and its hash names the blob`() { + fun `the host is given the ciphertext and its hash names the blob`() { val blob = CordnBlobUpload.of(sealed) // Blossom addresses a blob by the hash of what it stores. @@ -65,7 +65,7 @@ class CordnBlobUploadTest { } @Test - fun `the declared type is opaque, never the real one`() { + fun `the declared type is opaque rather than the real one`() { val blob = CordnBlobUpload.of(sealed) assertEquals("application/octet-stream", blob.contentType) @@ -93,7 +93,7 @@ class CordnBlobUploadTest { } @Test - fun `upload, never the media endpoint`() { + fun `upload rather than the media endpoint`() { // `/media` asks the server to re-encode. Re-encoding ciphertext // destroys it, so an account with "optimize uploads" on would break // every attachment and only the recipient would find out. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaEncryptionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaEncryptionTest.kt index 817de45134..572c412b28 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaEncryptionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaEncryptionTest.kt @@ -69,7 +69,7 @@ class CordnMediaEncryptionTest { } @Test - fun `renaming a file breaks it, because the name is authenticated`() { + fun `renaming a file breaks it because the name is authenticated`() { val sealed = roundTrip(name = "photo.jpg") assertFailsWith { @@ -180,7 +180,7 @@ class CordnMediaEncryptionTest { } @Test - fun `the display hints round-trip, including the waveform`() { + fun `the display hints round-trip including the waveform`() { // The recorder measures amplitudes and the composer preview already // draws them; without somewhere to put them they were dropped at upload // and the sender's own voice note came back bar-less. @@ -207,7 +207,7 @@ class CordnMediaEncryptionTest { } @Test - fun `a hint that is absent or malformed costs the hint, not the attachment`() { + fun `a hint that is absent or malformed costs the hint rather than the attachment`() { // Every one of these is optional, so a reader that cannot make sense of // one must still be able to fetch and open the file. val tag = CordnMediaTag.build(roundTrip(), url = "https://b.example.com/b") diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnAnnotationIndexTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnAnnotationIndexTest.kt index 24d5ce7ef4..6dc636a727 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnAnnotationIndexTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnAnnotationIndexTest.kt @@ -73,7 +73,7 @@ class CordnAnnotationIndexTest { } @Test - fun `only the author may edit, and the newest edit wins`() { + fun `only the author may edit and the newest edit wins`() { val note = message(alice, CordnMessageKinds.TEXT, "original", createdAt = 100) val tags = CordnMessageReferences.editTags(note.asTarget()) @@ -177,7 +177,7 @@ class CordnAnnotationIndexTest { } @Test - fun `any member may pin, and the last write wins`() { + fun `any member may pin and the last write wins`() { val note = message(alice, CordnMessageKinds.TEXT, "important", createdAt = 100) val t = note.asTarget() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageKindsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageKindsTest.kt index 8cc2c492f0..3acef99dd3 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageKindsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageKindsTest.kt @@ -64,7 +64,7 @@ class CordnMessageKindsTest { } @Test - fun `the numbers are cordn-web's, and differ from Marmot's on purpose`() { + fun `the numbers are cordn-web's and differ from Marmot's on purpose`() { // Pinned because they are the interop surface with the only other cordn // client, and because two of them disagree with Marmot (1009 edit, no // pin) by decision rather than accident. @@ -170,7 +170,7 @@ class CordnMessageKindsTest { // ---- inbound ------------------------------------------------------- @Test - fun `a reaction needs kind 7, e p k, and content`() { + fun `a reaction needs kind 7 with e p k and content`() { val good = CordnMessageReferences.reactionTags(target()) assertNotNull(CordnMessageReferences.reaction(7, "+", good)) @@ -210,7 +210,7 @@ class CordnMessageKindsTest { } @Test - fun `an edit needs kind 1010, an e tag, and text`() { + fun `an edit needs kind 1010 with an e tag and text`() { val tags = CordnMessageReferences.editTags(target()) assertEquals("1".repeat(64), assertNotNull(CordnMessageReferences.edit(1010, "new", tags)).targetId) @@ -230,7 +230,7 @@ class CordnMessageKindsTest { } @Test - fun `a pin needs kind 1011, an e tag, and a known op`() { + fun `a pin needs kind 1011 with an e tag and a known op`() { assertEquals( PinOp.ADD, assertNotNull(CordnMessageReferences.pin(1011, CordnMessageReferences.pinTags(target(), PinOp.ADD))).op,