Merge pull request #4165 from vitorpamplona/claude/marmot-group-screen-8r2grn

Marmot: warn when invites land on another device
This commit is contained in:
Vitor Pamplona
2026-09-22 15:16:39 -04:00
committed by GitHub
16 changed files with 809 additions and 90 deletions
@@ -31,11 +31,46 @@ import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.coroutines.cancellation.CancellationException
/**
* How long a [LatestKeyPackageOwner.NONE] answer may be reused.
*
* Much shorter than a definite answer's life, because NONE is ambiguous:
* `fetchAll` returns an empty list for a device that reached no relay at all
* rather than throwing, so "nobody has published one" and "we are offline"
* arrive identically. Long enough to stop repeated entries from re-fanning the
* query, short enough that the banner is not suppressed for a quarter of an
* hour after the network comes back.
*/
private const val NONE_MAX_AGE_SECONDS = 60L
/**
* Which install owns the newest KeyPackage currently on the account's relays.
*
* An inviter picks the highest `created_at` kind:30443 and nothing else
* ([KeyPackageFetcher.fetchKeyPackage]), and only the install holding that
* bundle's private keys can open the Welcome it produces. With the same
* account signed in twice, the two installs publish under different random
* d-tag slots, so both KeyPackages persist and the most recent publisher
* silently owns every future invite.
*/
enum class LatestKeyPackageOwner {
/** This install holds the bundle — invites land here. */
THIS_DEVICE,
/** A newer KeyPackage we have no private keys for — invites land elsewhere. */
OTHER_DEVICE,
/** Nothing published for this account, or nowhere to ask. */
NONE,
}
/**
* Marmot (MLS encrypted groups) orchestration for an [Account]: group create/
* leave/reset, member add/remove via key-package fetch, admin grant/revoke,
@@ -47,6 +82,9 @@ import kotlin.coroutines.cancellation.CancellationException
class AccountMarmotActions(
private val account: Account,
) {
/** Last (timestamp, answer) from [latestKeyPackageOwner], for passive callers. */
private var lastOwnerCheck: Pair<Long, LatestKeyPackageOwner>? = null
/**
* Resolve the relay set for a Marmot group. Prefer the relays carried in
* the MLS GroupContext metadata so every member converges on the same
@@ -330,6 +368,10 @@ class AccountMarmotActions(
}
account.client.publish(event, relays)
}
// A rotation we just published makes this device the newest owner.
// Leaving the old answer in place would keep the banner warning for
// up to its full life about a state that no longer exists.
lastOwnerCheck = null
}
}
@@ -350,6 +392,9 @@ class AccountMarmotActions(
}
account.cache.justConsumeMyOwnEvent(event)
account.client.publish(event, relays)
// Same as the rotation path: we have just changed who owns the newest
// KeyPackage, so any cached answer is stale by construction.
lastOwnerCheck = null
}
/**
@@ -400,6 +445,98 @@ class AccountMarmotActions(
return manager.hasActiveKeyPackages()
}
/**
* Ask the relays which install currently owns this account's invites.
*
* Deliberately a read, never a self-correcting one. Republishing whenever
* the answer is [LatestKeyPackageOwner.OTHER_DEVICE] would deadlock two
* installs against each other — each device's correction is the other's
* trigger, and neither ever settles — so the decision belongs to the user,
* with this as the evidence.
*
* Queries the same set we publish our own KeyPackages to, which is the
* inviter's view of us minus their own outbox: `fetchRelaysFor` unions our
* NIP-65 write set and our legacy kind:10051 with the inviter's outbox, and
* the first two are exactly [keyPackagePublishRelays].
*/
suspend fun latestKeyPackageOwner(maxAgeSeconds: Long = 0L): LatestKeyPackageOwner {
val manager = account.marmotManager ?: return LatestKeyPackageOwner.NONE
val relays = keyPackagePublishRelays()
if (relays.isEmpty()) return LatestKeyPackageOwner.NONE
// A passive caller (the groups-screen banner) may reuse a recent answer.
// Without this, every entry to that screen fanned a REQ out across the
// whole write set — and the thing it asks about only changes when
// another device publishes, which is rare enough to cache.
val cached = lastOwnerCheck
if (maxAgeSeconds > 0 && cached != null) {
val maxAge =
if (cached.second == LatestKeyPackageOwner.NONE) {
minOf(maxAgeSeconds, NONE_MAX_AGE_SECONDS)
} else {
maxAgeSeconds
}
if (TimeUtils.now() - cached.first <= maxAge) return cached.second
}
val latest = KeyPackageFetcher.fetchKeyPackage(account.client, account.signer.pubKey, relays)
val owner =
when {
latest == null -> LatestKeyPackageOwner.NONE
manager.ownsKeyPackage(latest) -> LatestKeyPackageOwner.THIS_DEVICE
else -> LatestKeyPackageOwner.OTHER_DEVICE
}
Log.d("MarmotDbg") {
"latestKeyPackageOwner: newest KeyPackage id=${latest?.id?.take(8)}… " +
"createdAt=${latest?.createdAt} owner=$owner"
}
// Cached even when nothing was found: that answer cost the same fan-out
// as any other, so leaving it uncached would re-run the whole query on
// every entry for exactly the accounts with nothing on their relays.
lastOwnerCheck = TimeUtils.now() to owner
return owner
}
/**
* The user-initiated republish behind the invite-device banner and the
* settings row. Returns whether a relay actually accepted the KeyPackage.
*
* Deliberately not [publishMarmotKeyPackage]. That one is the best-effort
* startup path: it early-returns in silence for a read-only account or an
* empty relay set and then hands the event to a fire-and-forget
* `client.publish`, so a caller reporting the outcome to someone watching
* would call every one of those failures a success.
*/
suspend fun republishKeyPackageConfirmed(): Boolean {
val manager = account.marmotManager ?: return false
if (!account.isWriteable()) return false
val relays = keyPackagePublishRelays()
if (relays.isEmpty()) return false
// Minting no longer destroys the displaced bundle — the rotation
// manager retains it, keyed by the event id it was published as — but
// every regeneration still costs a keypair, a relay round trip, and a
// slot in the bounded retention map, where it can evict a bundle
// someone is about to invite us through. Republishing when we already
// own the newest KeyPackage buys none of that back, since the answer
// cannot change, so that case is a no-op reporting success truthfully.
if (latestKeyPackageOwner() == LatestKeyPackageOwner.THIS_DEVICE) {
Log.d("MarmotDbg") { "republishKeyPackageConfirmed: already the newest; not minting" }
return true
}
val event = manager.generateKeyPackageEvent(relays.toList())
account.cache.justConsumeMyOwnEvent(event)
val accepted = account.client.publishAndConfirm(event, relays)
Log.d("MarmotDbg") {
"republishKeyPackageConfirmed: id=${event.id.take(8)}… accepted=$accepted on ${relays.size} relay(s)"
}
// The answer we just changed; a stale cache would keep the banner up.
lastOwnerCheck = if (accepted) TimeUtils.now() to LatestKeyPackageOwner.THIS_DEVICE else null
return accepted
}
/**
* Create a new Marmot MLS group under the CURRENT profile.
*
@@ -73,6 +73,7 @@ enum class NavBarItem {
PUBLIC_CHATS,
RELAY_GROUPS,
CONCORD,
MARMOT_GROUPS,
GEOHASH_CHATS,
FOLLOW_PACKS,
LIVE_STREAMS,
@@ -387,6 +388,22 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Group,
resolveRoute = { Route.Concords },
),
NavBarItem.MARMOT_GROUPS to
NavBarItemDef(
id = NavBarItem.MARMOT_GROUPS,
// Duplicates the already-translated `marmot_groups_title` in
// commonsUI's composeResources, which the screen's own title uses.
// Unavoidable here: `labelRes` is an Android @StringRes and every
// other catalog entry is one, so a Compose resource cannot be
// referenced without changing the type for all ~60 of them. Crowdin
// manages both resource sets, so this one gets translated too.
labelRes = R.string.marmot_groups_title,
// Lock, not Group: the rooms list already labels a Marmot room
// with this symbol, so it is the signifier users have learned
// for these, and it keeps the row distinct from Concord's.
icon = MaterialSymbols.Lock,
resolveRoute = { Route.MarmotGroupList },
),
NavBarItem.GEOHASH_CHATS to
NavBarItemDef(
id = NavBarItem.GEOHASH_CHATS,
@@ -522,6 +539,7 @@ val BottomBarCategories: List<NavBarCategory> =
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.CONCORD,
NavBarItem.MARMOT_GROUPS,
NavBarItem.GEOHASH_CHATS,
),
),
@@ -144,6 +144,7 @@ private val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.CONCORD,
NavBarItem.MARMOT_GROUPS,
NavBarItem.GEOHASH_CHATS,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
@@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.commons.ui.state.GenericBaseCacheAsync
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LatestKeyPackageOwner
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
@@ -2510,6 +2511,20 @@ class AccountViewModel(
suspend fun hasPublishedKeyPackage(): Boolean = account.marmot.hasPublishedKeyPackage()
/**
* Which install currently owns this account's Marmot invites. See [LatestKeyPackageOwner].
*
* [maxAgeSeconds] lets a passive caller reuse a recent answer instead of
* fanning a REQ across the write set; 0 always asks the relays.
*/
suspend fun latestKeyPackageOwner(maxAgeSeconds: Long = 0L): LatestKeyPackageOwner = account.marmot.latestKeyPackageOwner(maxAgeSeconds)
/** Republishes this device's KeyPackage; true only when a relay accepted it. */
suspend fun republishKeyPackage(): Boolean = account.marmot.republishKeyPackageConfirmed()
/** False for a read-only (pubkey-only) login, which cannot publish at all. */
fun canPublish(): Boolean = account.isWriteable()
/**
* Whether this account has a kind:10051 KeyPackage Relay List (MIP-00)
* advertising where it publishes KeyPackages.
@@ -154,6 +154,12 @@ private fun PreloadFor(
NavBarItem.CONCORD -> ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
// The Marmot list renders from local state alone — marmotGroupList.rooms plus
// marmotManager.activeGroupIds() — and MarmotManager already owns a per-group
// subscription for every group it restores or joins. There is no list-level REQ
// to warm up, and adding one would duplicate those.
NavBarItem.MARMOT_GROUPS -> Unit
NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel)
NavBarItem.LIVE_STREAMS -> LiveStreamsFilterAssemblerSubscription(accountViewModel)
@@ -0,0 +1,82 @@
/*
* 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.chats
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
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.draw.clip
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/** Counts above this render as "N+" so a very busy room doesn't blow out the row. */
const val CHAT_UNREAD_CAP = 99
/**
* The unread pill every chat list shares: Concord channels, Marmot groups.
*
* Sizing is `sizeIn` + padding rather than a fixed `size`, so "99+" widens the
* pill instead of being clipped by it, and the label rides `labelSmall` rather
* than a hardcoded sp so it still tracks the reader's font scale.
*
* [contentDescription] is the caller's, because the plural that reads well out
* loud is per-surface ("3 unread messages" vs "3 unread invites"); the pill
* itself is the same object everywhere.
*/
@Composable
fun ChatUnreadBadge(
count: Int,
contentDescription: String,
modifier: Modifier = Modifier,
) {
if (count <= 0) return
val label = if (count > CHAT_UNREAD_CAP) "$CHAT_UNREAD_CAP+" else count.toString()
Box(
modifier =
modifier
.semantics { this.contentDescription = contentDescription }
// Smoothly grows/shrinks as the count changes digits (1 → 2 → … → 99+) instead of
// snapping — a small touch that makes new activity feel noticed.
.animateContentSize()
.sizeIn(minWidth = 20.dp, minHeight = 20.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.padding(horizontal = 6.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
@@ -27,6 +27,8 @@ import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -166,6 +168,11 @@ fun CreateGroupScreen(
.padding(padding)
.consumeWindowInsets(padding)
.imePaddingSafe()
// The form is taller than the window once the IME is up, so
// `imePaddingSafe` alone just clips the retention picker and
// the footer off the bottom. Scrolling is what makes them
// reachable while the keyboard covers half the screen.
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
) {
Text(
@@ -219,7 +226,7 @@ fun CreateGroupScreen(
Text(
stringRes(Res.string.marmot_create_group_footer),
modifier = Modifier.padding(top = 12.dp),
modifier = Modifier.padding(top = 12.dp, bottom = 16.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -27,6 +27,8 @@ import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
@@ -157,6 +159,11 @@ fun EditGroupInfoScreen(
.padding(padding)
.consumeWindowInsets(padding)
.imePaddingSafe()
// Same reason as CreateGroupScreen: name + description +
// avatar-url + footer is taller than what is left once the
// IME is up, so the padding alone would clip the lower
// fields with no way to reach them.
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
) {
Spacer(modifier = Modifier.height(8.dp))
@@ -241,6 +248,7 @@ fun EditGroupInfoScreen(
text = stringRes(Res.string.marmot_edit_info_footer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp),
)
}
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -29,10 +28,10 @@ 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.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
@@ -52,13 +51,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -66,6 +63,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.marmot_create_group
import com.vitorpamplona.amethyst.commons.resources.marmot_empty_create_action
import com.vitorpamplona.amethyst.commons.resources.marmot_group_fallback_name
import com.vitorpamplona.amethyst.commons.resources.marmot_groups_title
import com.vitorpamplona.amethyst.commons.resources.marmot_no_groups
@@ -73,17 +71,29 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_no_groups_desc
import com.vitorpamplona.amethyst.commons.resources.marmot_no_invitations
import com.vitorpamplona.amethyst.commons.resources.marmot_no_invitations_desc
import com.vitorpamplona.amethyst.commons.resources.marmot_no_messages_yet
import com.vitorpamplona.amethyst.commons.resources.marmot_preview_group_updated
import com.vitorpamplona.amethyst.commons.resources.marmot_preview_media
import com.vitorpamplona.amethyst.commons.resources.marmot_preview_no_text
import com.vitorpamplona.amethyst.commons.resources.marmot_preview_with_sender
import com.vitorpamplona.amethyst.commons.resources.marmot_tab_known
import com.vitorpamplona.amethyst.commons.resources.marmot_tab_known_count
import com.vitorpamplona.amethyst.commons.resources.marmot_tab_new_requests
import com.vitorpamplona.amethyst.commons.resources.marmot_tab_new_requests_count
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.ChatUnreadBadge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.DisplayUserSetAsSubject
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@OptIn(ExperimentalMaterial3Api::class)
@@ -142,6 +152,11 @@ fun MarmotGroupListScreen(
},
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
// Above the tabs, because the state it reports is the reason both
// tabs can be empty: invites landing on another install look
// exactly like no invites at all.
MarmotInviteDeviceBanner(accountViewModel)
PrimaryTabRow(selectedTabIndex = selectedTab) {
Tab(
selected = selectedTab == 0,
@@ -204,13 +219,25 @@ fun MarmotGroupListScreen(
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 4.dp),
)
// Only the Known tab gets a button. There is no action
// that fills the New Requests tab — you cannot make
// someone invite you — so offering one there would be a
// dead end dressed up as a next step.
if (selectedTab == 0) {
Button(
onClick = { nav.nav(Route.CreateMarmotGroup) },
modifier = Modifier.padding(top = 20.dp),
) {
Text(stringRes(Res.string.marmot_empty_create_action))
}
}
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(visibleGroups, key = { it.first }) { (groupId, chatroom) ->
itemsIndexed(visibleGroups, key = { _, it -> it.first }) { index, (groupId, chatroom) ->
MarmotGroupListItem(
groupId = groupId,
chatroom = chatroom,
@@ -219,7 +246,11 @@ fun MarmotGroupListScreen(
nav.nav(Route.MarmotGroupChat(groupId))
},
)
HorizontalDivider()
// Between rows only: a divider under the last one draws a
// line across empty space with nothing beneath it.
if (index < visibleGroups.lastIndex) {
HorizontalDivider()
}
}
}
}
@@ -264,6 +295,54 @@ fun MarmotGroupListItem(
// to ~100 entries, so counting per recomposition is cheap.
val unread = chatroom.messages.count { (it.createdAt() ?: Long.MIN_VALUE) > lastReadTime }
// A preview has to survive the messages that carry no text: a system row
// keeps its state in tags and MIP-04 media keeps its in imeta, so both used
// to render as a blank second line under the group name.
val myPubKey = accountViewModel.account.signer.pubKey
val previewEvent = newestMessage?.event
val previewBody =
when {
newestMessage == null -> stringRes(Res.string.marmot_no_messages_yet)
previewEvent == null -> stringRes(Res.string.marmot_preview_no_text)
previewEvent.kind == MarmotAppEvent.KIND_SYSTEM -> stringRes(Res.string.marmot_preview_group_updated)
// Text first: an attachment usually carries a caption, and showing
// "Attachment" over the words the sender actually wrote would be a
// step back from the raw `content` this replaced.
previewEvent.content.isNotBlank() -> previewEvent.content
hasMip04Media(previewEvent) || hasEncryptedMediaV2(previewEvent) -> stringRes(Res.string.marmot_preview_media)
else -> stringRes(Res.string.marmot_preview_no_text)
}
// Only a genuinely known name earns the prefix: `bestName` returns null
// without metadata, and "a1b2c3d4: hi" is noise, not attribution.
//
// Read through `observeUserInfo`, not `metadataOrNull()`: the latter is a
// plain StateFlow.value read, so a kind:0 arriving after the row composed
// would never reach it.
//
// Deliberately `getUserIfExists` rather than the `LoadUser` idiom: this only
// subscribes for a sender the cache already knows, and a sender it does not
// simply goes unprefixed. Creating a User per unknown sender would put a
// metadata REQ behind every row of a list that is mostly strangers' names
// the reader never asked for — a preview line is not worth that.
val senderUser =
previewEvent
?.pubKey
?.takeIf { it != myPubKey }
?.let { accountViewModel.getUserIfExists(it) }
val senderName =
if (senderUser != null) {
observeUserInfo(senderUser, accountViewModel).value?.info?.bestName()
} else {
null
}
val previewText =
// A system caption already names its actor, so prefixing one would say it twice.
if (senderName != null && previewEvent?.kind != MarmotAppEvent.KIND_SYSTEM) {
stringRes(Res.string.marmot_preview_with_sender, senderName, previewBody)
} else {
previewBody
}
Row(
modifier =
Modifier
@@ -276,7 +355,7 @@ fun MarmotGroupListItem(
if (memberPubkeys.isNotEmpty()) {
NonClickableUserPictures(
userHexList = memberPubkeys,
size = 55.dp,
size = Size55dp,
accountViewModel = accountViewModel,
)
}
@@ -304,47 +383,36 @@ fun MarmotGroupListItem(
overflow = TextOverflow.Ellipsis,
)
}
if (newestMessage != null) {
Text(
text = newestMessage.event?.content ?: "",
style = MaterialTheme.typography.bodySmall,
Text(
text = previewText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
}
// The list is sorted by newestMessage.createdAt(), so showing that time
// is what makes the ordering legible; the message count it replaced was
// a number no reader was asking for.
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
newestMessage?.createdAt()?.let { createdAt ->
ToggleableTimeAgoText(
timestamp = createdAt,
style = TimeAgoStyle.Short,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
} else {
Text(
text = stringRes(Res.string.marmot_no_messages_yet),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
// The row is the tap target. A toggleable timestamp would
// swallow taps meant to open the group.
toggleable = false,
)
}
}
Column(horizontalAlignment = Alignment.End) {
if (unread > 0) {
Box(
modifier =
Modifier
.size(22.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Text(
text = if (unread > 99) "99+" else unread.toString(),
color = MaterialTheme.colorScheme.onPrimary,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
}
} else {
Text(
text = pluralStringResource(R.plurals.marmot_message_count, chatroom.messages.size, chatroom.messages.size),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
ChatUnreadBadge(
count = unread,
contentDescription = pluralStringResource(R.plurals.marmot_unread_messages, unread, unread),
)
}
}
@@ -0,0 +1,210 @@
/*
* 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.chats.marmotGroup
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
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.marmot_invite_device_banner
import com.vitorpamplona.amethyst.commons.resources.marmot_invite_device_banner_dismiss
import com.vitorpamplona.amethyst.commons.resources.marmot_invite_device_publish
import com.vitorpamplona.amethyst.model.LatestKeyPackageOwner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
/**
* How long a passive owner check may reuse its previous answer. What it reports
* only changes when another device publishes, which no user does often.
*/
private const val OWNER_CHECK_MAX_AGE_SECONDS = 15L * 60L
/**
* Warns, on the Marmot groups screen, that another install of this account is
* the one receiving its invites.
*
* This is the symptom's own screen. A user whose invites are landing on a
* second device sees an empty group list and no new requests — exactly what
* "nobody has invited me" looks like — so without a banner here the failure is
* indistinguishable from nothing happening, and the diagnosis sits behind a
* settings row nobody has a reason to open.
*
* Publishing stays a tap, never automatic: an inviter takes the newest
* KeyPackage, so a banner that republished itself on sight would deadlock the
* two installs against each other, each device's correction being the other's
* trigger.
*/
@Composable
fun MarmotInviteDeviceBanner(
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
) {
var owner by remember { mutableStateOf<LatestKeyPackageOwner?>(null) }
// Saveable: a rotation is not a decision to un-dismiss a warning the user
// has already read and waved away.
var dismissed by rememberSaveable { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
// A read-only login cannot publish at all, so the only action this banner
// offers is impossible for it. Better to say nothing than to offer a button
// that silently does nothing.
val canPublish = accountViewModel.canPublish()
LaunchedEffect(Unit) {
if (!canPublish) return@LaunchedEffect
try {
owner =
withContext(Dispatchers.IO) {
// Passive check: reuse a recent answer rather than fanning a
// REQ across the whole write set on every entry to this screen.
accountViewModel.latestKeyPackageOwner(maxAgeSeconds = OWNER_CHECK_MAX_AGE_SECONDS)
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// A relay we cannot reach is not evidence of anything, so it stays
// silent rather than accusing a device that may well be this one.
owner = null
}
}
AnimatedVisibility(visible = owner == LatestKeyPackageOwner.OTHER_DEVICE && !dismissed) {
// Message on its own line, actions under it. All three in one Row fit
// only on a wide screen with default font scale; at phone width with
// large fonts the label and the button fought for the same space.
//
// `surfaceVariant`, not `errorContainer`: this is an explanation, and
// most users will never see it. The error palette would make it the
// loudest thing on a screen whose actual subject is the group list.
Column(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 4.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(start = 10.dp, end = 4.dp, top = 8.dp, bottom = 4.dp),
) {
Row(verticalAlignment = Alignment.Top) {
Icon(
symbol = MaterialSymbols.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
Text(
text = stringRes(Res.string.marmot_invite_device_banner),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp, end = 4.dp).weight(1f),
)
IconButton(onClick = { dismissed = true }, modifier = Modifier.size(24.dp)) {
Icon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(Res.string.marmot_invite_device_banner_dismiss),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(
onClick = {
// Hidden optimistically, but only *stays* hidden once a
// relay has accepted: `republishKeyPackage` waits for the
// OK, so a rejection, a read-only account or an empty relay
// set come back false instead of being reported as the
// success they are not.
owner = LatestKeyPackageOwner.THIS_DEVICE
scope.launch(Dispatchers.IO) {
val successMessage = stringRes(context, R.string.marmot_invite_device_success)
val rejectedMessage = stringRes(context, R.string.marmot_invite_device_rejected)
try {
val accepted = accountViewModel.republishKeyPackage()
launch(Dispatchers.Main) {
if (accepted) {
Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, rejectedMessage, Toast.LENGTH_LONG).show()
owner = LatestKeyPackageOwner.OTHER_DEVICE
}
}
} catch (e: CancellationException) {
// Leaving the screen mid-publish is not a failure to
// report, and swallowing it here would break the
// scope's cancellation as well as pop a toast for a
// banner that is already gone.
throw e
} catch (e: Exception) {
val failureMessage =
stringRes(context, R.string.marmot_invite_device_failure, e.message ?: "")
launch(Dispatchers.Main) {
Toast.makeText(context, failureMessage, Toast.LENGTH_LONG).show()
// The warning was right after all, so put it back.
owner = LatestKeyPackageOwner.OTHER_DEVICE
}
}
}
},
) {
Text(
text = stringRes(Res.string.marmot_invite_device_publish),
style = MaterialTheme.typography.labelMedium,
)
}
}
}
}
}
@@ -20,32 +20,21 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
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.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
/** Counts above this render as "N+" so a very busy channel doesn't blow out the row. */
private const val CONCORD_UNREAD_CAP = 99
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.CHAT_UNREAD_CAP
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.ChatUnreadBadge
/**
* A small pill showing an unread-message [count] (new messages since this account last read).
* Renders nothing when [count] is zero, so callers can place it unconditionally. Capped at
* [CONCORD_UNREAD_CAP]+ and carries a plural content description for screen readers.
* [CHAT_UNREAD_CAP]+ and carries a plural content description for screen readers.
*
* The pill itself is [ChatUnreadBadge], shared with the other chat lists; this
* wrapper only supplies Concord's own plural so the two surfaces cannot drift
* apart visually.
*/
@Composable
fun ConcordUnreadBadge(
@@ -53,26 +42,9 @@ fun ConcordUnreadBadge(
modifier: Modifier = Modifier,
) {
if (count <= 0) return
val label = if (count > CONCORD_UNREAD_CAP) "$CONCORD_UNREAD_CAP+" else count.toString()
val description = pluralStringResource(R.plurals.concord_unread_messages, count, count)
Box(
modifier =
modifier
.semantics { contentDescription = description }
// Smoothly grows/shrinks as the count changes digits (1 → 2 → … → 99+) instead of
// snapping — a small touch that makes new activity feel noticed.
.animateContentSize()
.sizeIn(minWidth = 20.dp, minHeight = 20.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.padding(horizontal = 6.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimary,
)
}
ChatUnreadBadge(
count = count,
contentDescription = pluralStringResource(R.plurals.concord_unread_messages, count, count),
modifier = modifier,
)
}
+8
View File
@@ -225,6 +225,7 @@
<string name="public_chat">Public Chat</string>
<string name="marmot_group">Marmot Group</string>
<string name="marmot_groups_title">Marmot Groups</string>
<string name="public_chat_title">Public Chat Metadata</string>
<string name="public_chat_explainer">Public chats are visible to everyone on Nostr and anyone
can participate on them. They are great for open communities around specific topics.
@@ -1353,6 +1354,9 @@
<string name="child_safety_search_keywords" translatable="false">legal, safety, abuse, csae, child protection</string>
<string name="danger_zone">Danger Zone</string>
<string name="marmot_invite_device_success">This device now receives your Marmot invites.</string>
<string name="marmot_invite_device_failure">Failed to publish KeyPackage: %1$s</string>
<string name="marmot_invite_device_rejected">No relay accepted the KeyPackage. Check your relay settings and try again.</string>
<string name="reset_marmot_state">Reset Marmot State</string>
<string name="reset_marmot_success">Marmot state reset.</string>
<string name="reset_marmot_failure">Failed to reset Marmot state: %1$s</string>
@@ -2710,6 +2714,10 @@
<string name="nowhere_link_card_forum">Nowhere Forum</string>
<!-- Marmot (MLS) group chats -->
<plurals name="marmot_unread_messages">
<item quantity="one">%1$d new message</item>
<item quantity="other">%1$d new messages</item>
</plurals>
<plurals name="marmot_message_count">
<item quantity="one">%1$d msg</item>
<item quantity="other">%1$d msgs</item>
@@ -2443,6 +2443,33 @@ class MarmotManager(
*/
suspend fun hasActiveKeyPackages(): Boolean = keyPackageRotationManager.hasActiveKeyPackages()
/**
* True when this install holds the private bundle for [event], a published
* kind:30443 — i.e. whether an invite issued against it could be opened here.
*
* Two installs of the same account each mint their own random d-tag slot
* ([KeyPackageRotationManager.getOrCreateSlotDTag]), so their KeyPackages
* never replace one another and both sit on relays indefinitely. An
* inviter then takes whichever has the highest `created_at`, and only the
* install holding that bundle can open the resulting Welcome.
*
* Matched on the MLS KeyPackage reference, which is a hash over the
* KeyPackage itself, and only on the Nostr event id when an event carries
* no ref tag. The id route alone is not safe to answer this question:
* [KeyPackageRotationManager.findBundleByEventId] resolves an id to a
* *slot* and then returns whatever bundle occupies that slot now, so once
* a slot has been regenerated it reports a stale id as still ours and we
* would hide a warning about invites we can no longer open. The ref cannot
* alias that way, and it is what the Welcome path matches on first.
*/
suspend fun ownsKeyPackage(event: KeyPackageEvent): Boolean {
val refHex = event.keyPackageRef()
if (refHex != null) {
return keyPackageRotationManager.findBundleByRef(refHex.hexToByteArray()) != null
}
return keyPackageRotationManager.findBundleByEventId(event.id) != null
}
/**
* Check if a specific group membership exists.
*/
@@ -1623,6 +1623,14 @@
<string name="user_preferences">User Preferences</string>
<string name="settings_search_placeholder">Search settings</string>
<string name="settings_search_no_results">No settings found for "%1$s"</string>
<string name="marmot_preview_group_updated">Group updated</string>
<string name="marmot_preview_media">Attachment</string>
<string name="marmot_preview_no_text">Message</string>
<string name="marmot_preview_with_sender">%1$s: %2$s</string>
<string name="marmot_empty_create_action">Create a group</string>
<string name="marmot_invite_device_banner">Marmot invites are going to another device and cannot be opened here.</string>
<string name="marmot_invite_device_banner_dismiss">Dismiss</string>
<string name="marmot_invite_device_publish">Publish from this device</string>
<string name="reset_marmot_confirm_title">Reset Marmot State?</string>
<string name="reset_marmot_confirm_body">This will permanently delete every Marmot group chat, message history, and MLS key on this device for the current account. Peers will not be notified and may still see you in groups until their next commit. This cannot be undone. A new KeyPackage will be published the next time the app syncs.</string>
<string name="reset_marmot_confirm_action">Reset</string>
@@ -91,9 +91,17 @@ class KeyPackageRotationManager(
private val eventIdToSlot = mutableMapOf<String, String>()
/**
* Consumed KeyPackages we deliberately keep the private keys for, keyed by
* Published KeyPackages we deliberately keep the private keys for, keyed by
* the Nostr event id (kind:30443) they were published as.
*
* Two things land here. A KeyPackage a Welcome consumed, per the
* last-resort reasoning below; and a KeyPackage a slot regenerated out from
* under, which is retained for a plainer reason: regenerating replaces
* `activeBundles[slot]` but does nothing to the kind:30443 already sitting
* on relays, so dropping its keys would leave us advertising a KeyPackage
* no one can invite us through. Keeping it is what makes the advertisement
* honest.
*
* A KeyPackage carrying the LastResort marker (`0x000A`) is not single-use:
* OpenMLS skips `delete_key_package` for one, so MDK — which marks every
* KeyPackage last-resort and caches the peer KeyPackage it resolved — will
@@ -394,7 +402,7 @@ class KeyPackageRotationManager(
val bundle = KeyPackageBundle(keyPackage, initKp.privateKey, encKp.privateKey, sigKp.privateKey)
mutex.withLock {
activeBundles[dTagSlot] = bundle
installIntoSlotUnlocked(dTagSlot, bundle)
persistUnlocked()
}
return bundle
@@ -418,7 +426,7 @@ class KeyPackageRotationManager(
): KeyPackageBundle {
val bundle = CurrentProfileGroupFactory.createKeyPackage(signer, ciphersuite = ciphersuite)
mutex.withLock {
activeBundles[dTagSlot] = bundle
installIntoSlotUnlocked(dTagSlot, bundle)
persistUnlocked()
}
return bundle
@@ -578,10 +586,48 @@ class KeyPackageRotationManager(
dTagSlot: String,
bundle: KeyPackageBundle,
) = mutex.withLock {
activeBundles[dTagSlot] = bundle
installIntoSlotUnlocked(dTagSlot, bundle)
persistUnlocked()
}
/**
* Install [bundle] at [dTagSlot], retaining whatever it displaces.
*
* The displaced bundle's kind:30443 is still on relays — regenerating a
* slot publishes a NEW addressable event only once the caller sends it, and
* a send that never lands, or lands while a peer is already holding the
* older KeyPackage, leaves that older one live. So its keys move to
* [retainedBundles] rather than being dropped.
*
* Its event ids also leave [eventIdToSlot]. That index answers
* "which slot backs this id", and after this call the honest answer is
* "none" — leaving it would make [findBundleByEventId] resolve the id
* through the slot to the bundle that just *replaced* it, handing the
* Welcome path keys that cannot open the message it is holding.
*
* Caller must hold the mutex.
*/
private fun installIntoSlotUnlocked(
dTagSlot: String,
bundle: KeyPackageBundle,
) {
val displaced = activeBundles.put(dTagSlot, bundle)
if (displaced == null) return
val staleEventIds = eventIdToSlot.entries.filter { it.value == dTagSlot }.map { it.key }
for (staleEventId in staleEventIds) {
eventIdToSlot.remove(staleEventId)
// Re-inserted so the most recently displaced entry sorts last and
// survives eviction the longest, matching [consumeSlotUnlocked].
retainedBundles.remove(staleEventId)
retainedBundles[staleEventId] = displaced
}
pruneExpiredRetainedUnlocked()
while (retainedBundles.size > MAX_RETAINED_BUNDLES) {
retainedBundles.remove(retainedBundles.keys.first())
}
}
/**
* Get the d-tag slots that need rotation (KeyPackage was consumed).
*/
@@ -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.quartz.marmot.mip00KeyPackages
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertSame
import org.junit.Test
/**
* Regenerating a slot must not strand the KeyPackage already on relays.
*
* `generateKeyPackage` replaces `activeBundles[slot]`, but it does nothing to
* the kind:30443 a previous generation was published as — that event keeps
* sitting on relays, and a peer can still invite through it. Dropping the
* displaced private keys therefore left the account advertising a KeyPackage
* whose Welcomes it could not open.
*/
class RegeneratedSlotRetentionTest {
private val identity = ByteArray(32) { 0x22 }
@Test
fun aRegeneratedSlotKeepsTheKeysOfTheKeyPackageStillOnRelays() =
runBlocking {
val manager = KeyPackageRotationManager()
val slot = manager.getOrCreateSlotDTag("primary")
val first = manager.generateKeyPackage(identity, slot)
manager.recordPublishedEventId(slot, FIRST_EVENT_ID)
// The user republishes — or a consumed slot rotates — and the slot
// is minted afresh while the first KeyPackage is still published.
val second = manager.generateKeyPackage(identity, slot)
manager.recordPublishedEventId(slot, SECOND_EVENT_ID)
val recoveredFirst = manager.findBundleByEventId(FIRST_EVENT_ID)
assertNotNull("the displaced bundle must survive regeneration", recoveredFirst)
assertSame("a Welcome against the older event must get the OLDER bundle", first, recoveredFirst)
val recoveredSecond = manager.findBundleByEventId(SECOND_EVENT_ID)
assertSame("the newest event must still resolve to the active bundle", second, recoveredSecond)
}
@Test
fun aRegeneratedSlotIsAlsoRecoverableByKeyPackageReference() =
runBlocking {
val manager = KeyPackageRotationManager()
val slot = manager.getOrCreateSlotDTag("primary")
val first = manager.generateKeyPackage(identity, slot)
manager.recordPublishedEventId(slot, FIRST_EVENT_ID)
manager.generateKeyPackage(identity, slot)
// The Welcome path matches on the KeyPackage reference before it
// falls back to the Nostr event id, so retention has to hold up
// under that lookup too.
val byRef = manager.findBundleByRef(first.keyPackage.reference())
assertSame("the displaced bundle must be reachable by its own ref", first, byRef)
}
@Test
fun regeneratingDoesNotLeaveTheOldEventIdPointingAtTheNewBundle() =
runBlocking {
val manager = KeyPackageRotationManager()
val slot = manager.getOrCreateSlotDTag("primary")
val first = manager.generateKeyPackage(identity, slot)
manager.recordPublishedEventId(slot, FIRST_EVENT_ID)
val second = manager.generateKeyPackage(identity, slot)
// The regression this guards: resolving the id through the slot
// index returned whatever now occupied the slot, so the older event
// handed out keys that cannot open the Welcome addressed to it.
val recovered = manager.findBundleByEventId(FIRST_EVENT_ID)
assertEquals(
"the old event id must not resolve to the replacement bundle",
false,
recovered === second,
)
assertSame(first, recovered)
}
companion object {
private val FIRST_EVENT_ID = "aa".repeat(32)
private val SECOND_EVENT_ID = "bb".repeat(32)
}
}