Merge branch 'mantra' into claude/subgroups-multiple-parents-053be4
This commit is contained in:
commit
1a6034dadb
22 files changed
+2460
-170
No files matched your search
@@ -501,4 +501,17 @@
|
||||
<string name="accept_as_a_parent">Accept as a parent</string>
|
||||
<string name="a_group_is_a_subgroup_once_its_own_quorum_says_so">A group is a subgroup of another only once its own quorum says so. Accepting asks this group to agree what it signs with again, naming them.</string>
|
||||
<string name="asked_this_group_to_accept_that_parent">Asked this group to accept that parent</string>
|
||||
<!-- Every key ceremony a room holds, pending first: the list, and where each one has got to. -->
|
||||
<string name="key_ceremonies">Key ceremonies</string>
|
||||
<string name="this_group_has_held_no_key_ceremony">This group has not held a key ceremony.</string>
|
||||
<string name="the_groups_shared_key">The group's shared key</string>
|
||||
<string name="a_subgroups_key">A subgroup's key</string>
|
||||
<string name="a_subgroup_named_s">A subgroup: %1$s</string>
|
||||
<string name="waiting_for_members_to_join">Waiting for members to join.</string>
|
||||
<string name="waiting_for_everyone_to_commit_their_part">Waiting for everyone to commit their part.</string>
|
||||
<string name="waiting_for_everyone_to_confirm_the_result">Waiting for everyone to confirm the result.</string>
|
||||
<string name="the_group_has_a_key_from_this_ceremony">The group has a key from this ceremony.</string>
|
||||
<string name="abandoned_no_key_was_created">Abandoned. No key was created.</string>
|
||||
<string name="s_of_s_members_will_sign_with_it">%1$s of %2$s members will sign with it</string>
|
||||
<string name="set_up_a_shared_key">Set up a shared key</string>
|
||||
</resources>
|
||||
+20
-24
@@ -110,26 +110,27 @@ class DatabaseDkgRepository(
|
||||
): Flow<List<LocalFrostSigningSession>> =
|
||||
database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId)
|
||||
|
||||
/**
|
||||
* By session id, not by room. A ceremony room holds more than one ceremony
|
||||
* the moment a subgroup's admins are the whole group, and a lookup by room
|
||||
* answered about whichever the room's rule picked -- for a room holding only
|
||||
* a subgroup's ceremony, asking for the room's *own* found nothing at all,
|
||||
* and a finished subgroup with a signed key state could never be created.
|
||||
*/
|
||||
override suspend fun roomDerivedBy(dkgSessionId: String): String? =
|
||||
database.dkgSessionDao()
|
||||
.getSessionById(dkgSessionId)
|
||||
?.thresholdPublicKey
|
||||
?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() }
|
||||
|
||||
override fun observeSignedGroupKeyState(dkgSessionId: String): Flow<LocalGroupKeyState?> =
|
||||
database.groupSignedEventDao()
|
||||
.observeByKind(GroupKeyStateEvent.KIND)
|
||||
.map { signedEvents ->
|
||||
// The room this ceremony's key derives, resolved per emission
|
||||
// rather than once: the ceremony has no key until it finishes,
|
||||
// and this flow is running before it does.
|
||||
//
|
||||
// By session id, not by room. A ceremony room holds more than one
|
||||
// ceremony the moment a subgroup's admins are the whole group, and
|
||||
// a lookup by room answered about whichever the room's rule picked
|
||||
// -- for a room holding only a subgroup's ceremony, asking for the
|
||||
// room's *own* found nothing at all, and a finished subgroup with
|
||||
// a signed key state could never be created.
|
||||
val subjectRoomId = database.dkgSessionDao()
|
||||
.getSessionById(dkgSessionId)
|
||||
?.thresholdPublicKey
|
||||
?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() }
|
||||
|
||||
subjectRoomId?.let { GroupKeyStateManager.stateAmong(signedEvents, it) }
|
||||
// Resolved per emission rather than once: the ceremony has no key
|
||||
// until it finishes, and this flow is running before it does.
|
||||
roomDerivedBy(dkgSessionId)
|
||||
?.let { GroupKeyStateManager.stateAmong(signedEvents, it) }
|
||||
}
|
||||
|
||||
override suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState? = try {
|
||||
@@ -176,14 +177,9 @@ class DatabaseDkgRepository(
|
||||
database.groupSignedEventDao()
|
||||
.observeByKind(SubgroupBirthCertificateEvent.KIND)
|
||||
.map { signedEvents ->
|
||||
// The room the ceremony's key derives, resolved per emission
|
||||
// rather than once: the ceremony has no key until it finishes,
|
||||
// and this flow is running before it does.
|
||||
val subgroupChatRoomId = database.dkgSessionDao()
|
||||
.getSessionById(dkgSessionId)
|
||||
?.thresholdPublicKey
|
||||
?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() }
|
||||
?: return@map null
|
||||
// Resolved per emission rather than once: the ceremony has no key
|
||||
// until it finishes, and this flow is running before it does.
|
||||
val subgroupChatRoomId = roomDerivedBy(dkgSessionId) ?: return@map null
|
||||
|
||||
signedEvents
|
||||
.filter {
|
||||
|
||||
+33
-9
@@ -170,15 +170,7 @@ object SubgroupBirthCertificateEvent {
|
||||
subgroupChatRoomId: String,
|
||||
parentChatRoomId: String
|
||||
): Boolean = runCatching {
|
||||
if (event.kind != KIND) return false
|
||||
|
||||
if (!event.content.trim().equals(subgroupChatRoomId, ignoreCase = true)) return false
|
||||
|
||||
val addressed = parseSubgroupChatRoomId(event.tags) ?: return false
|
||||
if (!addressed.equals(subgroupChatRoomId, ignoreCase = true)) return false
|
||||
|
||||
val named = parseParentChatRoomId(event.tags) ?: return false
|
||||
if (!named.equals(parentChatRoomId, ignoreCase = true)) return false
|
||||
if (!addresses(event, subgroupChatRoomId, parentChatRoomId)) return false
|
||||
|
||||
val thresholdPublicKey = parseThresholdPublicKey(event.tags) ?: return false
|
||||
val path = parsePath(event.tags) ?: return false
|
||||
@@ -189,6 +181,38 @@ object SubgroupBirthCertificateEvent {
|
||||
GroupKeyStateEvent.isSignedByRoom(event, parentChatRoomId)
|
||||
}.getOrDefault(false)
|
||||
|
||||
/**
|
||||
* Whether this event is *addressed to* that child by that parent -- the
|
||||
* half of [certifies] that can be asked of a proposal nobody has signed.
|
||||
*
|
||||
* **Not a weaker [certifies].** It says who the event is about, not that
|
||||
* the parent's quorum agreed to it: no derivation and no signature, so an
|
||||
* event that merely claims the right pair passes. Nothing may decide a
|
||||
* lineage on this.
|
||||
*
|
||||
* What it is for is telling one *in-flight* certificate signing from
|
||||
* another in the same room. A parent certifies every subgroup it ever makes
|
||||
* from one room, so the ceremony screen has several to choose between and
|
||||
* no signature to choose by -- the proposal is what the quorum has not yet
|
||||
* signed. The three values it reads are assembled together by
|
||||
* [assembleTags], so they agree by construction.
|
||||
*/
|
||||
fun addresses(
|
||||
event: Event,
|
||||
subgroupChatRoomId: String,
|
||||
parentChatRoomId: String
|
||||
): Boolean = runCatching {
|
||||
if (event.kind != KIND) return false
|
||||
|
||||
if (!event.content.trim().equals(subgroupChatRoomId, ignoreCase = true)) return false
|
||||
|
||||
val addressed = parseSubgroupChatRoomId(event.tags) ?: return false
|
||||
if (!addressed.equals(subgroupChatRoomId, ignoreCase = true)) return false
|
||||
|
||||
val named = parseParentChatRoomId(event.tags) ?: return false
|
||||
named.equals(parentChatRoomId, ignoreCase = true)
|
||||
}.getOrDefault(false)
|
||||
|
||||
/**
|
||||
* The name tag. A bare two-element tag with no class of its own, because
|
||||
* nothing but the proposal screen and the subgroup list ever reads it and
|
||||
|
||||
@@ -65,6 +65,19 @@ interface DkgRepository {
|
||||
|
||||
suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession?
|
||||
|
||||
/**
|
||||
* The id of the room a ceremony's key derives, or null while it has no key.
|
||||
*
|
||||
* The one name a ceremony and the room it makes have in common, and the only
|
||||
* way to tell two of a parent's subgroups apart in anything written about
|
||||
* the room rather than about the ceremony -- a birth certificate names the
|
||||
* child, not the ceremony that produced it.
|
||||
*
|
||||
* Null is the ordinary answer rather than a failure: a ceremony has no key
|
||||
* until it finishes, and every caller here is watching from before that.
|
||||
*/
|
||||
suspend fun roomDerivedBy(dkgSessionId: String): String?
|
||||
|
||||
/**
|
||||
* Opens a ritual in [localChatRoom], making this device the coordinator.
|
||||
*
|
||||
@@ -213,6 +226,8 @@ interface DkgRepository {
|
||||
|
||||
override suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession? = null
|
||||
|
||||
override suspend fun roomDerivedBy(dkgSessionId: String): String? = null
|
||||
|
||||
override suspend fun proposeRitual(
|
||||
localChatRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
|
||||
+35
@@ -70,6 +70,7 @@ import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendin
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ProfilePreviewRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualListRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute
|
||||
import press.mantra.compose.extensions.toFormattedTimeAndDateString
|
||||
@@ -123,6 +124,7 @@ import mantra.composeapp.generated.resources.no_projects_exists_in_this_group
|
||||
import mantra.composeapp.generated.resources.projects
|
||||
import mantra.composeapp.generated.resources.proposals
|
||||
import mantra.composeapp.generated.resources.reindex_events
|
||||
import mantra.composeapp.generated.resources.key_ceremonies
|
||||
import mantra.composeapp.generated.resources.shared_key
|
||||
import mantra.composeapp.generated.resources.signing_key
|
||||
import mantra.composeapp.generated.resources.the_key_this_room_signs_as
|
||||
@@ -742,6 +744,39 @@ fun ChatRoomDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Every room, including the Marmot ones the `mlsGroupState`
|
||||
// check above keeps "Shared key" out of. That button offers
|
||||
// a room a key of its own, which only a NIP-17 room can
|
||||
// want; this one lists the ceremonies a room *holds*, and a
|
||||
// parent's Marmot room holds one per subgroup the group has
|
||||
// ever made. Until this there was no way to reach the second
|
||||
// of them except by scrolling the transcript to its request
|
||||
// line, and no way at all to reach one whose line had never
|
||||
// arrived.
|
||||
item {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onNavigateToRoute.invoke(
|
||||
DkgRitualListRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Key,
|
||||
contentDescription = "Key ceremonies"
|
||||
)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier.width(MaterialTheme.spacing.space125)
|
||||
)
|
||||
|
||||
Text(stringResource(Res.string.key_ceremonies))
|
||||
}
|
||||
}
|
||||
|
||||
// Every room, not only the one that holds the key. Most
|
||||
// of what a group signs it signs in its Marmot room --
|
||||
// its subgroups' certificates and key states included --
|
||||
|
||||
+31
-5
@@ -49,6 +49,7 @@ import press.mantra.compose.repository.NostrRepository
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualListRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
|
||||
@@ -176,12 +177,37 @@ fun ChatRoomMessagingScreen(
|
||||
ChatTranscript(
|
||||
viewModel = chatMessageListViewModel,
|
||||
onOpenSharedKey = { dkgSessionId ->
|
||||
// The same two reasons the signing lines open
|
||||
// the queue instead of one proposal. A line
|
||||
// written before chat rows named their ceremony
|
||||
// cannot say which it is about, and the room may
|
||||
// hold several -- the list is the honest answer
|
||||
// there, showing all of them with their own
|
||||
// state rather than guessing at the newest,
|
||||
// which since subgroups moved into the parent's
|
||||
// room is routinely the finished one.
|
||||
//
|
||||
// The other is that the ceremony they tapped is
|
||||
// not the only one waiting on them, in which
|
||||
// case it is not the whole of what is being
|
||||
// asked and a screen showing only it would say
|
||||
// it was.
|
||||
onNavigateToRoute.invoke(
|
||||
DkgRitualRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
dkgSessionId = dkgSessionId
|
||||
)
|
||||
if (dkgSessionId == null ||
|
||||
chatMessageListViewModel
|
||||
.hidesOtherCeremonies(dkgSessionId)
|
||||
) {
|
||||
DkgRitualListRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId
|
||||
)
|
||||
} else {
|
||||
DkgRitualRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
dkgSessionId = dkgSessionId
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
onOpenSigning = { sessionId ->
|
||||
|
||||
+2
-1
@@ -22,6 +22,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -100,7 +101,7 @@ internal fun DkgApprovalScaffold(
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
when (val state = dkgRitualViewModel.dkgRitualUIState) {
|
||||
when (val state = dkgRitualViewModel.dkgRitualUIState.collectAsState().value) {
|
||||
is DkgRitualUIState.Loading -> Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
|
||||
+504
@@ -0,0 +1,504 @@
|
||||
package press.mantra.compose.ui.composable
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material.icons.filled.HourglassEmpty
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import mantra.composeapp.generated.resources.Res
|
||||
import mantra.composeapp.generated.resources.a_subgroup_named_s
|
||||
import mantra.composeapp.generated.resources.a_subgroups_key
|
||||
import mantra.composeapp.generated.resources.abandoned_no_key_was_created
|
||||
import mantra.composeapp.generated.resources.check_the_combined_result
|
||||
import mantra.composeapp.generated.resources.everything_else
|
||||
import mantra.composeapp.generated.resources.join_the_ceremony_by_publishing_your_key
|
||||
import mantra.composeapp.generated.resources.key_ceremonies
|
||||
import mantra.composeapp.generated.resources.open
|
||||
import mantra.composeapp.generated.resources.review
|
||||
import mantra.composeapp.generated.resources.s_of_s_members_will_sign_with_it
|
||||
import mantra.composeapp.generated.resources.send_your_contribution_to_the_key
|
||||
import mantra.composeapp.generated.resources.set_up_a_shared_key
|
||||
import mantra.composeapp.generated.resources.the_group_has_a_key_from_this_ceremony
|
||||
import mantra.composeapp.generated.resources.the_group_has_its_key_finish_setting_it_up
|
||||
import mantra.composeapp.generated.resources.the_groups_shared_key
|
||||
import mantra.composeapp.generated.resources.this_group_has_held_no_key_ceremony
|
||||
import mantra.composeapp.generated.resources.waiting_for_everyone_to_commit_their_part
|
||||
import mantra.composeapp.generated.resources.waiting_for_everyone_to_confirm_the_result
|
||||
import mantra.composeapp.generated.resources.waiting_for_members_to_join
|
||||
import mantra.composeapp.generated.resources.waiting_for_you
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.extensions.toFormattedTimeAndDateString
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.DkgRepository
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.widgets.Decorative
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.composable.widgets.NavigateBackButton
|
||||
import press.mantra.compose.ui.theme.ConformancePreviews
|
||||
import press.mantra.compose.ui.theme.MantraTheme
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
import press.mantra.compose.ui.theme.spacing
|
||||
import press.mantra.compose.ui.view.model.DkgRitualListViewModel
|
||||
import press.mantra.compose.ui.view.state.DkgRitualListUIState
|
||||
|
||||
/**
|
||||
* Every key ceremony a room holds.
|
||||
*
|
||||
* The transcript shows a ceremony as it happens, in among everything else that
|
||||
* was said, and the standing notice above the composer says when one is waiting
|
||||
* on the reader. Neither can say *which*, once there are two -- and since a
|
||||
* subgroup's ChillDKG moved into its parent's room there are routinely two: the
|
||||
* group's own, long finished, and whatever subgroup is being made this week.
|
||||
*
|
||||
* So: one row per ceremony, each carrying its own state, the finished ones kept
|
||||
* rather than dropped. The ones still waiting on the reader come first, because
|
||||
* those are the only ones with anything to do in them; the rest are the record
|
||||
* of how this group came to hold the keys it holds, which is the other half of
|
||||
* what this screen is for.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DkgRitualListScreen(
|
||||
activeUserPublicKey: HexKey,
|
||||
chatRoomId: String,
|
||||
initialDkgRitualListUIState: DkgRitualListUIState = DkgRitualListUIState.Loading,
|
||||
chatRepository: ChatRepository,
|
||||
dkgRepository: DkgRepository,
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToRoute: (Route) -> Unit,
|
||||
) {
|
||||
val dkgRitualListViewModel: DkgRitualListViewModel = viewModel(
|
||||
factory = DkgRitualListViewModel.factory(
|
||||
chatRoomId = chatRoomId,
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
initialDkgRitualListUIState = initialDkgRitualListUIState,
|
||||
chatRepository = chatRepository,
|
||||
dkgRepository = dkgRepository
|
||||
)
|
||||
)
|
||||
|
||||
LaunchedEffect(true) {
|
||||
if (initialDkgRitualListUIState == DkgRitualListUIState.Loading) {
|
||||
dkgRitualListViewModel.initiate()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(Res.string.key_ceremonies),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
navigationIcon = { NavigateBackButton(onNavigateBack) }
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
when (val state = dkgRitualListViewModel.dkgRitualListUIState.collectAsState().value) {
|
||||
is DkgRitualListUIState.Loading -> Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(padding).readableContent()
|
||||
.padding(MaterialTheme.spacing.space250),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
is DkgRitualListUIState.Error -> Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(padding).readableContent()
|
||||
.padding(MaterialTheme.spacing.space250),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
|
||||
Text(text = state.message, textAlign = TextAlign.Center)
|
||||
}
|
||||
|
||||
is DkgRitualListUIState.Loaded -> {
|
||||
val open: (DkgRitualListUIState.Ceremony) -> Unit = { ceremony ->
|
||||
onNavigateToRoute.invoke(
|
||||
DkgRitualRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
dkgSessionId = ceremony.session.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(padding).readableContent().fillMaxSize()
|
||||
.padding(MaterialTheme.spacing.space250),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
|
||||
) {
|
||||
if (state.ceremonies.isEmpty()) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(top = MaterialTheme.spacing.space600),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(
|
||||
MaterialTheme.spacing.sectionGap
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
Res.string.this_group_has_held_no_key_ceremony
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
// The way out of an empty list, rather than a
|
||||
// dead end that sends a member back to the
|
||||
// group's details to find the other door. The
|
||||
// ceremony screen decides whether the group is
|
||||
// in a position to hold one; this only offers
|
||||
// to ask it.
|
||||
FilledTonalButton(onClick = {
|
||||
onNavigateToRoute.invoke(
|
||||
DkgRitualRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId
|
||||
)
|
||||
)
|
||||
}) {
|
||||
Icon(Icons.Default.Key, contentDescription = Decorative)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier.width(
|
||||
MaterialTheme.spacing.relatedGap
|
||||
)
|
||||
)
|
||||
|
||||
Text(stringResource(Res.string.set_up_a_shared_key))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.waitingForYou.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(Res.string.waiting_for_you),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
items(
|
||||
items = state.waitingForYou,
|
||||
key = { it.session.id }
|
||||
) { ceremony ->
|
||||
CeremonyCard(ceremony = ceremony, onClick = { open(ceremony) })
|
||||
}
|
||||
}
|
||||
|
||||
if (state.rest.isNotEmpty()) {
|
||||
// Headed only when there is a section above to tell it
|
||||
// apart from; on its own it is the whole screen, which
|
||||
// the title already says.
|
||||
if (state.waitingForYou.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(Res.string.everything_else),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(
|
||||
items = state.rest,
|
||||
key = { it.session.id }
|
||||
) { ceremony ->
|
||||
CeremonyCard(ceremony = ceremony, onClick = { open(ceremony) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One ceremony, named after the key it is making.
|
||||
*
|
||||
* What a ceremony is *for* is the first thing to say about it, because a room
|
||||
* holding two holds them for different groups: one is this room's own key and
|
||||
* the other belongs to a subgroup that does not exist yet. A row that only said
|
||||
* how far along it was would leave a member guessing which of the two they were
|
||||
* looking at.
|
||||
*/
|
||||
@Composable
|
||||
private fun CeremonyCard(
|
||||
ceremony: DkgRitualListUIState.Ceremony,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val session = ceremony.session
|
||||
|
||||
// Every colour inside this card is derived from the card rather than reached
|
||||
// for independently, because ListItem does not inherit LocalContentColor --
|
||||
// its headline comes from `onSurface` whatever the container is. See
|
||||
// ProposalListScreen, where that combination drew #1B1B1B on #1B1B1B.
|
||||
val cardColors = if (ceremony.awaitsYou) {
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
} else {
|
||||
CardDefaults.cardColors()
|
||||
}
|
||||
val cardContentColor = cardColors.contentColor
|
||||
|
||||
// A subgroup is named by its proposal, and a member who did not open it may
|
||||
// be looking at the ceremony before that name has arrived -- hence the third
|
||||
// branch, which still says which of the two kinds of key this is.
|
||||
val subject = session.subject
|
||||
|
||||
val label = when {
|
||||
!ceremony.isSubgroup -> stringResource(Res.string.the_groups_shared_key)
|
||||
subject != null -> stringResource(Res.string.a_subgroup_named_s, subject)
|
||||
else -> stringResource(Res.string.a_subgroups_key)
|
||||
}
|
||||
|
||||
Card(onClick = onClick, colors = cardColors) {
|
||||
ListItem(
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
headlineColor = cardContentColor,
|
||||
overlineColor = cardContentColor,
|
||||
supportingColor = cardContentColor,
|
||||
leadingIconColor = cardContentColor,
|
||||
trailingIconColor = cardContentColor,
|
||||
),
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = when {
|
||||
ceremony.awaitsYou -> Icons.Default.Key
|
||||
session.stage == DkgRitualStage.COMPLETE -> Icons.Default.CheckCircle
|
||||
session.stage == DkgRitualStage.FAILED -> Icons.Default.ErrorOutline
|
||||
else -> Icons.Default.HourglassEmpty
|
||||
},
|
||||
// Carries the stage on its own, and carries it alone on the
|
||||
// highlighted card where the failure state has no colour to
|
||||
// spare.
|
||||
contentDescription = when {
|
||||
ceremony.awaitsYou -> "Waiting on you"
|
||||
session.stage == DkgRitualStage.COMPLETE -> "Finished"
|
||||
session.stage == DkgRitualStage.FAILED -> "Abandoned"
|
||||
else -> "Waiting on others"
|
||||
},
|
||||
tint = when {
|
||||
// `error` is 2.67:1 on the highlighted card, so there the
|
||||
// failure is carried by the icon's shape and by the status
|
||||
// line rather than by colour.
|
||||
session.stage == DkgRitualStage.FAILED &&
|
||||
!ceremony.awaitsYou -> MaterialTheme.colorScheme.error
|
||||
else -> cardContentColor
|
||||
}
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// The same word the transcript's notice uses for the same
|
||||
// thing, so a member who came here looking for the card they
|
||||
// tapped finds it saying what it said there.
|
||||
Text(
|
||||
text = if (ceremony.awaitsYou) {
|
||||
stringResource(Res.string.review)
|
||||
} else {
|
||||
stringResource(Res.string.open)
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = cardContentColor
|
||||
)
|
||||
|
||||
Icon(
|
||||
Icons.Default.ChevronRight,
|
||||
contentDescription = "Open this ceremony"
|
||||
)
|
||||
}
|
||||
},
|
||||
overlineContent = { Text(text = label) },
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = statusOf(ceremony),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space25)
|
||||
) {
|
||||
// The quorum a ceremony settles is fixed by it and cannot be
|
||||
// changed afterwards, so it belongs on the row rather than
|
||||
// only on the screen behind it.
|
||||
Text(
|
||||
text = stringResource(
|
||||
Res.string.s_of_s_members_will_sign_with_it,
|
||||
session.threshold,
|
||||
session.participantCount
|
||||
)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "${ceremony.coordinatorName} · " +
|
||||
session.createdAt.toFormattedTimeAndDateString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a ceremony has got to, in a line.
|
||||
*
|
||||
* Shorter than the ceremony screen's ladder, and it has to be: this is one row
|
||||
* of a list somebody is scanning, not the page they are acting on. What it must
|
||||
* never do is call a ceremony finished when it is not, or waiting when there is
|
||||
* nothing left to wait for.
|
||||
*
|
||||
* What it owes this member outranks the stage, in the words the approval screens
|
||||
* use for the same step, so nobody is asked twice in two vocabularies.
|
||||
*/
|
||||
@Composable
|
||||
private fun statusOf(ceremony: DkgRitualListUIState.Ceremony): String {
|
||||
ceremony.pendingApproval?.let {
|
||||
return when (it) {
|
||||
DkgApprovalStep.HOST_KEY ->
|
||||
stringResource(Res.string.join_the_ceremony_by_publishing_your_key)
|
||||
DkgApprovalStep.ROUND_1 ->
|
||||
stringResource(Res.string.send_your_contribution_to_the_key)
|
||||
DkgApprovalStep.ROUND_2 ->
|
||||
stringResource(Res.string.check_the_combined_result)
|
||||
}
|
||||
}
|
||||
|
||||
if (ceremony.roomAwaitingCreation != null) {
|
||||
return stringResource(Res.string.the_group_has_its_key_finish_setting_it_up)
|
||||
}
|
||||
|
||||
return when (ceremony.session.stage) {
|
||||
DkgRitualStage.COLLECTING_HOST_KEYS ->
|
||||
stringResource(Res.string.waiting_for_members_to_join)
|
||||
|
||||
DkgRitualStage.COLLECTING_ROUND_1 ->
|
||||
stringResource(Res.string.waiting_for_everyone_to_commit_their_part)
|
||||
|
||||
DkgRitualStage.COLLECTING_ROUND_2 ->
|
||||
stringResource(Res.string.waiting_for_everyone_to_confirm_the_result)
|
||||
|
||||
DkgRitualStage.COMPLETE ->
|
||||
stringResource(Res.string.the_group_has_a_key_from_this_ceremony)
|
||||
|
||||
// The reason is the whole content of a failure -- "a member refused" and
|
||||
// "the group could not agree" are different things to have happened -- so
|
||||
// it stands in for the bare word whenever there is one.
|
||||
DkgRitualStage.FAILED ->
|
||||
ceremony.session.failureReason
|
||||
?: stringResource(Res.string.abandoned_no_key_was_created)
|
||||
}
|
||||
}
|
||||
|
||||
@ConformancePreviews
|
||||
@Composable
|
||||
private fun DkgRitualListScreenPreview() {
|
||||
val session = DkgSession(
|
||||
id = "sessionId",
|
||||
chatRoomId = "chatRoomId",
|
||||
coordinatorPublicKey = "c".repeat(64),
|
||||
userPublicKey = "u".repeat(64),
|
||||
threshold = 2,
|
||||
participantCount = 3,
|
||||
hostPublicKey = "h".repeat(64),
|
||||
round1Random = "1".repeat(64),
|
||||
round2AuxRandom = "2".repeat(64),
|
||||
)
|
||||
|
||||
MantraTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
DkgRitualListScreen(
|
||||
activeUserPublicKey = "u".repeat(64),
|
||||
chatRoomId = "chatRoomId",
|
||||
initialDkgRitualListUIState = DkgRitualListUIState.Loaded(
|
||||
localChatRoom = LocalChatRoom(
|
||||
chatRoom = ChatRoom(
|
||||
id = "chatRoomId",
|
||||
userPublicKey = "u".repeat(64),
|
||||
subject = "#Ekklesia",
|
||||
description = null,
|
||||
initialGiftWrapPayloadId = "sdfaer",
|
||||
mlsGroupState = null
|
||||
)
|
||||
),
|
||||
ceremonies = listOf(
|
||||
DkgRitualListUIState.Ceremony(
|
||||
session = session.copy(
|
||||
id = "subgroupSessionId",
|
||||
parentChatRoomId = "parentChatRoomId",
|
||||
subject = "Translators",
|
||||
stage = DkgRitualStage.COLLECTING_ROUND_1
|
||||
),
|
||||
coordinatorName = "Ada",
|
||||
pendingApproval = DkgApprovalStep.ROUND_1
|
||||
),
|
||||
DkgRitualListUIState.Ceremony(
|
||||
session = session.copy(stage = DkgRitualStage.COMPLETE),
|
||||
coordinatorName = "You"
|
||||
)
|
||||
)
|
||||
),
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY,
|
||||
onNavigateBack = {},
|
||||
onNavigateToRoute = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -37,6 +37,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -154,7 +155,7 @@ fun DkgRitualScreen(
|
||||
)
|
||||
)
|
||||
|
||||
ScreenStateTransition(dkgRitualViewModel.dkgRitualUIState) { uiState ->
|
||||
ScreenStateTransition(dkgRitualViewModel.dkgRitualUIState.collectAsState().value) { uiState ->
|
||||
when (val dkgRitualUIState = uiState) {
|
||||
is DkgRitualUIState.Error -> {
|
||||
Column(
|
||||
@@ -168,7 +169,7 @@ fun DkgRitualScreen(
|
||||
is DkgRitualUIState.Loaded -> {
|
||||
val session = dkgRitualUIState.session
|
||||
val participantCount = dkgRitualUIState.participantCount
|
||||
val canStartRitual = dkgRitualViewModel.canStartRitual()
|
||||
val canStartRitual = dkgRitualViewModel.canStartRitual(dkgRitualUIState)
|
||||
val isActionPending = dkgRitualViewModel.isActionPending.value
|
||||
|
||||
Scaffold(
|
||||
@@ -286,7 +287,7 @@ fun DkgRitualScreen(
|
||||
QuorumStepper(
|
||||
threshold = dkgRitualViewModel.threshold.value,
|
||||
participantCount = participantCount,
|
||||
quorumRange = dkgRitualViewModel.quorumRange(),
|
||||
quorumRange = dkgRitualViewModel.quorumRange(dkgRitualUIState),
|
||||
onThresholdChange = dkgRitualViewModel::setThreshold
|
||||
)
|
||||
} else {
|
||||
|
||||
+18
@@ -38,6 +38,7 @@ import press.mantra.compose.ui.composable.ChatRoomDetailScreen
|
||||
import press.mantra.compose.ui.composable.ChatRoomMessagingScreen
|
||||
import press.mantra.compose.ui.composable.CreateProfileScreen
|
||||
import press.mantra.compose.ui.composable.DkgJoinApprovalScreen
|
||||
import press.mantra.compose.ui.composable.DkgRitualListScreen
|
||||
import press.mantra.compose.ui.composable.DkgRitualScreen
|
||||
import press.mantra.compose.ui.composable.DkgRound1ApprovalScreen
|
||||
import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen
|
||||
@@ -140,6 +141,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualListRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute
|
||||
@@ -1198,6 +1200,22 @@ fun MantraNavHost(
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<DkgRitualListRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<DkgRitualListRoute>()
|
||||
|
||||
DkgRitualListScreen(
|
||||
activeUserPublicKey = route.activeUserPublicKey,
|
||||
chatRoomId = route.chatRoomId,
|
||||
chatRepository = databaseChatRepository,
|
||||
dkgRepository = databaseDkgRepository,
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onNavigateToRoute = { ceremonyRoute ->
|
||||
navController.navigate(route = ceremonyRoute)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<ProposalListRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<ProposalListRoute>()
|
||||
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package press.mantra.compose.ui.composable.navigation.routes
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Every key ceremony a room holds.
|
||||
*
|
||||
* The room's list, not the reader's: a ceremony is run by the whole group, and
|
||||
* which of them are waiting on this member is a status on each row rather than
|
||||
* a different list -- the same shape as [ProposalListRoute], for the same
|
||||
* reason.
|
||||
*
|
||||
* A room holds more than one as a matter of course since a subgroup's ChillDKG
|
||||
* moved into its parent's room, and until this there was no screen that could
|
||||
* name the second: [DkgRitualRoute] without a session id shows the newest, and
|
||||
* the newest is routinely the group's own, long finished, with the subgroup's
|
||||
* request buried underneath it.
|
||||
*/
|
||||
@Serializable
|
||||
data class DkgRitualListRoute(
|
||||
val activeUserPublicKey: String,
|
||||
val chatRoomId: String
|
||||
): Route()
|
||||
+7
-2
@@ -578,7 +578,8 @@ fun ChatTranscript(
|
||||
@Composable
|
||||
private fun CeremoniesAwaitingYouNotice(
|
||||
ceremonies: List<ChatMessageListViewModel.AwaitingCeremony>,
|
||||
onClick: (dkgSessionId: String) -> Unit,
|
||||
/** Null where the notice stands for more than one, which no single screen can show. */
|
||||
onClick: (dkgSessionId: String?) -> Unit,
|
||||
) {
|
||||
val single = ceremonies.singleOrNull()
|
||||
|
||||
@@ -602,7 +603,11 @@ private fun CeremoniesAwaitingYouNotice(
|
||||
}
|
||||
|
||||
Card(
|
||||
onClick = { onClick(ceremonies.first().sessionId) },
|
||||
// The one it names when it names one, and the list otherwise. A notice
|
||||
// reading "3 key ceremonies need you" that opened the first of them --
|
||||
// which is what this did -- answered a different question from the one
|
||||
// it asked, and left the other two exactly as hard to find as before.
|
||||
onClick = { onClick(single?.sessionId) },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = MaterialTheme.spacing.space50),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
|
||||
+19
@@ -156,6 +156,25 @@ class ChatMessageListViewModel(
|
||||
proposalsAwaitingYou.size > 1 &&
|
||||
proposalsAwaitingYou.any { it.sessionId == sessionId }
|
||||
|
||||
/**
|
||||
* Whether opening [sessionId] by itself would hide other ceremonies the room
|
||||
* is waiting on this member for.
|
||||
*
|
||||
* [hidesOtherDecisions]'s counterpart, and the same argument: a ceremony line
|
||||
* names one ceremony, and opening it is right while that is the only one
|
||||
* asking. A room holds more than one as a matter of course since a subgroup's
|
||||
* ChillDKG moved into its parent's room, and the second is not in the
|
||||
* transcript beside the first -- so answering the one they tapped and leaving
|
||||
* would look exactly like being done.
|
||||
*
|
||||
* Only for a ceremony that is itself waiting on them. A line about a ceremony
|
||||
* the group finished is history, and a reader who taps one is asking to see
|
||||
* that, not to be handed the queue.
|
||||
*/
|
||||
fun hidesOtherCeremonies(sessionId: String): Boolean =
|
||||
ceremoniesAwaitingYou.size > 1 &&
|
||||
ceremoniesAwaitingYou.any { it.sessionId == sessionId }
|
||||
|
||||
fun startDirectMessage(participant: Participant) {
|
||||
logger.d("Arming a direct message to ${participant.participantPublicKey}")
|
||||
directMessageRecipient = participant
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package press.mantra.compose.ui.view.model
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.extensions.memberName
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.DkgRepository
|
||||
import press.mantra.compose.ui.view.state.DkgRitualListUIState
|
||||
|
||||
/**
|
||||
* Every key ceremony a room holds.
|
||||
*
|
||||
* A room routinely holds more than one: a parent's Marmot room hosts the
|
||||
* ceremony of every subgroup the group ever makes, alongside whatever it holds
|
||||
* of its own. Watching the whole list rather than the newest is the point --
|
||||
* which of them is waiting on the reader is a fact about each one, and the
|
||||
* newest is routinely the finished one.
|
||||
*
|
||||
* The counterpart to [ProposalListViewModel], and deliberately the same shape.
|
||||
*/
|
||||
class DkgRitualListViewModel(
|
||||
val chatRoomId: String,
|
||||
val activeUserPublicKey: HexKey,
|
||||
initialDkgRitualListUIState: DkgRitualListUIState,
|
||||
val chatRepository: ChatRepository,
|
||||
val dkgRepository: DkgRepository,
|
||||
): ViewModel() {
|
||||
|
||||
private val _dkgRitualListUIState = MutableStateFlow(initialDkgRitualListUIState)
|
||||
|
||||
/**
|
||||
* A flow rather than a `mutableStateOf`: this is written from
|
||||
* `Dispatchers.IO`, and a `mutableStateOf` created in a view model's
|
||||
* constructor silently drops a write that lands during the composition that
|
||||
* created it. See [DkgRitualViewModel.dkgRitualUIState].
|
||||
*/
|
||||
val dkgRitualListUIState: StateFlow<DkgRitualListUIState> =
|
||||
_dkgRitualListUIState.asStateFlow()
|
||||
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
/**
|
||||
* Loads the room, then watches its ceremonies for as long as the screen
|
||||
* lives.
|
||||
*
|
||||
* Every one of them moves on messages arriving from other members, so a
|
||||
* screen that read the list once would go stale the moment somebody else
|
||||
* took their step -- which is exactly when a member is likely to be looking
|
||||
* at it.
|
||||
*/
|
||||
fun initiate() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
|
||||
if (localChatRoom == null) {
|
||||
_dkgRitualListUIState.value =
|
||||
DkgRitualListUIState.Error("Couldn't find the group")
|
||||
return@launch
|
||||
}
|
||||
|
||||
dkgRepository.observeSessionsForChatRoom(chatRoomId).collect { sessions ->
|
||||
logger.d("${sessions.size} key ceremonies in $chatRoomId")
|
||||
|
||||
_dkgRitualListUIState.value = DkgRitualListUIState.Loaded(
|
||||
localChatRoom = localChatRoom,
|
||||
ceremonies = sessions.map { asCeremony(it, localChatRoom) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One ceremony as a row.
|
||||
*
|
||||
* What it wants is asked of the repository rather than worked out here, so
|
||||
* this list, the transcript's standing notice and the ceremony screen cannot
|
||||
* disagree about which ceremonies still have something in them -- the same
|
||||
* rule the proposal list keeps.
|
||||
*
|
||||
* An abandoned ceremony is asked nothing. It can want nothing: there is no
|
||||
* step left to approve and no key to build a room from, and a row claiming
|
||||
* otherwise would send a member to a screen with nothing on it.
|
||||
*/
|
||||
private suspend fun asCeremony(
|
||||
session: DkgSession,
|
||||
localChatRoom: LocalChatRoom
|
||||
): DkgRitualListUIState.Ceremony {
|
||||
val live = session.takeIf { it.stage != DkgRitualStage.FAILED }
|
||||
|
||||
return DkgRitualListUIState.Ceremony(
|
||||
session = session,
|
||||
coordinatorName = session.coordinatorPublicKey.memberName(
|
||||
members = localChatRoom.localParticipants,
|
||||
activeUserPublicKey = activeUserPublicKey
|
||||
),
|
||||
pendingApproval = live?.let { dkgRepository.pendingApproval(it) },
|
||||
// Only of the member who opened it: creating the room is the
|
||||
// coordinator's job, and a row telling everybody else to do it would
|
||||
// be telling them to do something they cannot.
|
||||
roomAwaitingCreation = live
|
||||
?.takeIf { it.isCoordinator() }
|
||||
?.let { dkgRepository.roomAwaitingCreation(it) }
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DkgRitualListViewModel"
|
||||
|
||||
fun factory(
|
||||
chatRoomId: String,
|
||||
activeUserPublicKey: HexKey,
|
||||
initialDkgRitualListUIState: DkgRitualListUIState = DkgRitualListUIState.Loading,
|
||||
chatRepository: ChatRepository,
|
||||
dkgRepository: DkgRepository,
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
DkgRitualListViewModel(
|
||||
chatRoomId = chatRoomId,
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
initialDkgRitualListUIState = initialDkgRitualListUIState,
|
||||
chatRepository = chatRepository,
|
||||
dkgRepository = dkgRepository
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+229
-126
@@ -1,9 +1,7 @@
|
||||
package press.mantra.compose.ui.view.model
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -36,7 +34,10 @@ import press.mantra.compose.identity.Identity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
@@ -60,8 +61,51 @@ class DkgRitualViewModel(
|
||||
val dkgRepository: DkgRepository,
|
||||
): ViewModel() {
|
||||
|
||||
var dkgRitualUIState: DkgRitualUIState by mutableStateOf(initialDkgRitualUIState)
|
||||
private set
|
||||
private val _dkgRitualUIState = MutableStateFlow(initialDkgRitualUIState)
|
||||
|
||||
/**
|
||||
* What the screen draws -- a flow rather than a `mutableStateOf`, because
|
||||
* seven flows write it.
|
||||
*
|
||||
* The ceremony, the room's other ceremonies, the ceremony's messages, the
|
||||
* key state, whether the key state is signed, the certificate and the
|
||||
* certificate's signing all fold into this one object, and every one of
|
||||
* them emits on Room's own executor. Held
|
||||
* as a `var ... by mutableStateOf`, each of those was a read, a `copy` and
|
||||
* an assignment with a suspension point in the middle, so two emissions
|
||||
* arriving together silently dropped one: whichever read first wrote last,
|
||||
* over the top of the other's change.
|
||||
*
|
||||
* The change that went missing most visibly was [DkgRitualUIState.Loaded.session],
|
||||
* copied back to null by a watcher that had read the state before the
|
||||
* ceremony landed in it -- and a screen with no session is the screen that
|
||||
* offers to start one. It stayed that way until some other table changed and
|
||||
* the session flow emitted again, which for a ceremony waiting on an absent
|
||||
* member is never.
|
||||
*
|
||||
* [MutableStateFlow.update] retries on a losing compare-and-set, so a change
|
||||
* can only be folded into the state that is actually current. Every write
|
||||
* below goes through it -- see [updateLoaded]. It is the same rule the
|
||||
* wallet view models already keep, for the same reason.
|
||||
*/
|
||||
val dkgRitualUIState: StateFlow<DkgRitualUIState> = _dkgRitualUIState.asStateFlow()
|
||||
|
||||
/**
|
||||
* Folds a change into the loaded state, atomically, and does nothing when
|
||||
* the screen is not in it -- the `as? Loaded ?: return` that every one of
|
||||
* these call sites used to write by hand.
|
||||
*
|
||||
* [block] is re-run when it loses the race, so it has to be a pure function
|
||||
* of the state it is handed: read nothing else out of the view model inside
|
||||
* it, and do the suspending work above the call.
|
||||
*/
|
||||
private inline fun updateLoaded(
|
||||
block: (DkgRitualUIState.Loaded) -> DkgRitualUIState.Loaded
|
||||
) {
|
||||
_dkgRitualUIState.update { current ->
|
||||
if (current is DkgRitualUIState.Loaded) block(current) else current
|
||||
}
|
||||
}
|
||||
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
@@ -70,9 +114,6 @@ class DkgRitualViewModel(
|
||||
/** Watches the current session's messages; replaced whenever the session changes. */
|
||||
private var messageObserver: Job? = null
|
||||
|
||||
/** Watches the room's signing sessions, which is where the key state is agreed. */
|
||||
private var keyStateObserver: Job? = null
|
||||
|
||||
/** Watches the parent certifying this subgroup; re-pointed as the purpose is learned. */
|
||||
private var certificateObserver: Job? = null
|
||||
|
||||
@@ -101,9 +142,15 @@ class DkgRitualViewModel(
|
||||
* Any member may: ChillDKG gives the coordinator no say in the outcome, and a
|
||||
* NIP-17 room records no creator to reserve the job for. What rules it out is
|
||||
* a ceremony already running, or a group too small to split a key across.
|
||||
*
|
||||
* [state] is passed in by the screen rather than read off the view model,
|
||||
* because the state is a flow now and reading `.value` inside composition
|
||||
* observes nothing -- the offer would be decided once and never revised. The
|
||||
* screen hands in the state it is drawing, which is also the only state the
|
||||
* answer should be about while a transition still holds the outgoing one.
|
||||
*/
|
||||
fun canStartRitual(): Boolean {
|
||||
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return false
|
||||
fun canStartRitual(state: DkgRitualUIState = dkgRitualUIState.value): Boolean {
|
||||
val loaded = state as? DkgRitualUIState.Loaded ?: return false
|
||||
|
||||
if (loaded.session != null && loaded.session.stage != DkgRitualStage.FAILED) return false
|
||||
|
||||
@@ -116,8 +163,8 @@ class DkgRitualViewModel(
|
||||
return ChillDkgRitualManager.canRunRitual(loaded.localChatRoom)
|
||||
}
|
||||
|
||||
fun quorumRange(): IntRange {
|
||||
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded
|
||||
fun quorumRange(state: DkgRitualUIState = dkgRitualUIState.value): IntRange {
|
||||
val loaded = state as? DkgRitualUIState.Loaded
|
||||
?: return ChatRoomType.quorumRange(ChatRoomType.MINIMUM_QUORUM)
|
||||
|
||||
// Below the minimum the range runs backwards and is empty, which `coerceIn`
|
||||
@@ -137,17 +184,10 @@ class DkgRitualViewModel(
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
|
||||
if (localChatRoom == null) {
|
||||
dkgRitualUIState = DkgRitualUIState.Error("Couldn't load this chat")
|
||||
_dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't load this chat")
|
||||
return@launch
|
||||
}
|
||||
|
||||
dkgRitualUIState = DkgRitualUIState.Loaded(localChatRoom = localChatRoom)
|
||||
threshold.value = ChatRoomType
|
||||
.defaultQuorum(ChillDkgRitualManager.memberPublicKeys(localChatRoom).size)
|
||||
.coerceIn(quorumRange())
|
||||
|
||||
observeKeyState()
|
||||
|
||||
// Room-scoped, deliberately. Only the member who opened a subgroup
|
||||
// arrives here knowing it is one -- everybody else reaches this screen
|
||||
// from the room, through the transcript's ritual notice or the group's
|
||||
@@ -171,30 +211,63 @@ class DkgRitualViewModel(
|
||||
// Whether the room has any ceremony at all, which is a different
|
||||
// question from which one is being shown -- see
|
||||
// `DkgRitualUIState.roomHasCeremony`.
|
||||
launch {
|
||||
dkgRepository.observeLatestSessionForChatRoom(chatRoomId).collect { newest ->
|
||||
val current = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
|
||||
val roomSessions = dkgRepository.observeLatestSessionForChatRoom(chatRoomId)
|
||||
|
||||
dkgRitualUIState = current.copy(
|
||||
roomHasCeremony = newest != null && newest.stage != DkgRitualStage.FAILED
|
||||
)
|
||||
// Both answers are read here, before anything is published, rather
|
||||
// than awaited after a bare `Loaded` has gone up. A `Loaded` with no
|
||||
// session and no ceremony *is* the screen that offers to start one,
|
||||
// so publishing it first flashed "start key ceremony" over a ceremony
|
||||
// already running -- for as long as the two queries took, which is
|
||||
// long enough to read and long enough to tap. The flows below keep
|
||||
// both current from here on; this only settles what the first frame
|
||||
// says.
|
||||
val shownSession = sessions.first()
|
||||
val newestSession = roomSessions.first()
|
||||
|
||||
_dkgRitualUIState.value = DkgRitualUIState.Loaded(
|
||||
localChatRoom = localChatRoom,
|
||||
session = shownSession,
|
||||
parentChatRoomId = shownSession?.parentChatRoomId,
|
||||
pendingApproval = shownSession?.let { dkgRepository.pendingApproval(it) },
|
||||
roomHasCeremony = newestSession != null &&
|
||||
newestSession.stage != DkgRitualStage.FAILED
|
||||
)
|
||||
|
||||
threshold.value = ChatRoomType
|
||||
.defaultQuorum(ChillDkgRitualManager.memberPublicKeys(localChatRoom).size)
|
||||
.coerceIn(quorumRange())
|
||||
|
||||
launch {
|
||||
roomSessions.collect { newest ->
|
||||
updateLoaded { current ->
|
||||
current.copy(
|
||||
roomHasCeremony = newest != null &&
|
||||
newest.stage != DkgRitualStage.FAILED
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessions.collect { session ->
|
||||
val loaded = (dkgRitualUIState as? DkgRitualUIState.Loaded)
|
||||
?: DkgRitualUIState.Loaded(localChatRoom = localChatRoom)
|
||||
|
||||
// The session is the only thing that knows what the ceremony is
|
||||
// for. The route used to carry it too, which was right for the one
|
||||
// member who opened the subgroup and null for everybody else.
|
||||
val parent = session?.parentChatRoomId
|
||||
|
||||
dkgRitualUIState = loaded.copy(
|
||||
session = session,
|
||||
parentChatRoomId = parent,
|
||||
pendingApproval = session?.let { dkgRepository.pendingApproval(it) }
|
||||
)
|
||||
// Above the update, not inside it: `update` re-runs its block when
|
||||
// it loses the race, and a block that re-runs cannot suspend.
|
||||
val pending = session?.let { dkgRepository.pendingApproval(it) }
|
||||
|
||||
_dkgRitualUIState.update { current ->
|
||||
val loaded = current as? DkgRitualUIState.Loaded
|
||||
?: DkgRitualUIState.Loaded(localChatRoom = localChatRoom)
|
||||
|
||||
loaded.copy(
|
||||
session = session,
|
||||
parentChatRoomId = parent,
|
||||
pendingApproval = pending
|
||||
)
|
||||
}
|
||||
|
||||
// Re-point the per-ceremony watchers whenever the ceremony or its
|
||||
// purpose changes, which for a member arriving cold is once.
|
||||
@@ -212,23 +285,32 @@ class DkgRitualViewModel(
|
||||
if (session != null) {
|
||||
messageObserver = viewModelScope.launch(Dispatchers.IO) {
|
||||
dkgRepository.observeMessages(session.id).collect { messages ->
|
||||
val current = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
|
||||
|
||||
fun senders(kind: Kind) = messages
|
||||
.filter { it.kind == kind }
|
||||
.map { it.participantPublicKey }
|
||||
.toSet()
|
||||
|
||||
dkgRitualUIState = current.copy(
|
||||
hostKeyParticipants = senders(DkgRitualEvents.HOST_KEY),
|
||||
round1Participants = senders(DkgRitualEvents.ROUND_1),
|
||||
round2Participants = senders(DkgRitualEvents.ROUND_2),
|
||||
// Recomputed here as well as on the session, because
|
||||
// whether round 1 can be asked for depends on how many
|
||||
// host keys have arrived, which only this flow sees.
|
||||
pendingApproval = current.session
|
||||
?.let { dkgRepository.pendingApproval(it) }
|
||||
)
|
||||
// Recomputed here as well as on the session, because
|
||||
// whether round 1 can be asked for depends on how many
|
||||
// host keys have arrived, which only this flow sees.
|
||||
val pending = dkgRepository.pendingApproval(session)
|
||||
|
||||
updateLoaded { current ->
|
||||
// This watcher belongs to one ceremony, and
|
||||
// cancelling it does not reach into a body already
|
||||
// running -- so say which ceremony these counts are
|
||||
// for rather than letting them land on the next.
|
||||
if (current.session?.id != session.id) {
|
||||
return@updateLoaded current
|
||||
}
|
||||
|
||||
current.copy(
|
||||
hostKeyParticipants = senders(DkgRitualEvents.HOST_KEY),
|
||||
round1Participants = senders(DkgRitualEvents.ROUND_1),
|
||||
round2Participants = senders(DkgRitualEvents.ROUND_2),
|
||||
pendingApproval = pending
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,45 +318,6 @@ class DkgRitualViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows the group agreeing what its admin room will sign with.
|
||||
*
|
||||
* Nothing here watches a [GroupKeyState] row, because there is no room yet
|
||||
* for one to belong to -- being able to follow this before the room exists
|
||||
* is the point. What is watched instead is the session, for how the signing
|
||||
* is going, and the signed event, for whether it finished.
|
||||
*
|
||||
* The room a ceremony runs in signs nothing else, so the newest session in
|
||||
* it is the key state's -- but it is matched on the kind it carries anyway,
|
||||
* so a room that one day signs something else does not confuse the two.
|
||||
*/
|
||||
private fun observeKeyState() {
|
||||
keyStateObserver?.cancel()
|
||||
keyStateObserver = viewModelScope.launch(Dispatchers.IO) {
|
||||
// Two flows because they answer two questions off two tables. How
|
||||
// the signing is going is the session row; whether it finished is
|
||||
// the signed event, and only that second one can be asked at all
|
||||
// before there is a room -- which is the whole point of asking it.
|
||||
launch {
|
||||
dkgRepository.observeSigningSessions(chatRoomId).collect { sessions ->
|
||||
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
|
||||
|
||||
dkgRitualUIState = loaded.copy(
|
||||
keyStateSession = sessions.firstOrNull { local ->
|
||||
local.items.any { item ->
|
||||
runCatching {
|
||||
Event.fromJson(item.unsignedEventJson).kind
|
||||
}.getOrNull() == GroupKeyStateEvent.KIND
|
||||
}
|
||||
}?.session
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows one ceremony: whether its key state has been signed, and -- for a
|
||||
* subgroup -- whether its parent has certified it.
|
||||
@@ -293,54 +336,113 @@ class DkgRitualViewModel(
|
||||
certificateObserver?.cancel()
|
||||
|
||||
if (dkgSessionId == null) {
|
||||
dkgRitualUIState = (dkgRitualUIState as? DkgRitualUIState.Loaded)
|
||||
?.copy(isKeyStateSigned = false, isCertified = false, certificateSession = null)
|
||||
?: dkgRitualUIState
|
||||
updateLoaded {
|
||||
it.copy(
|
||||
isKeyStateSigned = false,
|
||||
keyStateSession = null,
|
||||
isCertified = false,
|
||||
certificateSession = null
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
certificateObserver = viewModelScope.launch(Dispatchers.IO) {
|
||||
// How the agreement is going, as opposed to whether it finished --
|
||||
// two questions off two tables, and only the second can be asked
|
||||
// before there is a room at all, which is the point of asking it.
|
||||
//
|
||||
// By the ceremony the proposal names, not by kind in this room. The
|
||||
// room a ceremony runs in used to sign nothing else, and that is
|
||||
// what this read assumed; a parent's Marmot room now hosts the
|
||||
// ceremony of every subgroup the group makes, so it holds one of
|
||||
// these per subgroup. Matching on kind alone handed the screen the
|
||||
// newest of them -- which is some other subgroup's -- and with it a
|
||||
// progress bar for a signing this ceremony is not in and a button
|
||||
// disabled on the strength of it. Permanently, once that other one
|
||||
// completed: the guard only reopens on a failure.
|
||||
//
|
||||
// `FrostKeyTag` is on the proposal from the moment it is made, so
|
||||
// this works before the ceremony has a key and before anybody signs.
|
||||
launch {
|
||||
dkgRepository.observeSigningSessions(chatRoomId).collect { sessions ->
|
||||
val keyState = sessions.firstOrNull { local ->
|
||||
local.items.any { item ->
|
||||
val event = runCatching {
|
||||
Event.fromJson(item.unsignedEventJson)
|
||||
}.getOrNull() ?: return@any false
|
||||
|
||||
event.kind == GroupKeyStateEvent.KIND &&
|
||||
GroupKeyStateEvent.parseDkgSessionId(event.tags) == dkgSessionId
|
||||
}
|
||||
}?.session
|
||||
|
||||
updateLoaded { it.copy(keyStateSession = keyState) }
|
||||
}
|
||||
}
|
||||
|
||||
// Whether the group has agreed what the room this ceremony's key
|
||||
// derives will sign with. Asked by ceremony rather than by room: the
|
||||
// room holds however many ceremonies it holds, and the answer is
|
||||
// about this one's key.
|
||||
launch {
|
||||
dkgRepository.observeSignedGroupKeyState(dkgSessionId).collect { state ->
|
||||
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
|
||||
|
||||
dkgRitualUIState = loaded.copy(isKeyStateSigned = state != null)
|
||||
updateLoaded { it.copy(isKeyStateSigned = state != null) }
|
||||
}
|
||||
}
|
||||
|
||||
if (parentChatRoomId == null) {
|
||||
dkgRitualUIState = (dkgRitualUIState as? DkgRitualUIState.Loaded)
|
||||
?.copy(isCertified = false, certificateSession = null)
|
||||
?: dkgRitualUIState
|
||||
updateLoaded { it.copy(isCertified = false, certificateSession = null) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
launch {
|
||||
dkgRepository.observeBirthCertificate(dkgSessionId, parentChatRoomId)
|
||||
.collect { certificate ->
|
||||
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
|
||||
|
||||
dkgRitualUIState = loaded.copy(isCertified = certificate != null)
|
||||
updateLoaded { it.copy(isCertified = certificate != null) }
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
// The child this ceremony is making, which is what a certificate
|
||||
// names. Resolved per emission rather than once, because the
|
||||
// ceremony has no key until it finishes and this flow is running
|
||||
// before it does -- the same reason `observeBirthCertificate`
|
||||
// resolves it per emission.
|
||||
//
|
||||
// Scoped to that child, not to the kind. A parent certifies every
|
||||
// subgroup it ever makes from the same room, so by kind alone this
|
||||
// found the newest certificate signing in the parent -- some other
|
||||
// child's -- and read its progress as this one's. Worse, the rung
|
||||
// disables "ask the group to certify" while a certificate session
|
||||
// is running and un-failed, so once a parent had certified one
|
||||
// subgroup it could never be asked to certify another.
|
||||
dkgRepository.observeSigningSessions(parentChatRoomId).collect { sessions ->
|
||||
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
|
||||
val subgroupChatRoomId = dkgRepository.roomDerivedBy(dkgSessionId)
|
||||
|
||||
dkgRitualUIState = loaded.copy(
|
||||
certificateSession = sessions.firstOrNull { local ->
|
||||
val certificate = subgroupChatRoomId?.let { child ->
|
||||
sessions.firstOrNull { local ->
|
||||
local.items.any { item ->
|
||||
runCatching {
|
||||
Event.fromJson(item.unsignedEventJson).kind
|
||||
}.getOrNull() == SubgroupBirthCertificateEvent.KIND
|
||||
val event = runCatching {
|
||||
Event.fromJson(item.unsignedEventJson)
|
||||
}.getOrNull() ?: return@any false
|
||||
|
||||
// `addresses`, not `certifies`: the thing being
|
||||
// matched is a proposal, and `certifies` ends on
|
||||
// a signature by the parent's quorum, which is
|
||||
// precisely what has not happened yet. Whether
|
||||
// the parent actually agreed is `isCertified`,
|
||||
// which does check it.
|
||||
SubgroupBirthCertificateEvent.addresses(
|
||||
event = event,
|
||||
subgroupChatRoomId = child,
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
)
|
||||
}
|
||||
}?.session
|
||||
)
|
||||
}
|
||||
|
||||
updateLoaded { it.copy(certificateSession = certificate) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,7 +461,7 @@ class DkgRitualViewModel(
|
||||
fun proposeKeyState() {
|
||||
if (isActionPending.value) return
|
||||
|
||||
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
|
||||
val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return
|
||||
val session = loaded.session ?: return
|
||||
if (session.thresholdPublicKey == null) return
|
||||
|
||||
@@ -398,7 +500,7 @@ class DkgRitualViewModel(
|
||||
|
||||
if (signing == null) {
|
||||
logger.e("Failed to propose the key state for $chatRoomId")
|
||||
dkgRitualUIState = DkgRitualUIState.Error(
|
||||
_dkgRitualUIState.value = DkgRitualUIState.Error(
|
||||
"Couldn't ask the group to agree its signing key. Please try again."
|
||||
)
|
||||
}
|
||||
@@ -421,7 +523,7 @@ class DkgRitualViewModel(
|
||||
fun proposeBirthCertificate() {
|
||||
if (isActionPending.value) return
|
||||
|
||||
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
|
||||
val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return
|
||||
// The state's, not the route's. Every step after the ceremony is open to
|
||||
// any of the subgroup's admins -- see `SubgroupManager` -- and they reach
|
||||
// this screen from the room, with no route to have told them anything.
|
||||
@@ -459,7 +561,7 @@ class DkgRitualViewModel(
|
||||
|
||||
if (signing == null) {
|
||||
logger.e("Failed to propose a birth certificate in $parent")
|
||||
dkgRitualUIState = DkgRitualUIState.Error(
|
||||
_dkgRitualUIState.value = DkgRitualUIState.Error(
|
||||
"Couldn't ask the group to certify this subgroup. Please try again."
|
||||
)
|
||||
}
|
||||
@@ -470,11 +572,11 @@ class DkgRitualViewModel(
|
||||
fun startRitual() {
|
||||
if (isActionPending.value) return
|
||||
|
||||
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
|
||||
val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return
|
||||
|
||||
val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey
|
||||
if (nostrPrivateKey == null) {
|
||||
dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
|
||||
_dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -492,7 +594,7 @@ class DkgRitualViewModel(
|
||||
|
||||
if (session == null) {
|
||||
logger.e("Failed to start DKG ritual for $chatRoomId")
|
||||
dkgRitualUIState = DkgRitualUIState.Error("Couldn't start the key ceremony. Please try again.")
|
||||
_dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't start the key ceremony. Please try again.")
|
||||
}
|
||||
// On success the session flow above delivers the new row; no need to
|
||||
// set it here and risk racing the observer.
|
||||
@@ -509,12 +611,12 @@ class DkgRitualViewModel(
|
||||
fun approve(step: DkgApprovalStep) {
|
||||
if (isActionPending.value) return
|
||||
|
||||
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
|
||||
val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return
|
||||
val session = loaded.session ?: return
|
||||
|
||||
val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey
|
||||
if (nostrPrivateKey == null) {
|
||||
dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
|
||||
_dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -558,7 +660,7 @@ class DkgRitualViewModel(
|
||||
fun createAdminGroup(onNavigateToRoute: (Route) -> Unit) {
|
||||
if (isActionPending.value) return
|
||||
|
||||
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
|
||||
val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return
|
||||
val session = loaded.session ?: return
|
||||
val thresholdPublicKey = session.thresholdPublicKey ?: return
|
||||
|
||||
@@ -568,7 +670,7 @@ class DkgRitualViewModel(
|
||||
|
||||
val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey
|
||||
if (nostrPrivateKey == null) {
|
||||
dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
|
||||
_dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -594,7 +696,7 @@ class DkgRitualViewModel(
|
||||
?: if (subgroupParent != null) "Subgroup" else "Group"
|
||||
|
||||
isActionPending.value = true
|
||||
dkgRitualUIState = loaded.copy(adminGroupBlockedOn = emptyList())
|
||||
updateLoaded { it.copy(adminGroupBlockedOn = emptyList()) }
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val outcome = chatRepository.createMarmotGroup(
|
||||
@@ -617,19 +719,21 @@ class DkgRitualViewModel(
|
||||
// Named rather than counted. A member cannot publish a key
|
||||
// package on somebody else's behalf, so the only useful thing
|
||||
// to say is whose door to knock on.
|
||||
dkgRitualUIState = loaded.copy(
|
||||
adminGroupBlockedOn = outcome.missing.map { publicKey ->
|
||||
loaded.ritualMembers
|
||||
.firstOrNull { it.participant.participantPublicKey == publicKey }
|
||||
?.profile
|
||||
?.humanReadableNameOrPubkey()
|
||||
?: publicKey.take(12)
|
||||
}
|
||||
)
|
||||
updateLoaded { current ->
|
||||
current.copy(
|
||||
adminGroupBlockedOn = outcome.missing.map { publicKey ->
|
||||
current.ritualMembers
|
||||
.firstOrNull { it.participant.participantPublicKey == publicKey }
|
||||
?.profile
|
||||
?.humanReadableNameOrPubkey()
|
||||
?: publicKey.take(12)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is MarmotGroupCreation.Outcome.Failed -> {
|
||||
dkgRitualUIState = DkgRitualUIState.Error(outcome.reason)
|
||||
_dkgRitualUIState.value = DkgRitualUIState.Error(outcome.reason)
|
||||
}
|
||||
|
||||
is MarmotGroupCreation.Outcome.Created -> withContext(Main) {
|
||||
@@ -657,7 +761,6 @@ class DkgRitualViewModel(
|
||||
|
||||
override fun onCleared() {
|
||||
messageObserver?.cancel()
|
||||
keyStateObserver?.cancel()
|
||||
certificateObserver?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package press.mantra.compose.ui.view.state
|
||||
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
|
||||
sealed interface DkgRitualListUIState {
|
||||
data class Loaded(
|
||||
val localChatRoom: LocalChatRoom,
|
||||
|
||||
/** Every ceremony the room holds, newest first. */
|
||||
val ceremonies: List<Ceremony> = emptyList(),
|
||||
): DkgRitualListUIState {
|
||||
/**
|
||||
* The ones that cannot move until this member acts.
|
||||
*
|
||||
* Split out rather than sorted to the top, the way the proposal list
|
||||
* splits its own -- the rest of the list is what the room has done,
|
||||
* these are what it is waiting on the reader for. A room can have two
|
||||
* at once the moment a subgroup's admins are the whole group, and
|
||||
* burying the second under the first one's history is the whole reason
|
||||
* this screen exists.
|
||||
*/
|
||||
val waitingForYou: List<Ceremony> = ceremonies.filter { it.awaitsYou }
|
||||
|
||||
val rest: List<Ceremony> = ceremonies.filterNot { it.awaitsYou }
|
||||
}
|
||||
|
||||
/**
|
||||
* One ceremony, as a row.
|
||||
*
|
||||
* Both reasons a ceremony can want something are carried separately rather
|
||||
* than folded into one flag, because they are owed by different people and
|
||||
* read differently: an approval is this member's gate and nobody else can
|
||||
* pass it, while finishing the setup is work the coordinator alone can come
|
||||
* back to. The row says which.
|
||||
*/
|
||||
data class Ceremony(
|
||||
val session: DkgSession,
|
||||
|
||||
/** Whoever opened it, as a name to read. */
|
||||
val coordinatorName: String,
|
||||
|
||||
/** What the ceremony is waiting on this member for, if anything. */
|
||||
val pendingApproval: DkgApprovalStep? = null,
|
||||
|
||||
/**
|
||||
* The room this finished ceremony's key derives, when it does not exist
|
||||
* yet and this member is the one who would make it.
|
||||
*
|
||||
* Reaching COMPLETE finishes nothing on its own: the group has a key and
|
||||
* somebody still has to get its state signed and create the room. The
|
||||
* same reading the transcript's standing notice makes.
|
||||
*/
|
||||
val roomAwaitingCreation: String? = null,
|
||||
) {
|
||||
/** Whether this ceremony is making a subgroup rather than the room's own key. */
|
||||
val isSubgroup: Boolean get() = session.parentChatRoomId != null
|
||||
|
||||
val awaitsYou: Boolean get() = pendingApproval != null || roomAwaitingCreation != null
|
||||
}
|
||||
|
||||
data class Error(
|
||||
val message: String
|
||||
): DkgRitualListUIState
|
||||
|
||||
data object Loading: DkgRitualListUIState
|
||||
}
|
||||
+146
@@ -436,6 +436,152 @@ class SubgroupBirthCertificateEventTest {
|
||||
assertNull(SubgroupManager.classify(subgroupChatRoomId, aboutSomebodyElse))
|
||||
}
|
||||
|
||||
// ---- `addresses`: the half that can be asked of a proposal ------------
|
||||
//
|
||||
// The ceremony screen has to tell one *in-flight* certificate signing from
|
||||
// another in the parent's room, and a parent certifies every subgroup it
|
||||
// ever makes from that one room. `certifies` cannot answer it: it ends on a
|
||||
// signature by the parent's quorum, which is exactly what the screen is
|
||||
// waiting for. `addresses` is the structural half, and the only thing worth
|
||||
// asserting about it is where it stops.
|
||||
|
||||
@Test
|
||||
fun `an unsigned proposal addresses its child, and certifies nothing`() {
|
||||
// What the ceremony screen is actually holding: the event as it was
|
||||
// assembled, before anybody signed it. Any string in `sig` serves --
|
||||
// the point is that no quorum has put one there.
|
||||
val proposal = certificate(signature = "0".repeat(128))
|
||||
|
||||
assertTrue(
|
||||
SubgroupBirthCertificateEvent.addresses(
|
||||
event = proposal,
|
||||
subgroupChatRoomId = subgroupChatRoomId,
|
||||
parentChatRoomId = parentChatRoomId
|
||||
),
|
||||
"the screen could not tell which subgroup its own proposal is about"
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
SubgroupBirthCertificateEvent.certifies(
|
||||
event = proposal,
|
||||
subgroupChatRoomId = subgroupChatRoomId,
|
||||
parentChatRoomId = parentChatRoomId
|
||||
),
|
||||
"an unsigned proposal is being read as a parent's certificate"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a forgery by another group addresses the pair it names, and certifies nothing`() {
|
||||
// The reason `addresses` must never decide a lineage: anybody can author
|
||||
// an event naming any pair, and this says yes to it. The guard is that
|
||||
// nothing downstream of a lineage asks `addresses` -- only the screen
|
||||
// deciding which of its own room's signings to draw does.
|
||||
val forged = certificate(author = childMaterial)
|
||||
|
||||
assertTrue(
|
||||
SubgroupBirthCertificateEvent.addresses(
|
||||
event = forged,
|
||||
subgroupChatRoomId = subgroupChatRoomId,
|
||||
parentChatRoomId = parentChatRoomId
|
||||
)
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
SubgroupBirthCertificateEvent.certifies(
|
||||
event = forged,
|
||||
subgroupChatRoomId = subgroupChatRoomId,
|
||||
parentChatRoomId = parentChatRoomId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sibling's proposal is not addressed to this child`() {
|
||||
// Two subgroups of one parent, which is the case the screen exists to
|
||||
// tell apart. The sibling's proposal is a real, well-formed certificate
|
||||
// request; it is simply about somebody else.
|
||||
val siblingRoomId = SharedKeyDerivation
|
||||
.marmotGroupId(strangerMaterial.thresholdPublicKey.value.toHex(), path)
|
||||
|
||||
val sibling = certificate(
|
||||
content = siblingRoomId,
|
||||
tags = SubgroupBirthCertificateEvent.assembleTags(
|
||||
subgroupChatRoomId = siblingRoomId,
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
thresholdPublicKey = strangerMaterial.thresholdPublicKey.value.toHex(),
|
||||
adminPublicKeys = admins,
|
||||
name = "Another team",
|
||||
path = path
|
||||
)
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
SubgroupBirthCertificateEvent.addresses(
|
||||
event = sibling,
|
||||
subgroupChatRoomId = subgroupChatRoomId,
|
||||
parentChatRoomId = parentChatRoomId
|
||||
),
|
||||
"one subgroup's rung is following a sibling's proposal"
|
||||
)
|
||||
|
||||
// And it is addressed to the sibling, so the sibling's own screen finds
|
||||
// it -- the two are told apart rather than both being refused.
|
||||
assertTrue(
|
||||
SubgroupBirthCertificateEvent.addresses(
|
||||
event = sibling,
|
||||
subgroupChatRoomId = siblingRoomId,
|
||||
parentChatRoomId = parentChatRoomId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a proposal naming another parent is not addressed by this one`() {
|
||||
val fromElsewhere = certificate(
|
||||
tags = SubgroupBirthCertificateEvent.assembleTags(
|
||||
subgroupChatRoomId = subgroupChatRoomId,
|
||||
parentChatRoomId = SharedKeyDerivation
|
||||
.marmotGroupId(strangerMaterial.thresholdPublicKey.value.toHex(), path),
|
||||
thresholdPublicKey = childKey,
|
||||
adminPublicKeys = admins,
|
||||
name = name,
|
||||
path = path
|
||||
)
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
SubgroupBirthCertificateEvent.addresses(
|
||||
event = fromElsewhere,
|
||||
subgroupChatRoomId = subgroupChatRoomId,
|
||||
parentChatRoomId = parentChatRoomId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `everything certifies addresses, so the weaker one cannot be the stricter`() {
|
||||
// `certifies` calls `addresses` and adds to it, and this is what keeps
|
||||
// that true: a change that let one pass where the other refuses would
|
||||
// mean two different answers to "which child is this about".
|
||||
val valid = certificate()
|
||||
|
||||
assertTrue(
|
||||
SubgroupBirthCertificateEvent.certifies(
|
||||
event = valid,
|
||||
subgroupChatRoomId = subgroupChatRoomId,
|
||||
parentChatRoomId = parentChatRoomId
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
SubgroupBirthCertificateEvent.addresses(
|
||||
event = valid,
|
||||
subgroupChatRoomId = subgroupChatRoomId,
|
||||
parentChatRoomId = parentChatRoomId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun certificate(
|
||||
content: String = subgroupChatRoomId,
|
||||
tags: Array<Array<String>> = SubgroupBirthCertificateEvent.assembleTags(
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package press.mantra.compose.ui.composable
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.runDesktopComposeUiTest
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.DkgRepository
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost
|
||||
import press.mantra.compose.ui.theme.MantraTheme
|
||||
import press.mantra.compose.ui.view.state.DkgRitualListUIState
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* What the ceremony list is for, read off the rendered screen.
|
||||
*
|
||||
* The ordering is settled in [press.mantra.compose.ui.view.model.DkgRitualListViewModel]
|
||||
* and tested there. What is only true once it is drawn is that a row goes to
|
||||
* the ceremony it names -- the whole reason this screen exists is that the
|
||||
* screens before it could not say which of a room's ceremonies they meant --
|
||||
* and that a room with none offers the way to hold one rather than ending in a
|
||||
* blank page.
|
||||
*/
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class DkgRitualListScreenJvmTest {
|
||||
private val me = "11".repeat(32)
|
||||
private val chatRoomId = "22".repeat(32)
|
||||
|
||||
@Test
|
||||
fun `a row opens the ceremony it names, not the room's newest`() =
|
||||
runDesktopComposeUiTest(500, 1200) {
|
||||
var opened: Route? = null
|
||||
|
||||
setContent {
|
||||
Screen(
|
||||
state = loaded(
|
||||
ceremony("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS).copy(
|
||||
pendingApproval = DkgApprovalStep.HOST_KEY
|
||||
),
|
||||
ceremony("newest", DkgRitualStage.COMPLETE)
|
||||
),
|
||||
onNavigateToRoute = { opened = it }
|
||||
)
|
||||
}
|
||||
|
||||
onNodeWithText("Join it by publishing your device's key")
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
|
||||
assertEquals(
|
||||
DkgRitualRoute(
|
||||
activeUserPublicKey = me,
|
||||
chatRoomId = chatRoomId,
|
||||
dkgSessionId = "subgroup"
|
||||
),
|
||||
opened
|
||||
)
|
||||
}
|
||||
|
||||
/** The two sections, so a ceremony owing nothing cannot be mistaken for one that does. */
|
||||
@Test
|
||||
fun `the ones waiting on you are headed apart from the rest`() =
|
||||
runDesktopComposeUiTest(500, 1200) {
|
||||
setContent {
|
||||
Screen(
|
||||
state = loaded(
|
||||
ceremony("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS).copy(
|
||||
pendingApproval = DkgApprovalStep.HOST_KEY
|
||||
),
|
||||
ceremony("newest", DkgRitualStage.COMPLETE)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
onNodeWithText("Waiting for you").assertIsDisplayed()
|
||||
onNodeWithText("Everything else").assertIsDisplayed()
|
||||
onNodeWithText("The group has a key from this ceremony.").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a room that has held none offers to hold one`() =
|
||||
runDesktopComposeUiTest(500, 1200) {
|
||||
var opened: Route? = null
|
||||
|
||||
setContent { Screen(state = loaded(), onNavigateToRoute = { opened = it }) }
|
||||
|
||||
onNodeWithText("This group has not held a key ceremony.").assertIsDisplayed()
|
||||
onNodeWithText("Set up a shared key").assertIsDisplayed().performClick()
|
||||
|
||||
// No session id: there is no ceremony to name, and the ceremony
|
||||
// screen is what decides whether the group can hold one at all.
|
||||
assertEquals(
|
||||
DkgRitualRoute(activeUserPublicKey = me, chatRoomId = chatRoomId),
|
||||
opened
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Screen(
|
||||
state: DkgRitualListUIState,
|
||||
onNavigateToRoute: (Route) -> Unit = {},
|
||||
) {
|
||||
MantraTheme {
|
||||
ProvideSnackbarHost {
|
||||
DkgRitualListScreen(
|
||||
activeUserPublicKey = me,
|
||||
chatRoomId = chatRoomId,
|
||||
initialDkgRitualListUIState = state,
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY,
|
||||
onNavigateBack = {},
|
||||
onNavigateToRoute = onNavigateToRoute,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loaded(vararg ceremonies: DkgRitualListUIState.Ceremony) =
|
||||
DkgRitualListUIState.Loaded(
|
||||
localChatRoom = LocalChatRoom(
|
||||
chatRoom = ChatRoom(
|
||||
id = chatRoomId,
|
||||
userPublicKey = me,
|
||||
subject = "Ekklesia",
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
)
|
||||
),
|
||||
ceremonies = ceremonies.toList()
|
||||
)
|
||||
|
||||
private fun ceremony(id: String, stage: DkgRitualStage) = DkgRitualListUIState.Ceremony(
|
||||
session = DkgSession(
|
||||
id = id,
|
||||
chatRoomId = chatRoomId,
|
||||
coordinatorPublicKey = me,
|
||||
userPublicKey = me,
|
||||
threshold = 2,
|
||||
participantCount = 2,
|
||||
hostPublicKey = me,
|
||||
round1Random = "66".repeat(32),
|
||||
round2AuxRandom = "77".repeat(32),
|
||||
stage = stage,
|
||||
),
|
||||
coordinatorName = "You",
|
||||
)
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
package press.mantra.compose.ui.view.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.delay
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.Participant
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.intermdiate.LocalParticipant
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.DkgRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.repository.NostrRepository
|
||||
import press.mantra.compose.ui.view.state.ChatMessageListUIState
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* When tapping one ceremony in the transcript is answering the whole question,
|
||||
* and when it is answering a third of it.
|
||||
*
|
||||
* The transcript names one ceremony per line and its standing notice stands for
|
||||
* however many are owed. Opening the named one is right while it is the only
|
||||
* thing being asked; it stops being right the moment there are two, because the
|
||||
* second is not on screen beside the first -- it may be pages up, or have
|
||||
* arrived while the member was reading -- so answering the one they tapped and
|
||||
* leaving would look exactly like being done.
|
||||
*
|
||||
* `hidesOtherDecisions` has made this call for proposals since before subgroups
|
||||
* existed. [ChatMessageListViewModel.hidesOtherCeremonies] is its counterpart,
|
||||
* and a room holds several ceremonies as a matter of course now that a
|
||||
* subgroup's ChillDKG runs in its parent's room.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class CeremonyQueueRoutingJvmTest {
|
||||
private val chatRoomId = "aa".repeat(32)
|
||||
private val me = "11".repeat(32)
|
||||
private val peer = "22".repeat(32)
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one ceremony owed is answered by opening it`() = runBlocking {
|
||||
val viewModel = awaiting(
|
||||
sessions = listOf(session("only", DkgRitualStage.COLLECTING_ROUND_1)),
|
||||
pending = mapOf("only" to DkgApprovalStep.ROUND_1),
|
||||
expected = 1
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
viewModel.hidesOtherCeremonies("only"),
|
||||
"the one ceremony owed was sent to a queue of itself"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two ceremonies owed send the member to the list instead`() = runBlocking {
|
||||
val viewModel = awaiting(
|
||||
sessions = listOf(
|
||||
session("ours", DkgRitualStage.COLLECTING_ROUND_1),
|
||||
session("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS)
|
||||
.copy(parentChatRoomId = chatRoomId, subject = "Translators"),
|
||||
),
|
||||
pending = mapOf(
|
||||
"ours" to DkgApprovalStep.ROUND_1,
|
||||
"subgroup" to DkgApprovalStep.HOST_KEY
|
||||
),
|
||||
expected = 2
|
||||
)
|
||||
|
||||
// Either tap: the queue is the honest answer to both, because neither is
|
||||
// the whole of what the room is waiting on this member for.
|
||||
assertTrue(viewModel.hidesOtherCeremonies("ours"))
|
||||
assertTrue(viewModel.hidesOtherCeremonies("subgroup"))
|
||||
}
|
||||
|
||||
/**
|
||||
* The restraint, and the reason this is not just "does the room owe two".
|
||||
* A line about a ceremony the group finished is history, and a reader who
|
||||
* taps one is asking to see that -- not to be handed a queue of other
|
||||
* people's business.
|
||||
*/
|
||||
@Test
|
||||
fun `a finished ceremony is opened on its own even while others are owed`() = runBlocking {
|
||||
val viewModel = awaiting(
|
||||
sessions = listOf(
|
||||
session("done", DkgRitualStage.COMPLETE),
|
||||
session("ours", DkgRitualStage.COLLECTING_ROUND_1),
|
||||
session("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS),
|
||||
),
|
||||
pending = mapOf(
|
||||
"ours" to DkgApprovalStep.ROUND_1,
|
||||
"subgroup" to DkgApprovalStep.HOST_KEY
|
||||
),
|
||||
expected = 2
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
viewModel.hidesOtherCeremonies("done"),
|
||||
"a finished ceremony's line was answered with somebody else's queue"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An abandoned one is not owed either, which matters because the room's
|
||||
* watcher filters on it and a row that slipped through would put a dead
|
||||
* ceremony at the top of the notice.
|
||||
*/
|
||||
@Test
|
||||
fun `an abandoned ceremony is never counted as owed`() = runBlocking {
|
||||
val viewModel = awaiting(
|
||||
sessions = listOf(session("abandoned", DkgRitualStage.FAILED)),
|
||||
// Says yes, and has to be ignored on a failure.
|
||||
pending = mapOf("abandoned" to DkgApprovalStep.ROUND_1),
|
||||
expected = 0
|
||||
)
|
||||
|
||||
assertTrue(viewModel.ceremoniesAwaitingYou.isEmpty())
|
||||
assertFalse(viewModel.hidesOtherCeremonies("abandoned"))
|
||||
}
|
||||
|
||||
/** Builds the view model and waits until its watcher has settled on [expected]. */
|
||||
private suspend fun awaiting(
|
||||
sessions: List<DkgSession>,
|
||||
pending: Map<String, DkgApprovalStep>,
|
||||
expected: Int,
|
||||
): ChatMessageListViewModel {
|
||||
val viewModel = ChatMessageListViewModel(
|
||||
initialChatMessageListUIState = ChatMessageListUIState.Loading,
|
||||
localChatRoom = room(),
|
||||
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
|
||||
dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY {
|
||||
override fun observeSessionsForChatRoom(
|
||||
chatRoomId: String
|
||||
): Flow<List<DkgSession>> = flowOf(sessions)
|
||||
|
||||
override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? =
|
||||
pending[session.id]
|
||||
}
|
||||
)
|
||||
|
||||
viewModel.initiate()
|
||||
|
||||
// The watcher writes once, so an empty expectation cannot be waited for
|
||||
// by its own value -- it is indistinguishable from not having run. Wait
|
||||
// for a non-empty one, and give an empty one a moment to be wrong in.
|
||||
withTimeout(5_000) {
|
||||
if (expected == 0) {
|
||||
delay(200)
|
||||
} else {
|
||||
while (viewModel.ceremoniesAwaitingYou.size != expected) delay(5)
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(expected, viewModel.ceremoniesAwaitingYou.size)
|
||||
return viewModel
|
||||
}
|
||||
|
||||
private fun room() = LocalChatRoom(
|
||||
chatRoom = ChatRoom(
|
||||
id = chatRoomId,
|
||||
userPublicKey = me,
|
||||
subject = "Ekklesia",
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
),
|
||||
localParticipants = listOf(me, peer).map { publicKey ->
|
||||
LocalParticipant(
|
||||
participant = Participant(
|
||||
participantPublicKey = publicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = null,
|
||||
),
|
||||
profile = null,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
private fun session(id: String, stage: DkgRitualStage) = DkgSession(
|
||||
id = id,
|
||||
chatRoomId = chatRoomId,
|
||||
coordinatorPublicKey = peer,
|
||||
userPublicKey = me,
|
||||
threshold = 2,
|
||||
participantCount = 2,
|
||||
hostPublicKey = peer,
|
||||
round1Random = "66".repeat(32),
|
||||
round2AuxRandom = "77".repeat(32),
|
||||
stage = stage,
|
||||
)
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
package press.mantra.compose.ui.view.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.Participant
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.intermdiate.LocalParticipant
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.DkgRepository
|
||||
import press.mantra.compose.ui.view.state.DkgRitualListUIState
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* What the key-ceremony list decides that nothing else can check.
|
||||
*
|
||||
* **Which ceremonies come first.** A room holds one per subgroup the group has
|
||||
* ever made plus its own, and the newest is routinely the finished one -- so
|
||||
* the list is only worth having if the two that want something from the reader
|
||||
* are at the top of it rather than wherever their dates put them.
|
||||
*
|
||||
* **What counts as wanting something.** Two different things do, owed by
|
||||
* different people: an approval gate this member cannot delegate, and a
|
||||
* finished ceremony whose room only its coordinator can create. An abandoned
|
||||
* ceremony wants neither, whatever the rows underneath it say.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DkgRitualListViewModelJvmTest {
|
||||
private val chatRoomId = "a1b2c3d4".repeat(8)
|
||||
private val me = "11".repeat(32)
|
||||
private val ada = "22".repeat(32)
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the ceremonies waiting on this member are separated from the rest`() = runBlocking {
|
||||
val mine = session("mine", DkgRitualStage.COLLECTING_ROUND_1)
|
||||
val finished = session("finished", DkgRitualStage.COMPLETE)
|
||||
val subgroup = session("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS)
|
||||
.copy(parentChatRoomId = "parent", subject = "Translators")
|
||||
|
||||
val loaded = load(
|
||||
// Newest first, the way the query returns them -- and the one that
|
||||
// wants something is deliberately not the newest.
|
||||
sessions = listOf(finished, subgroup, mine),
|
||||
pending = mapOf(
|
||||
"mine" to DkgApprovalStep.ROUND_1,
|
||||
"subgroup" to DkgApprovalStep.HOST_KEY
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("subgroup", "mine"),
|
||||
loaded.waitingForYou.map { it.session.id },
|
||||
"a ceremony waiting on this member was left down the list"
|
||||
)
|
||||
assertEquals(listOf("finished"), loaded.rest.map { it.session.id })
|
||||
assertTrue(loaded.waitingForYou.single { it.session.id == "subgroup" }.isSubgroup)
|
||||
}
|
||||
|
||||
/**
|
||||
* A finished ceremony still owes its coordinator a room, and owes everybody
|
||||
* else nothing -- a row telling the rest of the group to create it would be
|
||||
* telling them to do something they cannot.
|
||||
*/
|
||||
@Test
|
||||
fun `only the coordinator is told to finish setting up a finished ceremony`() = runBlocking {
|
||||
val theirs = session("theirs", DkgRitualStage.COMPLETE).copy(coordinatorPublicKey = ada)
|
||||
val ours = session("ours", DkgRitualStage.COMPLETE)
|
||||
|
||||
val loaded = load(
|
||||
sessions = listOf(theirs, ours),
|
||||
roomAwaitingCreation = "theRoomItDerives"
|
||||
)
|
||||
|
||||
assertEquals(listOf("ours"), loaded.waitingForYou.map { it.session.id })
|
||||
assertEquals("Ada", loaded.rest.single().coordinatorName)
|
||||
assertEquals("You", loaded.waitingForYou.single().coordinatorName)
|
||||
}
|
||||
|
||||
/**
|
||||
* An abandoned ceremony is asked nothing at all. There is no step left to
|
||||
* approve and no key to build a room from, so a row that claimed otherwise
|
||||
* would send a member to a screen with nothing on it.
|
||||
*/
|
||||
@Test
|
||||
fun `an abandoned ceremony is never waiting on anybody`() = runBlocking {
|
||||
val loaded = load(
|
||||
sessions = listOf(session("abandoned", DkgRitualStage.FAILED)),
|
||||
// Both answers say yes, and both have to be ignored on a failure.
|
||||
pending = mapOf("abandoned" to DkgApprovalStep.ROUND_1),
|
||||
roomAwaitingCreation = "theRoomItDerives"
|
||||
)
|
||||
|
||||
assertTrue(loaded.waitingForYou.isEmpty())
|
||||
|
||||
val row = loaded.rest.single()
|
||||
assertNull(row.pendingApproval)
|
||||
assertNull(row.roomAwaitingCreation)
|
||||
}
|
||||
|
||||
private suspend fun load(
|
||||
sessions: List<DkgSession>,
|
||||
pending: Map<String, DkgApprovalStep> = emptyMap(),
|
||||
roomAwaitingCreation: String? = null,
|
||||
): DkgRitualListUIState.Loaded {
|
||||
val viewModel = DkgRitualListViewModel(
|
||||
chatRoomId = chatRoomId,
|
||||
activeUserPublicKey = me,
|
||||
initialDkgRitualListUIState = DkgRitualListUIState.Loading,
|
||||
chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY {
|
||||
override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = room()
|
||||
},
|
||||
dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY {
|
||||
override fun observeSessionsForChatRoom(
|
||||
chatRoomId: String
|
||||
): Flow<List<DkgSession>> = flowOf(sessions)
|
||||
|
||||
override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? =
|
||||
pending[session.id]
|
||||
|
||||
override suspend fun roomAwaitingCreation(session: DkgSession): String? =
|
||||
roomAwaitingCreation
|
||||
}
|
||||
)
|
||||
|
||||
viewModel.initiate()
|
||||
|
||||
return withTimeout(5_000) {
|
||||
viewModel.dkgRitualListUIState.first { it is DkgRitualListUIState.Loaded }
|
||||
} as DkgRitualListUIState.Loaded
|
||||
}
|
||||
|
||||
private fun room() = LocalChatRoom(
|
||||
chatRoom = ChatRoom(
|
||||
id = chatRoomId,
|
||||
userPublicKey = me,
|
||||
subject = "Ekklesia",
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
),
|
||||
localParticipants = listOf(me to null, ada to "Ada").map { (publicKey, name) ->
|
||||
LocalParticipant(
|
||||
participant = Participant(
|
||||
participantPublicKey = publicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = null,
|
||||
),
|
||||
profile = name?.let {
|
||||
Profile(
|
||||
publicKey = publicKey,
|
||||
displayName = it,
|
||||
nostrEventId = "ee".repeat(32),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
private fun session(id: String, stage: DkgRitualStage) = DkgSession(
|
||||
id = id,
|
||||
chatRoomId = chatRoomId,
|
||||
coordinatorPublicKey = me,
|
||||
userPublicKey = me,
|
||||
threshold = 2,
|
||||
participantCount = 2,
|
||||
hostPublicKey = me,
|
||||
round1Random = "66".repeat(32),
|
||||
round2AuxRandom = "77".repeat(32),
|
||||
stage = stage,
|
||||
)
|
||||
}
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
package press.mantra.compose.ui.view.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.FrostSigningItem
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.Participant
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
|
||||
import press.mantra.compose.database.model.intermdiate.LocalParticipant
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.database.model.types.FrostSigningStage
|
||||
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.DkgRepository
|
||||
import press.mantra.compose.ui.view.state.DkgRitualUIState
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* A parent's room holds every one of its subgroups' signings, and the ceremony
|
||||
* screen has to pick out the one it is showing.
|
||||
*
|
||||
* Since a subgroup's ChillDKG moved into its parent's Marmot room, both of the
|
||||
* rungs after the ceremony run there too: the birth certificate is the parent's
|
||||
* signature, and the key state is signed over the parent's room because the
|
||||
* subgroup's own room does not exist yet. So a parent making a second subgroup
|
||||
* has two of each in one room, told apart by nothing but the ceremony they
|
||||
* belong to.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DkgRitualSecondSubgroupJvmTest {
|
||||
private val parentChatRoomId = "aa".repeat(32)
|
||||
private val me = "11".repeat(32)
|
||||
private val peer = "22".repeat(32)
|
||||
|
||||
// Real points, in the compressed form `SharedKeyDerivation` reads: it derives
|
||||
// a room id off the curve, and a made-up 32 bytes is not on it.
|
||||
private val firstKey = "02" + KeyPair().pubKey.toHexKey()
|
||||
private val secondKey = "02" + KeyPair().pubKey.toHexKey()
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the certificate shown is this subgroup's, not the parent's newest`() = runBlocking {
|
||||
val loaded = load(
|
||||
signingSessions = listOf(
|
||||
// Newest first, the way the query returns them.
|
||||
certificateSigning("forSecond", subgroupOf(secondKey)),
|
||||
certificateSigning("forFirst", subgroupOf(firstKey)),
|
||||
),
|
||||
until = { it.certificateSession != null }
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"forFirst",
|
||||
loaded.certificateSession?.id,
|
||||
"the screen for one subgroup is following another subgroup's certificate"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the key state shown is this ceremony's, not the room's newest`() = runBlocking {
|
||||
val loaded = load(
|
||||
signingSessions = listOf(
|
||||
keyStateSigning("forSecond", dkgSessionId = "secondCeremony"),
|
||||
keyStateSigning("forFirst", dkgSessionId = "firstCeremony"),
|
||||
),
|
||||
until = { it.keyStateSession != null }
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"forFirst",
|
||||
loaded.keyStateSession?.id,
|
||||
"the screen for one ceremony is following another ceremony's key state"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The damaging half, and the reason this is a block rather than a cosmetic
|
||||
* mix-up. Both rungs disable their button while a signing of that kind is
|
||||
* running and un-failed, and a *completed* signing is un-failed forever --
|
||||
* so by kind alone, a parent that had certified one subgroup could never be
|
||||
* asked to certify another, and the rung sat there disabled with no way out.
|
||||
*/
|
||||
@Test
|
||||
fun `a sibling's finished signings leave this ceremony's rungs open`() = runBlocking {
|
||||
// Driven in two emissions rather than one, so the assertion is about a
|
||||
// watcher that demonstrably ran. Asserting "still null" against a single
|
||||
// emission passes before the watcher has started, which is how a version
|
||||
// of this test passed against the bug it was written for.
|
||||
val signingSessions = MutableStateFlow(
|
||||
listOf(
|
||||
certificateSigning("mine", subgroupOf(firstKey)),
|
||||
keyStateSigning("myKeyState", dkgSessionId = "firstCeremony"),
|
||||
)
|
||||
)
|
||||
|
||||
val viewModel = viewModel(signingSessions)
|
||||
viewModel.initiate()
|
||||
|
||||
awaitLoaded(viewModel) {
|
||||
it.certificateSession?.id == "mine" && it.keyStateSession?.id == "myKeyState"
|
||||
}
|
||||
|
||||
// Now the room holds only a sibling's, both finished -- which is every
|
||||
// parent that has already made one subgroup and is making its second.
|
||||
signingSessions.value = listOf(
|
||||
certificateSigning(
|
||||
"siblingCertificate",
|
||||
subgroupOf(secondKey),
|
||||
stage = FrostSigningStage.COMPLETE
|
||||
),
|
||||
// A key state naming another ceremony: the sibling's, or this parent's
|
||||
// own from an adoption, which is signed in this room too.
|
||||
keyStateSigning(
|
||||
"siblingKeyState",
|
||||
dkgSessionId = "secondCeremony",
|
||||
stage = FrostSigningStage.COMPLETE
|
||||
),
|
||||
)
|
||||
|
||||
val loaded = awaitLoaded(viewModel) {
|
||||
it.certificateSession == null && it.keyStateSession == null
|
||||
}
|
||||
|
||||
// Both rungs gate on these being null or failed, and a completed signing
|
||||
// is never failed -- so a sibling left in either of them shuts the rung
|
||||
// for good.
|
||||
assertNull(loaded.certificateSession)
|
||||
assertNull(loaded.keyStateSession)
|
||||
}
|
||||
|
||||
private suspend fun load(
|
||||
signingSessions: List<LocalFrostSigningSession>,
|
||||
until: (DkgRitualUIState.Loaded) -> Boolean,
|
||||
): DkgRitualUIState.Loaded {
|
||||
val viewModel = viewModel(MutableStateFlow(signingSessions))
|
||||
viewModel.initiate()
|
||||
return awaitLoaded(viewModel, until)
|
||||
}
|
||||
|
||||
private suspend fun awaitLoaded(
|
||||
viewModel: DkgRitualViewModel,
|
||||
until: (DkgRitualUIState.Loaded) -> Boolean,
|
||||
): DkgRitualUIState.Loaded = withTimeout(5_000) {
|
||||
viewModel.dkgRitualUIState.first {
|
||||
(it as? DkgRitualUIState.Loaded)?.let(until) == true
|
||||
}
|
||||
} as DkgRitualUIState.Loaded
|
||||
|
||||
/** The screen is always showing the FIRST subgroup's ceremony. */
|
||||
private fun viewModel(
|
||||
signingSessions: MutableStateFlow<List<LocalFrostSigningSession>>
|
||||
): DkgRitualViewModel {
|
||||
val ceremony = DkgSession(
|
||||
id = "firstCeremony",
|
||||
chatRoomId = parentChatRoomId,
|
||||
coordinatorPublicKey = me,
|
||||
userPublicKey = me,
|
||||
threshold = 2,
|
||||
participantCount = 2,
|
||||
hostPublicKey = me,
|
||||
round1Random = "66".repeat(32),
|
||||
round2AuxRandom = "77".repeat(32),
|
||||
stage = DkgRitualStage.COMPLETE,
|
||||
thresholdPublicKey = firstKey,
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
subject = "Translators",
|
||||
)
|
||||
|
||||
return DkgRitualViewModel(
|
||||
chatRoomId = parentChatRoomId,
|
||||
activeUserPublicKey = me,
|
||||
dkgSessionId = ceremony.id,
|
||||
initialDkgRitualUIState = DkgRitualUIState.Loading,
|
||||
activeIdentityStateFlow = MutableStateFlow(null),
|
||||
chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY {
|
||||
override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = room()
|
||||
},
|
||||
dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY {
|
||||
override suspend fun roomDerivedBy(dkgSessionId: String): String? =
|
||||
if (dkgSessionId == ceremony.id) subgroupOf(firstKey) else null
|
||||
|
||||
override fun observeSessionById(sessionId: String): Flow<DkgSession?> =
|
||||
flowOf(ceremony)
|
||||
|
||||
override fun observeLatestSessionForChatRoom(
|
||||
chatRoomId: String,
|
||||
parentChatRoomId: String?,
|
||||
): Flow<DkgSession?> = flowOf(ceremony)
|
||||
|
||||
override fun observeSigningSessions(
|
||||
chatRoomId: String
|
||||
): Flow<List<LocalFrostSigningSession>> = signingSessions
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** The room id a ceremony's key derives, which is what a certificate names. */
|
||||
private fun subgroupOf(thresholdPublicKey: HexKey): String =
|
||||
press.mantra.compose.managers.SharedKeyDerivation.marmotGroupId(thresholdPublicKey)
|
||||
|
||||
private fun certificateSigning(
|
||||
id: String,
|
||||
subgroupChatRoomId: String,
|
||||
stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
|
||||
) = signing(
|
||||
id = id,
|
||||
stage = stage,
|
||||
event = Event(
|
||||
id = "ee".repeat(32),
|
||||
pubKey = me,
|
||||
createdAt = 0,
|
||||
kind = SubgroupBirthCertificateEvent.KIND,
|
||||
tags = SubgroupBirthCertificateEvent.assembleTags(
|
||||
subgroupChatRoomId = subgroupChatRoomId,
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
thresholdPublicKey = firstKey,
|
||||
adminPublicKeys = listOf(me, peer),
|
||||
name = "Translators",
|
||||
),
|
||||
content = subgroupChatRoomId,
|
||||
sig = "ff".repeat(64),
|
||||
)
|
||||
)
|
||||
|
||||
private fun keyStateSigning(
|
||||
id: String,
|
||||
dkgSessionId: String,
|
||||
stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
|
||||
) = signing(
|
||||
id = id,
|
||||
stage = stage,
|
||||
event = Event(
|
||||
id = "ee".repeat(32),
|
||||
pubKey = me,
|
||||
createdAt = 0,
|
||||
kind = GroupKeyStateEvent.KIND,
|
||||
tags = GroupKeyStateEvent.assembleTags(
|
||||
chatRoomId = subgroupOf(firstKey),
|
||||
dkgSessionId = dkgSessionId,
|
||||
),
|
||||
content = firstKey,
|
||||
sig = "ff".repeat(64),
|
||||
)
|
||||
)
|
||||
|
||||
private fun signing(
|
||||
id: String,
|
||||
event: Event,
|
||||
stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
|
||||
) = LocalFrostSigningSession(
|
||||
session = FrostSigningSession(
|
||||
id = id,
|
||||
stage = stage,
|
||||
chatRoomId = parentChatRoomId,
|
||||
coordinatorPublicKey = me,
|
||||
userPublicKey = me,
|
||||
dkgSessionId = "firstCeremony",
|
||||
threshold = 2,
|
||||
participantCount = 2,
|
||||
signerId = 0,
|
||||
),
|
||||
unorderedItems = listOf(
|
||||
FrostSigningItem(
|
||||
sessionId = id,
|
||||
itemIndex = 0,
|
||||
unsignedEventJson = event.toJson(),
|
||||
eventId = event.id,
|
||||
nonceRandom = "55".repeat(32),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
private fun room() = LocalChatRoom(
|
||||
chatRoom = ChatRoom(
|
||||
id = parentChatRoomId,
|
||||
userPublicKey = me,
|
||||
subject = "Ekklesia",
|
||||
description = null,
|
||||
mlsGroupState = "state",
|
||||
),
|
||||
localParticipants = listOf(me, peer).map { publicKey ->
|
||||
LocalParticipant(
|
||||
participant = Participant(
|
||||
participantPublicKey = publicKey,
|
||||
chatRoomId = parentChatRoomId,
|
||||
relayHint = null,
|
||||
),
|
||||
profile = null,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
package press.mantra.compose.ui.view.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.FrostSigningItem
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.Participant
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
|
||||
import press.mantra.compose.database.model.intermdiate.LocalParticipant
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.DkgRepository
|
||||
import press.mantra.compose.ui.view.state.DkgRitualUIState
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
/**
|
||||
* The two ways the key-ceremony screen came to offer to start the ceremony it
|
||||
* was already showing.
|
||||
*
|
||||
* "Start key ceremony" is the whole no-ceremony screen -- the explainer, the
|
||||
* quorum picker and the button -- so anything that leaves the state saying
|
||||
* there is no ceremony puts that offer in front of a member watching one run.
|
||||
* Both of these did, and both are invisible to a test that only asks the view
|
||||
* model what it settles on: the state was right before and right after, and
|
||||
* wrong in between for long enough to read and to tap.
|
||||
*
|
||||
* Neither is about ChillDKG. They are about one `DkgRitualUIState` being
|
||||
* assembled by seven flows at once, which is why they are tested here rather
|
||||
* than against the ritual.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DkgRitualStartOfferJvmTest {
|
||||
private val chatRoomId = "a1b2c3d4".repeat(8)
|
||||
private val me = "11".repeat(32)
|
||||
private val peer = "22".repeat(32)
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
/**
|
||||
* The first loaded state already knows the room is running a ceremony.
|
||||
*
|
||||
* The screen used to publish a bare `Loaded` and then wait for the rows,
|
||||
* and a `Loaded` with no session and no ceremony *is* the offer to start
|
||||
* one. On a cold open -- the only time the queries are slow -- that is
|
||||
* exactly when it was shown.
|
||||
*/
|
||||
@Test
|
||||
fun `the offer to start is not published while the rows are still being read`() = runBlocking {
|
||||
val session = session(DkgRitualStage.COLLECTING_HOST_KEYS)
|
||||
|
||||
val viewModel = viewModel(
|
||||
object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY {
|
||||
// Slow on purpose: a Room query answers in microseconds and
|
||||
// would hide the window rather than prove it is gone.
|
||||
override fun observeLatestSessionForChatRoom(
|
||||
chatRoomId: String,
|
||||
parentChatRoomId: String?,
|
||||
): Flow<DkgSession?> = flow {
|
||||
delay(50)
|
||||
emit(session)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
viewModel.initiate()
|
||||
|
||||
val firstLoaded = withTimeout(5_000) {
|
||||
viewModel.dkgRitualUIState.first { it is DkgRitualUIState.Loaded }
|
||||
} as DkgRitualUIState.Loaded
|
||||
|
||||
assertEquals(session.id, firstLoaded.session?.id, "the first loaded state missed the ceremony")
|
||||
assertFalse(
|
||||
viewModel.canStartRitual(firstLoaded),
|
||||
"offered to start a ceremony on a room already running one"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A write that suspends between reading the state and writing it back does
|
||||
* not carry the stale copy over somebody else's change.
|
||||
*
|
||||
* Every flow folding into this state used to read it, build a `copy` and
|
||||
* assign -- and the session watcher asks the database what this member owes
|
||||
* in the middle of that. Whoever wrote during that pause had their change
|
||||
* thrown away by the assignment that followed. The same pause in the other
|
||||
* direction is what put `session` back to null and offered to start the
|
||||
* ceremony on screen; this is the half of it that can be forced to happen
|
||||
* rather than raced into.
|
||||
*
|
||||
* The pair used here is the session watcher against the room watcher, which
|
||||
* are the two that are genuinely concurrent: both are collecting before
|
||||
* either has written, and neither waits on the other. `roomHasCeremony` is
|
||||
* the field they fight over, and it is the one that decides the offer.
|
||||
*/
|
||||
@Test
|
||||
fun `a change written during a suspension survives the write that follows it`() = runBlocking {
|
||||
val live = session(DkgRitualStage.COLLECTING_ROUND_1)
|
||||
val abandoned = session(DkgRitualStage.FAILED)
|
||||
|
||||
val roomSessions = MutableStateFlow<DkgSession?>(live)
|
||||
|
||||
// Opened once the test has had the room watcher write. The seeding call
|
||||
// runs before anything else is collecting, so it is let through; the
|
||||
// session watcher's call is the one that waits, and `parked` says when
|
||||
// it is in there.
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
val parked = CompletableDeferred<Unit>()
|
||||
var calls = 0
|
||||
|
||||
val viewModel = viewModel(
|
||||
object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY {
|
||||
override fun observeLatestSessionForChatRoom(
|
||||
chatRoomId: String,
|
||||
parentChatRoomId: String?,
|
||||
): Flow<DkgSession?> = roomSessions
|
||||
|
||||
override fun observeSessionById(sessionId: String): Flow<DkgSession?> =
|
||||
flowOf(live)
|
||||
|
||||
override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? {
|
||||
calls += 1
|
||||
if (calls == 1) return null
|
||||
|
||||
parked.complete(Unit)
|
||||
gate.await()
|
||||
return DkgApprovalStep.ROUND_1
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
viewModel.initiate()
|
||||
|
||||
awaitLoaded(viewModel) { it.roomHasCeremony }
|
||||
|
||||
// The session watcher is now inside `pendingApproval`, holding -- in the
|
||||
// shape this used to be written in -- a copy of the state as it was
|
||||
// before the next two lines.
|
||||
withTimeout(5_000) { parked.await() }
|
||||
roomSessions.value = abandoned
|
||||
awaitLoaded(viewModel) { !it.roomHasCeremony }
|
||||
|
||||
gate.complete(Unit)
|
||||
|
||||
val settled = awaitLoaded(viewModel) {
|
||||
it.pendingApproval == DkgApprovalStep.ROUND_1
|
||||
}
|
||||
|
||||
assertFalse(
|
||||
settled.roomHasCeremony,
|
||||
"the session watcher wrote back a copy taken before the room watcher's change"
|
||||
)
|
||||
assertEquals(live.id, settled.session?.id)
|
||||
}
|
||||
|
||||
private suspend fun awaitLoaded(
|
||||
viewModel: DkgRitualViewModel,
|
||||
until: (DkgRitualUIState.Loaded) -> Boolean,
|
||||
): DkgRitualUIState.Loaded = withTimeout(5_000) {
|
||||
viewModel.dkgRitualUIState.first {
|
||||
(it as? DkgRitualUIState.Loaded)?.let(until) == true
|
||||
}
|
||||
} as DkgRitualUIState.Loaded
|
||||
|
||||
private fun viewModel(dkgRepository: DkgRepository) = DkgRitualViewModel(
|
||||
chatRoomId = chatRoomId,
|
||||
activeUserPublicKey = me,
|
||||
initialDkgRitualUIState = DkgRitualUIState.Loading,
|
||||
activeIdentityStateFlow = MutableStateFlow(null),
|
||||
chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY {
|
||||
override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = room()
|
||||
},
|
||||
dkgRepository = dkgRepository
|
||||
)
|
||||
|
||||
private fun room() = LocalChatRoom(
|
||||
chatRoom = ChatRoom(
|
||||
id = chatRoomId,
|
||||
userPublicKey = me,
|
||||
subject = "Ekklesia",
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
),
|
||||
localParticipants = listOf(me, peer).map { publicKey ->
|
||||
LocalParticipant(
|
||||
participant = Participant(
|
||||
participantPublicKey = publicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = null,
|
||||
),
|
||||
profile = null,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
private fun session(stage: DkgRitualStage) = DkgSession(
|
||||
id = "ceremony",
|
||||
chatRoomId = chatRoomId,
|
||||
coordinatorPublicKey = me,
|
||||
userPublicKey = me,
|
||||
threshold = 2,
|
||||
participantCount = 2,
|
||||
hostPublicKey = me,
|
||||
round1Random = "66".repeat(32),
|
||||
round2AuxRandom = "77".repeat(32),
|
||||
stage = stage,
|
||||
)
|
||||
|
||||
private fun keyStateSigningSession() = LocalFrostSigningSession(
|
||||
session = FrostSigningSession(
|
||||
id = "signing",
|
||||
chatRoomId = chatRoomId,
|
||||
coordinatorPublicKey = me,
|
||||
userPublicKey = me,
|
||||
dkgSessionId = "ceremony",
|
||||
threshold = 2,
|
||||
participantCount = 2,
|
||||
signerId = 0,
|
||||
),
|
||||
unorderedItems = listOf(
|
||||
FrostSigningItem(
|
||||
sessionId = "signing",
|
||||
itemIndex = 0,
|
||||
unsignedEventJson = """{"id":"${"33".repeat(32)}","pubkey":"$me",""" +
|
||||
""""created_at":0,"kind":30326,"tags":[],"content":"","sig":"${"44".repeat(64)}"}""",
|
||||
eventId = "33".repeat(32),
|
||||
nonceRandom = "55".repeat(32),
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user