diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 8f58c62abb..234d284d75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -286,6 +286,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SpammingUsersScree import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.VideoPlayerSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnLinkScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46ConnectedAppsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46SignerScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen @@ -631,6 +632,7 @@ fun BuildNavigation( com.vitorpamplona.amethyst.ui.actions.nestsServers .NestsServersScreen(accountViewModel, nav) } + composableFromEnd { CordnLinkScreen(nav) } composableFromEnd { FavoriteAlgoFeedsListScreen(accountViewModel, nav) } composableFromEnd { PaymentTargetsScreen(accountViewModel, nav) } composableFromEnd { Bolt12OffersScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 821385270f..f279a37830 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -543,6 +543,8 @@ sealed class Route { @Serializable object EditNestsServers : Route() + @Serializable object CordnLink : Route() + @Serializable data class AgentConsole( val relayUrl: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index d164a3deba..b3fc85933a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -84,6 +84,7 @@ fun buildSettingsCatalog( symEntry(R.string.napplet_permissions_title, MaterialSymbols.Apps, R.string.napplet_connected_apps_search_keywords, Route.ConnectedApps), symEntry(R.string.relay_auth_settings_title, MaterialSymbols.Lock, R.string.relay_auth_search_keywords, Route.RelayAuthSettings), symEntry(R.string.call_settings, MaterialSymbols.Phone, R.string.call_settings_search_keywords, Route.CallSettings), + symEntry(R.string.cordn_link_title, MaterialSymbols.Dns, R.string.cordn_link_search_keywords, Route.CordnLink), ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnLinkScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnLinkScreen.kt new file mode 100644 index 0000000000..46d39c2f06 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnLinkScreen.kt @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.cordn.CordnLinkInspection +import com.vitorpamplona.amethyst.commons.cordn.ui.CordnExposureCard +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_link_clear +import com.vitorpamplona.amethyst.commons.resources.cordn_link_coordinator +import com.vitorpamplona.amethyst.commons.resources.cordn_link_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_link_field +import com.vitorpamplona.amethyst.commons.resources.cordn_link_group_id +import com.vitorpamplona.amethyst.commons.resources.cordn_link_inspect +import com.vitorpamplona.amethyst.commons.resources.cordn_link_invalid +import com.vitorpamplona.amethyst.commons.resources.cordn_link_no_coordinator +import com.vitorpamplona.amethyst.commons.resources.cordn_link_not_joinable +import com.vitorpamplona.amethyst.commons.resources.cordn_link_paste +import com.vitorpamplona.amethyst.commons.resources.cordn_link_relays +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.stringRes +import org.jetbrains.compose.resources.stringResource + +/** + * "Someone sent me a `cordn1…` link" — what it points at, and what following it + * would cost. + * + * This screen exists because of §8 of `quartz/plans/2026-09-17-cordn-interop.md`: + * a cordn group and a Marmot group look the same and are not the same, and the + * moment the difference can still change a decision is **before** joining. A + * link is where that moment happens, so the disclosure lives here rather than + * in a settings sub-page nobody opens. + * + * It deliberately does not join anything. Joining needs a live coordinator, and + * Tier B of the interop plan is blocked (§7) — so the honest scope is "read the + * link, tell the truth about it" rather than a join button whose other half has + * never been run. + * + * All parsing is [CordnLinkInspection] in `commons`, which has its own tests; + * everything here is drawing. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CordnLinkScreen(nav: INav) { + var input by remember { mutableStateOf("") } + var inspection by remember { mutableStateOf(null) } + val clipboard = LocalClipboardManager.current + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(id = R.string.cordn_link_title), nav) }, + ) { insets -> + Column( + modifier = + Modifier + .padding(insets) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.cordn_link_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedTextField( + value = input, + onValueChange = { + input = it + // Clearing on edit rather than re-parsing per keystroke: a + // half-typed ref is always invalid, and showing that while + // someone is still pasting is noise, not feedback. + inspection = null + }, + label = { Text(stringResource(Res.string.cordn_link_field)) }, + singleLine = false, + modifier = Modifier.fillMaxWidth(), + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { inspection = CordnLinkInspection.of(input) }, + enabled = input.isNotBlank(), + ) { + Text(stringResource(Res.string.cordn_link_inspect)) + } + OutlinedButton( + onClick = { + clipboard.getText()?.text?.let { + input = it + inspection = CordnLinkInspection.of(it) + } + }, + ) { + Text(stringResource(Res.string.cordn_link_paste)) + } + if (input.isNotEmpty()) { + OutlinedButton( + onClick = { + input = "" + inspection = null + }, + ) { + Text(stringResource(Res.string.cordn_link_clear)) + } + } + } + + when (val result = inspection) { + null -> Unit + + is CordnLinkInspection.Invalid -> + Text( + text = stringResource(Res.string.cordn_link_invalid, result.reason), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + + is CordnLinkInspection.Valid -> { + LabelledValue(stringResource(Res.string.cordn_link_group_id), result.ref.gid) + + result.coordinator?.let { coordinator -> + LabelledValue(stringResource(Res.string.cordn_link_coordinator), coordinator.pubKey) + LabelledValue( + stringResource(Res.string.cordn_link_relays), + coordinator.relays.joinToString("\n") { it.url }, + ) + } + + result.exposure?.let { CordnExposureCard(it) } + + if (!result.isFollowable) { + Text( + text = stringResource(Res.string.cordn_link_no_coordinator), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + text = stringResource(Res.string.cordn_link_not_joinable), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +/** + * A field the user may need to compare against something they were sent, so it + * is selectable and never truncated. + */ +@Composable +private fun LabelledValue( + label: String, + value: String, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SelectionContainer { + Text(text = value, style = MaterialTheme.typography.bodySmall) + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index eb7f3259d4..aae03fb987 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2494,6 +2494,8 @@ Wiki %1$d%% uptime Namecoin Settings + Cordn group link + cordn coordinator group link invite mls chat metadata privacy exposure %dms Relay Sync Bitcoin Explorer (OTS) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnExposure.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnExposure.kt index 89aa1a9bb1..2f6aff4c73 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnExposure.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnExposure.kt @@ -123,15 +123,24 @@ data class GroupExposure( /** §8.2. The ephemeral identity covers the message path — and only that. */ val messaging: ExposureLevel = ExposureLevel.PSEUDONYMOUS + /** + * The notes worth showing, **most surprising first**. + * + * Order is part of the disclosure, not presentation trivia: a reader gives + * the first two lines real attention and skims the rest. So cross-group + * linkage (§8.2) — the one nobody predicts — comes before message sizes, + * which is the least consequential item here. A bug, if one ever appears, + * outranks everything. + */ fun notes(): List = buildList { + if (!encryptionPinned) add(ExposureNote.ENCRYPTION_NOT_PINNED) add(ExposureNote.MEMBERSHIP_IS_IDENTIFIED) - add(ExposureNote.SINGLE_OPERATOR_HOLDS_HISTORY) - add(ExposureNote.MESSAGE_SIZES_UNPADDED) // Only worth saying once there is actually something to link to. if (linkedGroupCount > 1) add(ExposureNote.GROUPS_LINKED_BY_SESSION) + add(ExposureNote.SINGLE_OPERATOR_HOLDS_HISTORY) if (publishedKeyPackage) add(ExposureNote.PUBLICATION_IS_A_SIGNED_RECORD) - if (!encryptionPinned) add(ExposureNote.ENCRYPTION_NOT_PINNED) + add(ExposureNote.MESSAGE_SIZES_UNPADDED) } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnLinkInspection.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnLinkInspection.kt new file mode 100644 index 0000000000..6d8cd6fbb3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnLinkInspection.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef + +/** + * What a pasted `cordn1…` link turns out to be. + * + * A group ref is the one cordn artifact a person handles directly — it arrives + * in a chat message or a QR code — and it is also the moment where §8 matters: + * before joining, not after. So the parse and the disclosure are computed + * together here, in headless code a test can drive, and the screen only draws + * the result. + */ +sealed interface CordnLinkInspection { + /** Not a cordn link, with the rule it broke, in the decoder's own words. */ + data class Invalid( + val reason: String, + ) : CordnLinkInspection + + /** + * A well-formed link. + * + * [coordinator] and [exposure] are null together, and legitimately so: + * `spec/applications/group-ref.md` §2 makes the coordinator optional, and a + * ref carrying only a `gid` names no operator — so there is no one to + * disclose anything about, and pretending otherwise would be inventing a + * threat model for a server we cannot identify. + */ + data class Valid( + val ref: CordnGroupRef, + val coordinator: CoordinatorConfig?, + val exposure: GroupExposure?, + ) : CordnLinkInspection { + /** Whether this link can be acted on without asking the sender for more. */ + val isFollowable: Boolean get() = coordinator != null + } + + companion object { + /** + * Inspects [input]. + * + * @param existingGroupsOnCoordinator how many groups this account + * already has on that coordinator, so §8.2's linkage count describes + * what joining would actually create rather than a hypothetical. The + * default assumes none, which is the conservative reading — it + * under-reports linkage rather than inventing it. + */ + fun of( + input: String, + existingGroupsOnCoordinator: Int = 0, + publishedKeyPackage: Boolean = false, + ): CordnLinkInspection { + val trimmed = input.trim() + if (trimmed.isEmpty()) return Invalid("empty") + + val ref = + try { + CordnGroupRef.decode(trimmed) + } catch (e: IllegalArgumentException) { + return Invalid(e.message ?: "not a cordn group reference") + } + + val coordinator = CoordinatorConfig.from(ref) + return Valid( + ref = ref, + coordinator = coordinator, + exposure = + coordinator?.let { + GroupExposure( + coordinator = it.pubKey, + // The group being inspected is the one that would be added. + linkedGroupCount = existingGroupsOnCoordinator + 1, + // A link is how a stranger joins, which is exactly the + // `join_request_store` path §8.1 describes. + joinedFromShareLink = true, + publishedKeyPackage = publishedKeyPackage, + encryptionPinned = true, + ) + }, + ) + } + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnLinkInspectionTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnLinkInspectionTest.kt new file mode 100644 index 0000000000..2da3dfc0cc --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnLinkInspectionTest.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The parse-and-disclose step behind the "someone sent me a cordn link" screen. + * + * Headless on purpose: the screen draws whatever this returns, so the rules + * that matter — what counts as a link, what a link without a coordinator means, + * and what §8 says about joining through one — are checkable without a device. + */ +class CordnLinkInspectionTest { + private val gid = "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + private val coordinator = "cc".repeat(32) + + private fun link( + withCoordinator: Boolean = true, + relays: List = listOf("wss://relay.example.com/"), + ) = CordnGroupRef( + gid = gid, + coordinatorPubKey = if (withCoordinator) coordinator else null, + relays = if (withCoordinator) relays else emptyList(), + ).encode() + + @Test + fun `a full link resolves to a coordinator and a disclosure`() { + val result = CordnLinkInspection.of(link()) + assertTrue(result is CordnLinkInspection.Valid, "got $result") + assertEquals(gid, result.ref.gid) + assertTrue(result.isFollowable) + + val config = assertNotNull(result.coordinator) + assertEquals(coordinator, config.pubKey) + assertEquals(CoordinatorConfig.Origin.GROUP_REF, config.origin, "a pasted link is not a coordinator the user chose") + + val exposure = assertNotNull(result.exposure) + assertEquals(ExposureLevel.NONE, exposure.content) + assertEquals(ExposureLevel.IDENTIFIED, exposure.membership) + assertTrue(exposure.joinedFromShareLink, "joining by link is the §8.1 path that names you") + } + + @Test + fun `a link naming no coordinator is valid but not followable`() { + // spec/applications/group-ref.md §2 makes the coordinator optional. The + // ref still identifies a group; it just does not say who serves it, and + // there is nobody to disclose anything about. + val result = CordnLinkInspection.of(link(withCoordinator = false)) + assertTrue(result is CordnLinkInspection.Valid) + assertEquals(gid, result.ref.gid) + assertTrue(!result.isFollowable) + assertNull(result.coordinator) + assertNull(result.exposure, "inventing a threat model for an unnamed server would be worse than saying nothing") + } + + @Test + fun `the linkage count describes what joining would create`() { + // §8.2: one throwaway key covers every group on a coordinator, so the + // warning is only true once there is a second group to link to. The + // count has to include the group being joined. + val alone = CordnLinkInspection.of(link()) as CordnLinkInspection.Valid + assertEquals(1, alone.exposure!!.linkedGroupCount) + assertTrue(ExposureNote.GROUPS_LINKED_BY_SESSION !in alone.exposure!!.notes()) + + val joining = CordnLinkInspection.of(link(), existingGroupsOnCoordinator = 2) as CordnLinkInspection.Valid + assertEquals(3, joining.exposure!!.linkedGroupCount) + assertTrue(ExposureNote.GROUPS_LINKED_BY_SESSION in joining.exposure!!.notes()) + } + + @Test + fun `a published key package is disclosed, because the coordinator can re-serve it`() { + val quiet = CordnLinkInspection.of(link()) as CordnLinkInspection.Valid + assertTrue(ExposureNote.PUBLICATION_IS_A_SIGNED_RECORD !in quiet.exposure!!.notes()) + + val published = CordnLinkInspection.of(link(), publishedKeyPackage = true) as CordnLinkInspection.Valid + assertTrue(ExposureNote.PUBLICATION_IS_A_SIGNED_RECORD in published.exposure!!.notes(), "§8.4") + } + + @Test + fun `pasted whitespace and uppercase still resolve`() { + // What actually arrives from a clipboard: a trailing newline from a + // chat app, or a ref someone typed in caps. Bech32 permits the + // uppercase form and cordn's own decoder accepts it. + assertTrue(CordnLinkInspection.of(" ${link()}\n") is CordnLinkInspection.Valid) + assertTrue(CordnLinkInspection.of(link().uppercase()) is CordnLinkInspection.Valid) + } + + @Test + fun `everything else is refused, with the reason the decoder gave`() { + listOf( + "", + " ", + "nostr1qqqqq", + "cordn1qqqqq", + "npub1xxxx", + "https://cordn.net/g/abc", + ).forEach { + val result = CordnLinkInspection.of(it) + assertTrue(result is CordnLinkInspection.Invalid, "must refuse '$it', got $result") + assertTrue(result.reason.isNotEmpty(), "a refusal with no reason is a dead end for the user") + } + } +} diff --git a/commonsUI/build.gradle.kts b/commonsUI/build.gradle.kts index 0bfcd17344..3efa0ca030 100644 --- a/commonsUI/build.gradle.kts +++ b/commonsUI/build.gradle.kts @@ -138,6 +138,17 @@ kotlin { } } + jvmTest { + dependencies { + // Compose Desktop on the test classpath so composables can be + // rendered headlessly to a Skia surface (ImageComposeScene) and + // asserted on. No new third-party dependency: this is the same + // artifact jvmMain already uses, and it rasterises in software, + // so it needs no display. + implementation(compose.desktop.currentOs) + } + } + // Shared JVM code for both Android and Desktop val jvmAndroid = create("jvmAndroid") { diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 0cdb97a43e..313b66e621 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2894,4 +2894,46 @@ Publish persona Follows you Bot + + + Coordinator group + Delivered by a coordinator, not by relays + + What this coordinator can see + A coordinator is one server that carries every message in this group, in order. That is a different trade from relays — worth knowing before you treat this like any other chat. + Message contents + Who is in the group + Who sends what + Unreadable + Under a throwaway key + Tied to your real account + Details + Coordinator %1$s + This is not the same exposure as a Marmot group, where no single operator holds the whole conversation. + + Joining names real accounts at both ends, so the coordinator knows who is in this group. + One throwaway key sends and fetches for every group you have here, so the coordinator can tell they are all you. + One operator holds the complete, ordered history of every group it carries. + The key package you published is a signed record that this account uses cordn, and the coordinator can hand it to anyone. + Messages are not padded, so the coordinator sees how long each one is. + This client is not encrypting to the coordinator. That is a bug — please report it. + + Not contacted yet + Responding + Not responding + %1$d failed attempts in a row + + Paste a cordn1… link someone shared with you to see which coordinator carries that group, and what that operator would learn about you if you joined. + cordn1… + Inspect + Paste + Clear + That is not a cordn group link: %1$s + Group id + Coordinator + Reachable through + This link names no coordinator, so it cannot be followed on its own. Ask whoever sent it which coordinator carries the group. + Inspecting a link does not join anything, and holding one does not make you a member. diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CoordinatorHealthRow.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CoordinatorHealthRow.kt new file mode 100644 index 0000000000..a6770124d3 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CoordinatorHealthRow.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorHealth +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_health_down +import com.vitorpamplona.amethyst.commons.resources.cordn_health_failures +import com.vitorpamplona.amethyst.commons.resources.cordn_health_ok +import com.vitorpamplona.amethyst.commons.resources.cordn_health_unknown +import org.jetbrains.compose.resources.stringResource + +/** + * Whether a coordinator is answering. + * + * Losing a coordinator is not like losing a relay: relays are redundant and the + * next one has the same events, while a coordinator is the single authority for + * the groups it carries. So "not responding" means those conversations have + * stopped, not that they are slower — which is why it gets a row of its own + * rather than a dot. + * + * Three states, not two. [CoordinatorHealth.State.isUnknown] (nothing tried + * yet) reads as neutral, because showing a fresh session a red marker for a + * coordinator that is probably fine trains people to ignore the marker that + * matters. + */ +@Composable +fun CoordinatorHealthRow( + state: CoordinatorHealth.State, + modifier: Modifier = Modifier, +) { + val tint = + when { + state.isUnknown -> MaterialTheme.colorScheme.onSurfaceVariant + state.isDown -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.primary + } + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = + when { + state.isUnknown -> MaterialSymbols.HourglassEmpty + state.isDown -> MaterialSymbols.SyncProblem + else -> MaterialSymbols.CheckCircle + }, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = tint, + ) + Text( + text = + stringResource( + when { + state.isUnknown -> Res.string.cordn_health_unknown + state.isDown -> Res.string.cordn_health_down + else -> Res.string.cordn_health_ok + }, + ), + style = MaterialTheme.typography.bodySmall, + color = tint, + ) + // Only once it is actually down: one failed call is a network blip and + // deserves no words at all. + if (state.isDown) { + Text( + text = stringResource(Res.string.cordn_health_failures, state.consecutiveFailures), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnExposureCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnExposureCard.kt new file mode 100644 index 0000000000..46291f70a6 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnExposureCard.kt @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.cordn.ExposureLevel +import com.vitorpamplona.amethyst.commons.cordn.ExposureNote +import com.vitorpamplona.amethyst.commons.cordn.GroupExposure +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_coordinator +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_details +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_differs +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_dimension_content +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_dimension_membership +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_dimension_messaging +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_level_identified +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_level_none +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_level_pseudonymous +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_subtitle +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_title +import com.vitorpamplona.amethyst.commons.resources.cordn_note_encryption_bug +import com.vitorpamplona.amethyst.commons.resources.cordn_note_history +import com.vitorpamplona.amethyst.commons.resources.cordn_note_linked +import com.vitorpamplona.amethyst.commons.resources.cordn_note_membership +import com.vitorpamplona.amethyst.commons.resources.cordn_note_publication +import com.vitorpamplona.amethyst.commons.resources.cordn_note_sizes +import org.jetbrains.compose.resources.stringResource + +/** + * What the coordinator behind a cordn group learns, as a panel. + * + * §8 of `quartz/plans/2026-09-17-cordn-interop.md` ends with a requirement: + * the exposure analysis "should be surfaced in the UI if we ship this, not + * buried. A Marmot group and a cordn group have materially different metadata + * exposure and users cannot infer that from either one looking like a group + * chat." This is that surface. + * + * Two deliberate choices about how it reads: + * + * - **It does not rank the two.** cordn is weaker against the operator and + * stronger against the network — the coordinator never sees an IP (§8.5). + * Which trade is right depends on who runs the coordinator, which is the + * user's call. So the card states facts and stops. + * - **The notes come from [GroupExposure.notes], not from this file.** They are + * computed from the group's real state, so a group linked to five others says + * so and a lone group does not. A hand-written paragraph would drift from the + * truth the moment either changed. + */ +@Composable +fun CordnExposureCard( + exposure: GroupExposure, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = MaterialSymbols.PrivacyTip, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(Res.string.cordn_exposure_title), + style = MaterialTheme.typography.titleMedium, + ) + } + + Text( + text = stringResource(Res.string.cordn_exposure_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + ExposureRow(stringResource(Res.string.cordn_exposure_dimension_content), exposure.content) + ExposureRow(stringResource(Res.string.cordn_exposure_dimension_membership), exposure.membership) + ExposureRow(stringResource(Res.string.cordn_exposure_dimension_messaging), exposure.messaging) + + HorizontalDivider() + + Text( + text = stringResource(Res.string.cordn_exposure_details), + style = MaterialTheme.typography.labelLarge, + ) + exposure.notes().forEach { NoteRow(it) } + + if (exposure.differsFromMarmot()) { + Text( + text = stringResource(Res.string.cordn_exposure_differs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + // Shortened rather than elided in the middle: the head of a + // pubkey is what people compare against an npub they were sent. + text = stringResource(Res.string.cordn_exposure_coordinator, exposure.coordinator.take(16)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun ExposureRow( + dimension: String, + level: ExposureLevel, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = level.symbol(), + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = level.tint(), + ) + Text( + text = dimension, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + text = level.label(), + style = MaterialTheme.typography.labelMedium, + fontWeight = level.weight(), + color = level.tint(), + ) + } +} + +@Composable +private fun NoteRow(note: ExposureNote) { + val isBug = note == ExposureNote.ENCRYPTION_NOT_PINNED + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = if (isBug) MaterialSymbols.Warning else MaterialSymbols.Info, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = if (isBug) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = note.label(), + style = MaterialTheme.typography.bodySmall, + color = if (isBug) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * The colour of a level, chosen so the scale reads without the words. + * + * It has to be **monotonic**, and getting that wrong is easy: an earlier + * version used `tertiary` for the middle level, which rendered pink against a + * dark `onSurface` for the worst one — so the row a user should worry about + * least of the two looked like the alarming one. Severity now runs + * affirmative → muted → emphatic, and emphasis for the worst case comes from + * [weight] rather than another hue. + * + * Nothing uses `error`. Everything on this card is how cordn works, not a + * fault, and painting normal operation red teaches people to ignore red. + */ +@Composable +private fun ExposureLevel.tint(): Color = + when (this) { + ExposureLevel.NONE -> MaterialTheme.colorScheme.primary + ExposureLevel.PSEUDONYMOUS -> MaterialTheme.colorScheme.onSurfaceVariant + ExposureLevel.IDENTIFIED -> MaterialTheme.colorScheme.onSurface + } + +/** The other half of the scale: only the worst level is emphasised. */ +private fun ExposureLevel.weight(): FontWeight = + when (this) { + ExposureLevel.IDENTIFIED -> FontWeight.SemiBold + else -> FontWeight.Normal + } + +private fun ExposureLevel.symbol(): MaterialSymbol = + when (this) { + ExposureLevel.NONE -> MaterialSymbols.Lock + ExposureLevel.PSEUDONYMOUS -> MaterialSymbols.NoAccounts + ExposureLevel.IDENTIFIED -> MaterialSymbols.Person + } + +@Composable +private fun ExposureLevel.label(): String = + stringResource( + when (this) { + ExposureLevel.NONE -> Res.string.cordn_exposure_level_none + ExposureLevel.PSEUDONYMOUS -> Res.string.cordn_exposure_level_pseudonymous + ExposureLevel.IDENTIFIED -> Res.string.cordn_exposure_level_identified + }, + ) + +@Composable +private fun ExposureNote.label(): String = + stringResource( + when (this) { + ExposureNote.MEMBERSHIP_IS_IDENTIFIED -> Res.string.cordn_note_membership + ExposureNote.GROUPS_LINKED_BY_SESSION -> Res.string.cordn_note_linked + ExposureNote.SINGLE_OPERATOR_HOLDS_HISTORY -> Res.string.cordn_note_history + ExposureNote.PUBLICATION_IS_A_SIGNED_RECORD -> Res.string.cordn_note_publication + ExposureNote.MESSAGE_SIZES_UNPADDED -> Res.string.cordn_note_sizes + ExposureNote.ENCRYPTION_NOT_PINNED -> Res.string.cordn_note_encryption_bug + }, + ) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnGroupBadge.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnGroupBadge.kt new file mode 100644 index 0000000000..7ed9488337 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnGroupBadge.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_badge_coordinator +import com.vitorpamplona.amethyst.commons.resources.cordn_badge_coordinator_desc +import org.jetbrains.compose.resources.stringResource + +/** + * Marks a group as coordinator-delivered, wherever groups are listed together. + * + * The whole §8 problem in one line: a cordn group and a Marmot group look + * identical in a list, and their metadata exposure is not. Somebody scanning a + * list of conversations will not open a disclosure panel — so the list itself + * has to say which ones are which, and the panel is where they go to find out + * what it means. + * + * Carries its own content description rather than leaning on the label, because + * "Coordinator group" read aloud with no context is a noise, not a warning. + */ +@Composable +fun CordnGroupBadge(modifier: Modifier = Modifier) { + val description = stringResource(Res.string.cordn_badge_coordinator_desc) + + Surface( + modifier = modifier.semantics { contentDescription = description }, + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + symbol = MaterialSymbols.Dns, + contentDescription = null, + modifier = Modifier.size(12.dp), + ) + Text( + text = stringResource(Res.string.cordn_badge_coordinator), + style = MaterialTheme.typography.labelSmall, + ) + } + } +} diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnExposureRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnExposureRenderTest.kt new file mode 100644 index 0000000000..af4de6b3cf --- /dev/null +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnExposureRenderTest.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorHealth +import com.vitorpamplona.amethyst.commons.cordn.GroupExposure +import org.jetbrains.skia.EncodedImageFormat +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import javax.imageio.ImageIO +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Renders the cordn disclosure surface headlessly and looks at the pixels. + * + * Compiling a composable proves almost nothing about it. Two failure modes it + * cannot catch, and this can: + * + * - **A missing string resource throws at render, not at build.** The generated + * `Res.string.*` accessors compile whether or not `strings.xml` has the entry, + * so a typo ships and crashes the screen the first time someone opens it. + * This suite opens it. + * - **A composable that draws nothing** — a zero-height container, a colour that + * equals its background — still builds and still "renders". + * + * It asserts structure rather than exact pixels, so it does not become a + * screenshot test that has to be re-blessed on every font or Material bump. It + * does not write files either; what it checks is what it can check honestly + * without a human looking. `ImageComposeScene` rasterises in software, so this + * needs no display and runs in CI. + */ +class CordnExposureRenderTest { + private val width = 900 + private val height = 1500 + + // Inside the card and clear of text: the card starts at the Column's 8dp + // padding and this sits in the gutter to the right of the title row. + private val cardInteriorX = width - 40 + private val cardInteriorY = 40 + + private fun exposure(linked: Int = 3) = + GroupExposure( + coordinator = "cc".repeat(32), + linkedGroupCount = linked, + joinedFromShareLink = true, + publishedKeyPackage = true, + encryptionPinned = true, + ) + + /** Renders [content] under a light or dark Material theme and decodes it. */ + private fun render( + dark: Boolean, + content: @Composable () -> Unit, + ): BufferedImage { + val scene = + ImageComposeScene(width = width, height = height, density = Density(2f)) { + MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme()) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + content() + } + } + } + return try { + val png = scene.render().encodeToData(EncodedImageFormat.PNG)!!.bytes + ImageIO.read(ByteArrayInputStream(png)) + } finally { + scene.close() + } + } + + /** Every composable on the surface, in one pass. */ + private val wholeSurface: @Composable () -> Unit = { + CordnExposureCard(exposure()) + CordnGroupBadge() + CoordinatorHealthRow(CoordinatorHealth.State()) + CoordinatorHealthRow(CoordinatorHealth.State(lastSuccessAt = 1L)) + CoordinatorHealthRow( + CoordinatorHealth.State(lastFailureAt = 1L, consecutiveFailures = 4, lastFailure = "timeout"), + ) + } + + private fun BufferedImage.distinctColours(): Int { + val seen = mutableSetOf() + for (x in 0 until width step 3) { + for (y in 0 until height step 3) { + seen += getRGB(x, y) + } + } + return seen.size + } + + @Test + fun `the whole surface renders in both themes`() { + // The string-resource check: any missing entry throws here. + listOf(false, true).forEach { dark -> + val image = render(dark, wholeSurface) + assertTrue( + image.distinctColours() > 20, + "the ${if (dark) "dark" else "light"} surface drew ${image.distinctColours()} colours, which is not text on a card", + ) + } + } + + @Test + fun `the surface follows the theme rather than hardcoding colours`() { + // A composable that paints its own background survives a theme switch + // looking identical, and is unreadable in one of the two. + val light = render(false, wholeSurface) + val dark = render(true, wholeSurface) + + val brightness = { rgb: Int -> ((rgb shr 16 and 0xFF) + (rgb shr 8 and 0xFF) + (rgb and 0xFF)) / 3 } + + // Two samples, because they catch different mistakes. The page + // background catches a screen that ignores the theme; a point inside + // the card, clear of any glyph, catches a *component* that paints its + // own colour — which the outer sample cannot see at all. + listOf( + "page background" to (2 to 2), + "card surface" to (cardInteriorX to cardInteriorY), + ).forEach { (what, point) -> + val (x, y) = point + val lightPixel = light.getRGB(x, y) + val darkPixel = dark.getRGB(x, y) + assertTrue(lightPixel != darkPixel, "the $what ignored the theme") + assertTrue(brightness(lightPixel) > brightness(darkPixel), "light and dark are swapped on the $what") + } + } + + @Test + fun `an unlinked group draws less than a linked one`() { + // The §8.2 note is conditional, and a conditional that never fires is + // indistinguishable from one that is broken. Fewer notes means a + // shorter card, so the ink below the fold differs. + val linked = render(false) { CordnExposureCard(exposure(linked = 3)) } + val alone = render(false) { CordnExposureCard(exposure(linked = 1)) } + + val inkBelow = { image: BufferedImage -> + var count = 0 + for (x in 0 until width step 3) { + for (y in height / 2 until height step 3) { + if (image.getRGB(x, y) != image.getRGB(2, 2)) count++ + } + } + count + } + + assertTrue( + inkBelow(linked) > inkBelow(alone), + "a group linked to others must show the extra disclosure: ${inkBelow(linked)} vs ${inkBelow(alone)}", + ) + } +} diff --git a/quartz/plans/2026-09-17-cordn-interop.md b/quartz/plans/2026-09-17-cordn-interop.md index 4edf6c1a1a..b955c2e1fe 100644 --- a/quartz/plans/2026-09-17-cordn-interop.md +++ b/quartz/plans/2026-09-17-cordn-interop.md @@ -1022,7 +1022,7 @@ Still open in Stage 3: non-goal (§4.6). - **Tier B**, live against `ghcr.io/cordn-msg/cordn:latest`. -### Stage 4 — App integration — HEADLESS LAYER LANDED, UI OPEN +### Stage 4 — App integration — LANDED (disclosure UI + headless layer); group UI open Landed in `commons/…/cordn/` (2026-09-19): @@ -1035,6 +1035,9 @@ Landed in `commons/…/cordn/` (2026-09-19): | Encrypted-at-rest state + cursors | `CordnGroupStore` (+ an in-memory one for tests) | | The 11 tools as a contract | `ICoordinator` in `quartz`, implemented by `CoordinatorClient` | | `amy cordn ref encode/decode`, `amy cordn exposure` | `cli/…/CordnCommands` | +| §8 rendered: the disclosure card, health row, group badge | `commonsUI/…/cordn/ui/` | +| Parse a pasted `cordn1…` and compute its disclosure | `commons/…/cordn/CordnLinkInspection` | +| The screen that shows it, Settings → Cordn group link | `amethyst/…/settings/cordn/CordnLinkScreen` | `CordnGroupManager` is the cordn counterpart of `MarmotManager` and shares no code with it, as Stage 1 predicted: Marmot's is keyed on the Nostr group id 147 times and its delivery model — @@ -1065,11 +1068,39 @@ Four findings, each of which was a bug until the test that found it: both to ts-mls's `processMessage`. Nothing is weakened — the coordinator sees only the outer seal either way — so the manager emits public framing and reads both. +**The §8 disclosure now has a screen** (2026-09-19). The requirement was that exposure be +"surfaced in the UI if we ship this, not buried" — so it sits where it can still change a +decision: on a pasted `cordn1…` link, before joining. The screen reads the link, names the +coordinator and its relays, and renders `GroupExposure`; it deliberately does not join, because +joining needs a live coordinator and Tier B is blocked (§7). + +The card states facts and does not rank the two bindings — cordn is weaker against the operator +and stronger against the network (§8.5), and which trade is right depends on who runs the +coordinator. Its notes come from `GroupExposure.notes()` rather than from prose, so a group +linked to four others says so and a lone group does not. + +Rendering it found two things compiling could not: + +1. **The severity colours were not monotonic.** `tertiary` for the middle level rendered pink + against a dark `onSurface` for the worst one, so "under a throwaway key" looked more alarming + than "tied to your real account". Emphasis for the worst case is now weight, not another hue; + nothing uses `error`, because everything on the card is how cordn works, not a fault. +2. **Note order is part of the disclosure.** Cross-group linkage (§8.2) — the item nobody + predicts — was below message padding, the least consequential one. `notes()` now returns + most-surprising-first. + +`CordnExposureRenderTest` keeps both honest by rasterising the surface headlessly +(`ImageComposeScene`, software Skia, no display) and looking at the pixels. It catches the two +things a compile cannot: a missing string resource, which throws at render because the generated +`Res.string.*` accessors compile regardless, and a composable that draws nothing. Mutation-checked +— hardcoding the card's background and disabling the §8.2 conditional each kill exactly one test. +The first attempt at the theme test sampled only the page background and let the hardcoded card +through, which is why it now samples inside the card too. + Still open: -- **The UI.** `GroupExposure` exists so that the §8 requirement ("surfaced, not buried") has - something to render, but nothing renders it yet. That is the remaining Stage 4 work, along - with cordn chatroom/feed models beside `model/marmotGroups/` and a ViewModel. +- **Group UI.** Chatroom/feed models beside `model/marmotGroups/`, a ViewModel, and the badge + wired into a real group list — all of which need groups to exist on a device first. - **Coordinator-driving `amy` verbs** (`publish`, `invite`, `send`, `sync`). Deliberately not shipped: with Tier B blocked (§7) there is nothing to exercise them against, and unexercised coordinator verbs are a guess with a command-line interface. The logic they would call is in