feat(cordn): render the §8 disclosure — exposure card, health row, badge, link screen

§8 of the interop plan ends with a requirement: the metadata-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." GroupExposure existed so there
would be something to render; this renders it.

The disclosure sits where it can still change a decision — on a pasted cordn1…
link, before joining, rather than in a settings page nobody opens. The screen
reads the link, names the coordinator and its relays, and shows the exposure. It
deliberately does not join: joining needs a live coordinator and Tier B is
blocked on licensing (§7), so a join button would be half a feature whose other
half has never been run.

- commonsUI/.../cordn/ui/: CordnExposureCard, CoordinatorHealthRow,
  CordnGroupBadge. Material3 only, so Desktop gets them for free.
- commons/.../CordnLinkInspection: parse-and-disclose, headless and tested, so
  the screen only draws. A ref naming no coordinator is valid but not
  followable, and carries no exposure — inventing a threat model for a server
  we cannot identify would be worse than saying nothing.
- Settings → Cordn group link, with a route and search keywords.

Rendering it found two bugs compiling could not:

- 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" read as more alarming than "tied to your real account".
  Emphasis for the worst case is now weight rather than another hue. Nothing
  uses `error`: everything on the card is how cordn works, not a fault, and
  painting normal operation red teaches people to ignore red.
- Note order is part of the disclosure. Cross-group linkage (§8.2), the item
  nobody predicts, sat below message padding, the least consequential one.
  notes() now returns most-surprising-first.

CordnExposureRenderTest rasterises the surface headlessly (ImageComposeScene,
software Skia, no display, no new dependency — the same Compose Desktop
artifact jvmMain already uses) and asserts on pixels. It catches what 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 background and disabling the §8.2
conditional each kill exactly one test. The first version of the theme test
sampled only the page background and let the hardcoded card through, so it now
samples inside the card as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
This commit is contained in:
Claude
2026-09-19 02:05:08 +00:00
parent 8308d57f89
commit bb239c51ed
15 changed files with 1188 additions and 7 deletions
@@ -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<Route.CordnLink> { CordnLinkScreen(nav) }
composableFromEnd<Route.EditFavoriteAlgoFeeds> { FavoriteAlgoFeedsListScreen(accountViewModel, nav) }
composableFromEnd<Route.EditPaymentTargets> { PaymentTargetsScreen(accountViewModel, nav) }
composableFromEnd<Route.EditBolt12Offers> { Bolt12OffersScreen(accountViewModel, nav) }
@@ -543,6 +543,8 @@ sealed class Route {
@Serializable object EditNestsServers : Route()
@Serializable object CordnLink : Route()
@Serializable
data class AgentConsole(
val relayUrl: String,
@@ -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),
),
)
@@ -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<CordnLinkInspection?>(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)
}
}
}
+2
View File
@@ -2494,6 +2494,8 @@
<string name="kind_wiki">Wiki</string>
<string name="uptime">%1$d%% uptime</string>
<string name="namecoin_settings">Namecoin Settings</string>
<string name="cordn_link_title">Cordn group link</string>
<string name="cordn_link_search_keywords">cordn coordinator group link invite mls chat metadata privacy exposure</string>
<string name="namecoin_response_time">%dms</string>
<string name="event_sync_title">Relay Sync</string>
<string name="ots_explorer_settings">Bitcoin Explorer (OTS)</string>
@@ -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<ExposureNote> =
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)
}
/**
@@ -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,
)
},
)
}
}
}
@@ -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<String> = 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")
}
}
}
+11
View File
@@ -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") {
@@ -2894,4 +2894,46 @@
<string name="buzz_persona_publish">Publish persona</string>
<string name="profile_card_follows_you">Follows you</string>
<string name="profile_card_bot">Bot</string>
<!-- cordn: groups delivered by an MCP coordinator rather than by relays.
The exposure strings state what the coordinator operator learns; see
spec/00.md §8 and quartz/plans/2026-09-17-cordn-interop.md. -->
<string name="cordn_badge_coordinator">Coordinator group</string>
<string name="cordn_badge_coordinator_desc">Delivered by a coordinator, not by relays</string>
<string name="cordn_exposure_title">What this coordinator can see</string>
<string name="cordn_exposure_subtitle">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.</string>
<string name="cordn_exposure_dimension_content">Message contents</string>
<string name="cordn_exposure_dimension_membership">Who is in the group</string>
<string name="cordn_exposure_dimension_messaging">Who sends what</string>
<string name="cordn_exposure_level_none">Unreadable</string>
<string name="cordn_exposure_level_pseudonymous">Under a throwaway key</string>
<string name="cordn_exposure_level_identified">Tied to your real account</string>
<string name="cordn_exposure_details">Details</string>
<string name="cordn_exposure_coordinator">Coordinator %1$s</string>
<string name="cordn_exposure_differs">This is not the same exposure as a Marmot group, where no single operator holds the whole conversation.</string>
<string name="cordn_note_membership">Joining names real accounts at both ends, so the coordinator knows who is in this group.</string>
<string name="cordn_note_linked">One throwaway key sends and fetches for every group you have here, so the coordinator can tell they are all you.</string>
<string name="cordn_note_history">One operator holds the complete, ordered history of every group it carries.</string>
<string name="cordn_note_publication">The key package you published is a signed record that this account uses cordn, and the coordinator can hand it to anyone.</string>
<string name="cordn_note_sizes">Messages are not padded, so the coordinator sees how long each one is.</string>
<string name="cordn_note_encryption_bug">This client is not encrypting to the coordinator. That is a bug — please report it.</string>
<string name="cordn_health_unknown">Not contacted yet</string>
<string name="cordn_health_ok">Responding</string>
<string name="cordn_health_down">Not responding</string>
<string name="cordn_health_failures">%1$d failed attempts in a row</string>
<string name="cordn_link_explainer">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.</string>
<string name="cordn_link_field">cordn1…</string>
<string name="cordn_link_inspect">Inspect</string>
<string name="cordn_link_paste">Paste</string>
<string name="cordn_link_clear">Clear</string>
<string name="cordn_link_invalid">That is not a cordn group link: %1$s</string>
<string name="cordn_link_group_id">Group id</string>
<string name="cordn_link_coordinator">Coordinator</string>
<string name="cordn_link_relays">Reachable through</string>
<string name="cordn_link_no_coordinator">This link names no coordinator, so it cannot be followed on its own. Ask whoever sent it which coordinator carries the group.</string>
<string name="cordn_link_not_joinable">Inspecting a link does not join anything, and holding one does not make you a member.</string>
</resources>
@@ -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,
)
}
}
}
@@ -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
},
)
@@ -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,
)
}
}
}
@@ -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<Int>()
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)}",
)
}
}
+35 -4
View File
@@ -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