feat: hold the ritual until its owner approves each step

The ChillDKG ritual ran entirely on its own. `acceptProposal` published this
device's host key the moment a PROPOSAL arrived from a relay, and `advance`
published rounds 1 and 2 as soon as their inputs landed. Receiving a nostr event
was therefore enough to enrol the owner of a phone in a group's permanent signing
quorum, without anything having been shown to them first.

Nothing of this device's own now goes out before its owner says so. Three
approvals, because each publishes something different and commits the member to
something different:

  host key  joins the ceremony, and fixes n. A member who joins and then stops
            answering does not merely fail to help -- the ritual cannot finish
            without every member, so they hold it open for everybody.
  round 1   contributes to the key itself. The member's own secret material
            starts shaping a key they will be expected to help sign with.
  round 2   confirms the coordinator's combined result matches what this device
            sent. A check rather than a formality: it is what stops a coordinator
            substituting a key the members never contributed to.

The coordinator's two aggregations are deliberately not gated. They relay other
members' already-published messages and disclose nothing of the coordinator's own,
so an approval there would stall the whole group on one person's attention without
protecting anybody. The member who opens a ceremony is auto-approved for the host
key alone -- starting one is already the act of agreeing to be in it -- and is
still asked for rounds 1 and 2, which publish key material.

Each gate returns rather than throwing. The ritual is not failing, it is waiting
on a person; everything already received stays stored, so it resumes the moment
they approve. `pendingApproval` mirrors those gates exactly and has to keep doing
so: if the two disagree the screen offers an approval that does nothing, or none
while the ritual sits still.

Schema v2 -> v3 adds four nullable columns to DkgSession -- three approval
timestamps and `approvalRequestedThrough` -- so Room generates the migration. A
ritual already in flight comes back with all three null, which reads as "not
approved yet" and simply asks, rather than silently continuing.

## Being asked

Three screens rather than one parameterised by step, because each is making a
different case and the copy is the substance of the screen, not decoration around
it. They share a scaffold for one reason that is not cosmetic: a screen opened for
one step can go stale -- a redelivery carries the ritual forward, or the member
approves on another device -- so it re-checks the pending step before offering a
button, and `approve` checks again in the manager and ignores a mismatch.

"Not now" does not refuse on the member's behalf. There is no "no" in ChillDKG
short of abandoning the ceremony, and quietly leaving is what a member who is not
ready actually wants; abandoning stays on the ritual screen where the consequence
can be spelled out.

A chat line announces each request, written once per step and guarded by
`approvalRequestedThrough` -- `advance` runs on every arriving message and would
otherwise ask again on each one. It is the one ritual line that asks rather than
reports, so it is the one that is not quiet: primary tint, a Review affordance,
and a tap through to the ritual screen, whose bottom bar routes to the step the
ceremony is actually waiting on.

## Telling the steps apart

The request started as a single message type, which meant one icon for all three
and no way to tell "join the ceremony" from "confirm the key". The type is the
only thing a transcript keeps -- a line drawn days later has no session to ask
what was being requested -- so the step moved into it, one type per step, and
every stage now carries its own icon.

MIGRATION_3_4 rewrites the rows already written. They cannot regenerate: a request
is announced once, so a ceremony already in flight would keep its undifferentiated
icons forever. It changes no schema at all -- the version bump exists only to give
a data rewrite somewhere to run, which is why it is a manual migration on the
builder rather than another AutoMigration. Rows it cannot match keep the old type,
which the renderer still recognises.

An answered request shows a checkmark where Review was. Whether it was answered
comes from the transcript rather than the session: approving is the only thing
that causes the step to be published, and publishing writes an authored line, so a
matching line at or after the request means done. That keeps a room that has run
more than one ceremony correct -- ChatMessage has no session id to disambiguate
with -- and needs no DkgRepository in the message list. The comparison is on
createdAt rather than list position, because the list is ORDER BY createdAt DESC
with reverseLayout, where index arithmetic runs backwards.

Compiles and assembles; the ordering test still passes. No ritual has been run on
a device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-05 13:43:46 +02:00
parent 6a5b6cd6cb
commit cae50ce359
23 changed files with 11237 additions and 14 deletions

View File

@@ -164,11 +164,20 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
UnsignedNostrEvent::class,
Zap::class
],
version = 2,
version = 4,
autoMigrations = [
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
// generate the migration itself — nothing existing changes shape.
AutoMigration(from = 1, to = 2)
AutoMigration(from = 1, to = 2),
// v3 adds the three nullable approval timestamps to DkgSession. Nullable
// additions need no default and drop no data, so Room generates this one
// too. Rituals already in flight come back with all three null, which reads
// as "not approved yet" and simply asks the member for each step.
AutoMigration(from = 2, to = 3)
// v4 changes no schema at all -- it rewrites `dkgApprovalNeeded` chat rows
// into one type per ritual step. Data, not shape, so it is a manual
// migration passed to the builder rather than an entry here. See
// MIGRATION_3_4.
]
)
@ColumnTypeConverters(MantraConverters::class)

View File

@@ -2,6 +2,7 @@ package press.mantra.compose.database.builder
import androidx.room3.RoomDatabase
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import press.mantra.compose.database.migrations.MIGRATION_3_4
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
@@ -15,6 +16,9 @@ fun getRoomDatabase(
builder: RoomDatabase.Builder<press.mantra.compose.database.MantraDatabase>
): press.mantra.compose.database.MantraDatabase {
return builder
// Everything else Room generates itself; this one rewrites rows rather than
// changing shape, which an AutoMigration cannot express.
.addMigrations(MIGRATION_3_4)
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.build()

View File

@@ -0,0 +1,51 @@
package press.mantra.compose.database.migrations
import androidx.room3.migration.Migration
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.execSQL
import press.mantra.compose.database.model.ChatMessage
/**
* Splits the single `dkgApprovalNeeded` chat line into one type per ritual step.
*
* The rows carried no step, so every request rendered with the same icon and a
* reader could not tell "join the ceremony" from "confirm the key". The type is
* the only thing the transcript keeps — a line drawn days later has no session to
* ask — so the step had to move into it.
*
* Rewriting the existing rows rather than leaving them to the renderer's fallback,
* because they cannot be regenerated: a request is announced once, guarded by
* `DkgSession.approvalRequestedThrough`, so a ritual already in flight will never
* write its request lines again. Without this, any ceremony started before the
* split keeps its undifferentiated icons forever.
*
* Matched on content because that is the only thing distinguishing them, and the
* three strings were written by this app one version ago. A row that matches none
* of them keeps the old type, which the renderer still recognises — see
* [ChatMessage.TYPE_DKG_APPROVAL_NEEDED_LEGACY]. This changes no schema; the
* version bump exists only to give the data rewrite somewhere to run.
*/
val MIGRATION_3_4 = object : Migration(3, 4) {
override suspend fun migrate(connection: SQLiteConnection) {
suspend fun rewrite(newType: String, contentPrefix: String) {
connection.execSQL(
"UPDATE ChatMessage SET messageType = '$newType' " +
"WHERE messageType = '${ChatMessage.TYPE_DKG_APPROVAL_NEEDED_LEGACY}' " +
"AND content LIKE '$contentPrefix%'"
)
}
rewrite(
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_HOST_KEY,
"Your approval is needed to join the shared key ceremony."
)
rewrite(
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1,
"Everyone has joined the shared key ceremony."
)
rewrite(
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_2,
"The contributions have been combined."
)
}
}

View File

@@ -110,6 +110,50 @@ data class ChatMessage(
const val TYPE_DKG_COMPLETE = "dkgComplete"
const val TYPE_DKG_FAILED = "dkgFailed"
/**
* The ritual is waiting on this device's owner and will publish nothing
* until they act. Unauthored on purpose: these are addressed to the reader
* rather than said by anyone, so they read "Your approval is needed", not
* "You your approval is needed".
*
* One type per step rather than one for all three, because the row is the
* only thing the transcript keeps: a line rendered days later has no session
* to ask what was being requested, so which step it was has to be in the
* type. Sharing one type left every request wearing the same icon.
*/
const val TYPE_DKG_APPROVAL_NEEDED_HOST_KEY = "dkgApprovalNeededHostKey"
const val TYPE_DKG_APPROVAL_NEEDED_ROUND_1 = "dkgApprovalNeededRound1"
const val TYPE_DKG_APPROVAL_NEEDED_ROUND_2 = "dkgApprovalNeededRound2"
/**
* The single type the three above replaced. Still recognised so rows already
* written keep rendering as system lines instead of turning into chat
* bubbles attributed to the reader. Nothing writes it any more.
*/
const val TYPE_DKG_APPROVAL_NEEDED_LEGACY = "dkgApprovalNeeded"
/**
* The line each request is answered by: the step it asked for, published by
* this device. Approving is the only thing that causes that to be written,
* so its presence is what marks a request done.
*
* The legacy type is absent on purpose -- it does not say which step it was
* asking for, so nothing can be said about whether it was answered.
*/
val DKG_REQUEST_FULFILMENTS = mapOf(
TYPE_DKG_APPROVAL_NEEDED_HOST_KEY to TYPE_DKG_HOST_KEY,
TYPE_DKG_APPROVAL_NEEDED_ROUND_1 to TYPE_DKG_ROUND_1,
TYPE_DKG_APPROVAL_NEEDED_ROUND_2 to TYPE_DKG_ROUND_2,
)
/** The lines that are asking rather than reporting. */
val DKG_REQUEST_TYPES = setOf(
TYPE_DKG_APPROVAL_NEEDED_HOST_KEY,
TYPE_DKG_APPROVAL_NEEDED_ROUND_1,
TYPE_DKG_APPROVAL_NEEDED_ROUND_2,
TYPE_DKG_APPROVAL_NEEDED_LEGACY,
)
val DKG_TYPES = setOf(
TYPE_DKG_STARTED,
TYPE_DKG_HOST_KEY,
@@ -119,7 +163,7 @@ data class ChatMessage(
TYPE_DKG_CERTIFICATE,
TYPE_DKG_COMPLETE,
TYPE_DKG_FAILED,
)
) + DKG_REQUEST_TYPES
/**
* The ritual lines somebody did, as opposed to ones that simply happened.

View File

@@ -6,6 +6,7 @@ import androidx.room3.Index
import androidx.room3.PrimaryKey
import press.mantra.compose.database.model.traits.LocalStoreEntity
import press.mantra.compose.database.model.traits.TimestampedEntity
import press.mantra.compose.database.model.types.DkgApprovalStep
import press.mantra.compose.database.model.types.DkgRitualStage
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlin.time.Clock
@@ -86,6 +87,39 @@ data class DkgSession(
val failureReason: String? = null,
/**
* When this device's owner approved publishing their host key, and with it
* joining the ceremony at all. Null until they do, and nothing of theirs goes
* out before it is set.
*
* The ritual is otherwise driven entirely by arriving messages, which would
* mean a member is enrolled in the group's permanent signing quorum by a relay
* delivering an event to a phone in their pocket. Each of these three is a
* separate decision because each publishes something different and commits the
* member to something different -- see `ChillDkgRitualManager.pendingApproval`.
*
* Set at creation for the member who opened the ceremony: starting one is
* already the act of agreeing to be in it.
*/
val hostKeyApprovedAt: Instant? = null,
/** When they approved publishing their contribution to the key. */
val round1ApprovedAt: Instant? = null,
/** When they approved signing to confirm the combined result. */
val round2ApprovedAt: Instant? = null,
/**
* The furthest step this device has already asked its owner about, so the chat
* line asking for it is written once.
*
* One column rather than a flag per step because the steps are strictly
* ordered: reaching [DkgApprovalStep.ROUND_1] means the host key was asked for
* and granted. ChatMessage has no session to key a row against and no unique
* constraint to lean on, so the guard has to live here.
*/
val approvalRequestedThrough: DkgApprovalStep? = null,
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt,
override val savedAt: Instant = createdAt,

View File

@@ -0,0 +1,39 @@
package press.mantra.compose.database.model.types
/**
* One thing a ChillDKG ritual needs this device's owner to agree to before it
* will publish on their behalf.
*
* The ritual is driven by arriving messages, so without these it runs to
* completion on its own — a relay delivering an event to a phone in a pocket is
* enough to enrol its owner in a group's permanent signing quorum. Each step is a
* separate decision because each publishes something different and commits the
* member to something different, and each is presented on its own screen.
*
* The coordinator's two aggregations are deliberately not here. They relay other
* members' already-published messages and disclose nothing of the coordinator's
* own, so gating them would stall the whole group on one person's attention
* without protecting anybody.
*/
enum class DkgApprovalStep {
/**
* Publish this device's host public key, which is what puts its owner in the
* participant set. The most consequential of the three: `n` is fixed from here,
* and every later step assumes this member is in.
*/
HOST_KEY,
/**
* Publish this device's contribution to the key. Every member's is mixed in, so
* this is the point at which the member's own secret material starts shaping a
* key they will be expected to help sign with.
*/
ROUND_1,
/**
* Sign to confirm the coordinator's combined result matches what this device
* sent. Refusing here is what stops a coordinator substituting a different key
* than the members contributed to, so it is a check, not a formality.
*/
ROUND_2
}

View File

@@ -4,6 +4,7 @@ import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.DkgParticipantMessage
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.managers.ChillDkgRitualManager
import press.mantra.compose.repository.DkgRepository
import co.touchlab.kermit.Logger
@@ -44,6 +45,30 @@ class DatabaseDkgRepository(
null
}
override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? =
ChillDkgRitualManager.pendingApproval(database, session)
override suspend fun approve(
localChatRoom: LocalChatRoom,
sessionId: String,
step: DkgApprovalStep,
nostrPrivateKey: ByteArray
) {
try {
ChillDkgRitualManager.approve(
database = database,
localChatRoom = localChatRoom,
sessionId = sessionId,
step = step,
nostrPrivateKey = nostrPrivateKey
)
} catch (e: Throwable) {
// The ritual fails itself and tells the group; swallowing here keeps a
// protocol fault from taking the screen down with it.
logger.e("Error approving $step for DKG ritual $sessionId", e)
}
}
companion object {
private const val TAG = "DatabaseDkgRepository"
}

View File

@@ -7,6 +7,7 @@ import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.GiftWrapPayload
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.ChatRoomType
import press.mantra.compose.database.model.types.DkgApprovalStep
import press.mantra.compose.database.model.types.DkgRitualStage
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.dkg.DkgRitualEvents
@@ -148,7 +149,12 @@ object ChillDkgRitualManager {
// Fresh per session. Persisted because participantStep1/2 are pure in
// it, which is what makes the whole ritual restart-safe.
round1Random = RandomInstance.bytes(32).toHex(),
round2AuxRandom = RandomInstance.bytes(32).toHex()
round2AuxRandom = RandomInstance.bytes(32).toHex(),
// Opening a ceremony is already the act of agreeing to be in it, so this
// member is not asked again to publish the host key they just committed
// the group to. Rounds 1 and 2 are still asked for: those publish key
// material, and by then the ceremony has other people in it.
hostKeyApprovedAt = Clock.System.now()
)
database.dkgSessionDao().upsert(session)
announceStarted(database, session)
@@ -289,9 +295,14 @@ object ChillDkgRitualManager {
database.dkgSessionDao().upsert(session)
announceStarted(database, session)
logger.i("Joined DKG ritual $sessionId ($threshold-of-${members.size})")
logger.i("Recorded DKG ritual $sessionId ($threshold-of-${members.size}); awaiting approval")
publishHostKey(database, localChatRoom, session)
// Deliberately publishes nothing. This used to put the device's host key on
// the wire, which meant a relay delivering an event was enough to enrol its
// owner in the group's permanent signing quorum. The session is recorded so
// the ritual can be shown and stored messages replayed; [advance] sends
// nothing until [approve] has been called.
announceApprovalNeeded(database, session, DkgApprovalStep.HOST_KEY)
replayStoredMessages(database, session)
return session
@@ -450,10 +461,27 @@ object ChillDkgRitualManager {
val hostSeckey = deriveHostSecretKey(nostrPrivateKey)
try {
// Nothing of this device's own goes out before its owner has said so.
// Each gate returns rather than throwing: the ritual is not failing, it
// is waiting on a person, and everything already received stays stored
// so it resumes the moment they approve.
if (session.hostKeyApprovedAt == null) return
if (ownMessage(database, session, DkgRitualEvents.HOST_KEY) == null) {
publishHostKey(database, localChatRoom, session)
}
// Waiting on host keys: the participant set isn't settled, so there is
// nothing this device can compute yet.
val hostPublicKeys = hostPublicKeys(database, session) ?: return
// A second decision, and a different one: the host key only joined the
// ceremony, this contributes to the key itself.
if (session.round1ApprovedAt == null) {
announceApprovalNeeded(database, session, DkgApprovalStep.ROUND_1)
return
}
val step1 = ChillDKG.participantStep1(
hostSecretKey = hostSeckey,
hostPublicKeys = hostPublicKeys,
@@ -482,6 +510,15 @@ object ChillDkgRitualManager {
val coordinatorRound1 = session.coordinatorRound1 ?: return
// The last decision, and a real check rather than a formality: signing
// here asserts the combined result matches what this device sent, which
// is what stops a coordinator swapping in a key the members never
// contributed to.
if (session.round2ApprovedAt == null) {
announceApprovalNeeded(database, session, DkgApprovalStep.ROUND_2)
return
}
val step2 = ChillDKG.participantStep2(
hostSecretKey = hostSeckey,
state = step1.state,
@@ -784,6 +821,124 @@ object ChillDkgRitualManager {
actor = session.coordinatorPublicKey
)
/**
* What this ritual is waiting on its owner for, or null if it is waiting on
* somebody else — or on nothing, being finished.
*
* Mirrors the gates in [advance] rather than duplicating their reasoning: a step
* is pending exactly when [advance] would stop at it. The two must agree, or the
* screen offers an approval that does nothing, or none while the ritual sits
* still.
*/
suspend fun pendingApproval(database: MantraDatabase, session: DkgSession): DkgApprovalStep? {
if (session.stage == DkgRitualStage.COMPLETE || session.stage == DkgRitualStage.FAILED) return null
if (session.hostKeyApprovedAt == null) return DkgApprovalStep.HOST_KEY
if (session.round1ApprovedAt == null) {
// Nothing to contribute to until the participant set is settled: round 1
// is computed over every member's host key, so asking earlier would be
// asking about a ceremony whose shape is not yet known.
val hostKeys = database.dkgSessionDao()
.countMessagesByKind(session.id, DkgRitualEvents.HOST_KEY)
return if (hostKeys >= session.participantCount) DkgApprovalStep.ROUND_1 else null
}
if (session.round2ApprovedAt == null) {
// Nothing to check until the coordinator has combined the contributions.
return if (session.coordinatorRound1 != null) DkgApprovalStep.ROUND_2 else null
}
return null
}
/**
* Records that this device's owner agreed to [step], then lets the ritual run as
* far as the next thing it is waiting on.
*
* Approving a step it is not actually waiting on is a no-op rather than an error:
* a stale screen left open across a redelivery should not publish anything, and
* the caller has no way to know the ritual has moved on since it was drawn.
*/
suspend fun approve(
database: MantraDatabase,
localChatRoom: LocalChatRoom,
sessionId: String,
step: DkgApprovalStep,
nostrPrivateKey: ByteArray
) {
val session = database.dkgSessionDao().getSessionById(sessionId) ?: return
if (pendingApproval(database, session) != step) {
logger.i("Ritual $sessionId is not waiting on $step; ignoring the approval")
return
}
val now = Clock.System.now()
update(database, session) {
when (step) {
DkgApprovalStep.HOST_KEY -> it.copy(hostKeyApprovedAt = now)
DkgApprovalStep.ROUND_1 -> it.copy(round1ApprovedAt = now)
DkgApprovalStep.ROUND_2 -> it.copy(round2ApprovedAt = now)
}
}
logger.i("Ritual $sessionId: $step approved")
advance(
database = database,
localChatRoom = localChatRoom,
sessionId = sessionId,
nostrPrivateKey = nostrPrivateKey
)
}
/**
* Tells the group's chat that this ritual is waiting on the reader.
*
* The ritual is otherwise silent while it waits, and waiting is its normal
* state: it cannot finish until every member has taken part, so a member who
* never notices stalls it for everybody. The line is not addressed to the group
* — every device writes its own, and each only ever writes its own owner's.
*
* Written once per step by [DkgSession.approvalRequestedThrough], because
* [advance] runs on every arriving message and would otherwise ask again on each
* one.
*/
private suspend fun announceApprovalNeeded(
database: MantraDatabase,
session: DkgSession,
step: DkgApprovalStep
) {
val asked = current(database, session).approvalRequestedThrough
if (asked != null && asked.ordinal >= step.ordinal) return
update(database, session) { it.copy(approvalRequestedThrough = step) }
val (messageType, content) = when (step) {
DkgApprovalStep.HOST_KEY -> ChatMessage.TYPE_DKG_APPROVAL_NEEDED_HOST_KEY to
"Your approval is needed to join the shared key ceremony. Nothing has been " +
"published from this device yet."
DkgApprovalStep.ROUND_1 -> ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1 to
"Everyone has joined the shared key ceremony. Your approval is needed to " +
"contribute to the key."
DkgApprovalStep.ROUND_2 -> ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_2 to
"The contributions have been combined. Your approval is needed to check the " +
"result and confirm it."
}
announce(
database = database,
session = session,
messageType = messageType,
content = content,
actor = session.userPublicKey
)
}
/**
* Puts one protocol message in the group's chat, as a line naming who sent it.
*

View File

@@ -3,11 +3,18 @@ package press.mantra.compose.repository
import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.DkgApprovalStep
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
/** Reads and starts ChillDKG rituals. Advancing one is the inbound path's job. */
/**
* Reads, starts and approves ChillDKG rituals.
*
* Advancing one is the inbound path's job, with one exception: a ritual waiting on
* this device's owner does not advance until [approve] is called, because it will
* not publish on their behalf until they say so.
*/
interface DkgRepository {
/** The room's current ritual — abandoned attempts are superseded by the newest. */
fun observeLatestSessionForChatRoom(chatRoomId: String): Flow<DkgSession?>
@@ -24,6 +31,17 @@ interface DkgRepository {
threshold: Int
): DkgSession?
/** What the ritual is waiting on this device's owner for, if anything. */
suspend fun pendingApproval(session: DkgSession): DkgApprovalStep?
/** Agrees to one step, letting the ritual publish it and run on. */
suspend fun approve(
localChatRoom: LocalChatRoom,
sessionId: String,
step: DkgApprovalStep,
nostrPrivateKey: ByteArray
)
companion object {
val NO_OP_DKG_REPOSITORY: DkgRepository = object : DkgRepository {
override fun observeLatestSessionForChatRoom(chatRoomId: String): Flow<DkgSession?> = flowOf(null)
@@ -38,6 +56,15 @@ interface DkgRepository {
nostrPrivateKey: ByteArray,
threshold: Int
): DkgSession? = null
override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? = null
override suspend fun approve(
localChatRoom: LocalChatRoom,
sessionId: String,
step: DkgApprovalStep,
nostrPrivateKey: ByteArray
) = Unit
}
}
}

View File

@@ -0,0 +1,220 @@
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import press.mantra.compose.database.model.types.DkgApprovalStep
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.DkgRepository
import press.mantra.compose.ui.view.model.DkgRitualViewModel
import press.mantra.compose.ui.view.state.DkgRitualUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import fr.acinq.phoenix.data.ActiveWallet
import kotlinx.coroutines.flow.StateFlow
/**
* The frame every ChillDKG approval screen sits in: the view model, the states
* where there is nothing to approve, and the approve/decline bar.
*
* The three screens are separate because each is asking for something different
* and has its own case to make. What they share is only the chrome and, more
* importantly, the guard below — which is a correctness concern rather than a
* cosmetic one and should not be re-argued three times.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun DkgApprovalScaffold(
step: DkgApprovalStep,
title: String,
approveLabel: String,
activeUserPublicKey: HexKey,
chatRoomId: String,
activeWalletStateFlow: StateFlow<ActiveWallet?>,
chatRepository: ChatRepository,
dkgRepository: DkgRepository,
onDone: () -> Unit,
body: @Composable (DkgRitualUIState.Loaded) -> Unit,
) {
val dkgRitualViewModel: DkgRitualViewModel = viewModel(
factory = DkgRitualViewModel.factory(
chatRoomId = chatRoomId,
activeUserPublicKey = activeUserPublicKey,
activeWalletStateFlow = activeWalletStateFlow,
chatRepository = chatRepository,
dkgRepository = dkgRepository
)
)
// Nothing loads the room or starts watching the session until this runs, so
// without it the screen sits on its initial Loading state forever.
LaunchedEffect(true) {
dkgRitualViewModel.initiate()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
)
}
) { padding ->
when (val state = dkgRitualViewModel.dkgRitualUIState) {
is DkgRitualUIState.Loading -> Column(
modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
CircularProgressIndicator()
}
is DkgRitualUIState.Error -> Column(
modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Text(text = state.message, textAlign = TextAlign.Center)
}
is DkgRitualUIState.Loaded -> {
// Loaded means the room is loaded, not the ritual: [initiate] sets
// this state and only then starts collecting the session, so the
// first emission always has a null session and a null pending step.
// Reading that as "nothing to approve" would flash the empty state
// on the way in, which looks exactly like the screen not working.
if (state.session == null) {
Column(
modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
CircularProgressIndicator()
}
return@Scaffold
}
// The ritual may have moved on since this screen was opened -- a
// redelivery can carry it forward, and the member may have approved
// on another device. Approving anyway would be approving a step that
// is no longer the question, so the screen says so instead. The
// manager checks this too; this is the half the member can see.
if (state.pendingApproval != step) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(padding)
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Spacer(modifier = Modifier.height(40.dp))
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "There is nothing to approve here right now. The ceremony has " +
"either moved on or is waiting on somebody else.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
TextButton(onClick = onDone) { Text(text = "Back") }
}
return@Scaffold
}
val isActionPending = dkgRitualViewModel.isActionPending.value
Column(
modifier = Modifier
.fillMaxWidth()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
body(state)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Declining is deliberately not a button that refuses on the
// member's behalf: there is no "no" in ChillDKG short of
// abandoning the ceremony, and quietly leaving is what a
// member who is not ready actually wants. Abandoning is on
// the ritual screen, where the consequence can be spelled out.
TextButton(
onClick = onDone,
enabled = !isActionPending
) {
Text(text = "Not now")
}
Button(
onClick = {
dkgRitualViewModel.approve(step)
onDone()
},
enabled = !isActionPending,
modifier = Modifier.weight(1f)
) {
if (isActionPending) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
} else {
Text(text = approveLabel)
}
}
}
}
}
}
}
}
/** One "this is what happens" line on an approval screen. */
@Composable
internal fun DkgApprovalPoint(text: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(text = "", style = MaterialTheme.typography.bodyMedium)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@@ -0,0 +1,78 @@
package press.mantra.compose.ui.composable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import press.mantra.compose.database.model.types.DkgApprovalStep
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.DkgRepository
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import fr.acinq.phoenix.data.ActiveWallet
import kotlinx.coroutines.flow.StateFlow
/**
* Asks whether to join a shared key ceremony somebody else opened.
*
* The first and most consequential of the three approvals. Publishing a host key
* is what puts this member in the participant set, and the set is what `n` is
* counted over — so a member who joins and then stops answering does not merely
* fail to help, they hold the ceremony open for everybody else. Saying that
* plainly here is the point of the screen.
*
* Until this is approved nothing has left the device, which is worth stating
* outright: the member is being asked to start, not to continue.
*/
@Composable
fun DkgJoinApprovalScreen(
activeUserPublicKey: HexKey,
chatRoomId: String,
activeWalletStateFlow: StateFlow<ActiveWallet?>,
chatRepository: ChatRepository,
dkgRepository: DkgRepository,
onDone: () -> Unit,
) {
DkgApprovalScaffold(
step = DkgApprovalStep.HOST_KEY,
title = "Join the key ceremony?",
approveLabel = "Join the ceremony",
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
activeWalletStateFlow = activeWalletStateFlow,
chatRepository = chatRepository,
dkgRepository = dkgRepository,
onDone = onDone
) { state ->
val session = state.session
val group = state.localChatRoom.chatRoom.subject ?: "this group"
Text(
text = "$group is creating a shared key that takes " +
"${session?.threshold ?: 0} of ${state.participantCount} members to sign with.",
style = MaterialTheme.typography.bodyLarge
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
DkgApprovalPoint("Your device publishes the key it will be identified by for the rest of the ceremony.")
DkgApprovalPoint("You will be asked twice more before anything of your key material goes out.")
DkgApprovalPoint(
"The ceremony cannot finish until every member has taken part, so joining and " +
"then stopping leaves the group waiting."
)
}
Text(
text = "Nothing has been published from this device yet. Declining leaves the ceremony " +
"for the others to abandon or wait on, and you can join later from the shared key screen.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@@ -57,6 +57,11 @@ import press.mantra.compose.repository.DkgRepository
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.composable.widgets.profile.ProfileColor
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.database.model.types.DkgApprovalStep
import press.mantra.compose.ui.composable.navigation.routes.DkgJoinApprovalRoute
import press.mantra.compose.ui.composable.navigation.routes.DkgRound1ApprovalRoute
import press.mantra.compose.ui.composable.navigation.routes.DkgRound2ApprovalRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.view.model.DkgRitualViewModel
import press.mantra.compose.ui.view.state.DkgRitualUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -82,6 +87,7 @@ fun DkgRitualScreen(
activeWalletStateFlow: StateFlow<ActiveWallet?>,
chatRepository: ChatRepository,
dkgRepository: DkgRepository,
onNavigateToRoute: (Route) -> Unit = {},
) {
val dkgRitualViewModel: DkgRitualViewModel = viewModel(
factory = DkgRitualViewModel.factory(
@@ -123,6 +129,50 @@ fun DkgRitualScreen(
)
},
bottomBar = {
val pending = dkgRitualUIState.pendingApproval
// The ceremony is stopped, waiting on this member, and will stay
// stopped until they act -- so this outranks everything else the
// bar could offer.
if (pending != null) {
BottomAppBar(
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = {
onNavigateToRoute(
when (pending) {
DkgApprovalStep.HOST_KEY -> DkgJoinApprovalRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
)
DkgApprovalStep.ROUND_1 -> DkgRound1ApprovalRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
)
DkgApprovalStep.ROUND_2 -> DkgRound2ApprovalRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
)
}
)
}
) {
Icon(Icons.Default.Key, contentDescription = null)
Text(
text = when (pending) {
DkgApprovalStep.HOST_KEY -> "Review and join"
DkgApprovalStep.ROUND_1 -> "Review and contribute"
DkgApprovalStep.ROUND_2 -> "Review and confirm"
}
)
}
},
actions = {}
)
return@Scaffold
}
// Any member can open a ritual — the coordinator has no say in
// the key — but only when there isn't one already running.
if (canStartRitual) {

View File

@@ -0,0 +1,94 @@
package press.mantra.compose.ui.composable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import press.mantra.compose.database.model.types.DkgApprovalStep
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.DkgRepository
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import fr.acinq.phoenix.data.ActiveWallet
import kotlinx.coroutines.flow.StateFlow
/**
* Asks whether to contribute to the key itself.
*
* The step where the member's own secret material first shapes the outcome.
* Joining settled who is in the ceremony; this settles what the key is, and once
* it is out there is no version of the key that does not include it.
*
* The participant set is worth showing here rather than a count, because this is
* the last moment at which "who am I making a key with" is still a question the
* member can act on — everyone is now known, and the ceremony has not yet
* committed to anything.
*/
@Composable
fun DkgRound1ApprovalScreen(
activeUserPublicKey: HexKey,
chatRoomId: String,
activeWalletStateFlow: StateFlow<ActiveWallet?>,
chatRepository: ChatRepository,
dkgRepository: DkgRepository,
onDone: () -> Unit,
) {
DkgApprovalScaffold(
step = DkgApprovalStep.ROUND_1,
title = "Contribute to the shared key?",
approveLabel = "Contribute",
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
activeWalletStateFlow = activeWalletStateFlow,
chatRepository = chatRepository,
dkgRepository = dkgRepository,
onDone = onDone
) { state ->
val session = state.session
Text(
text = "Everyone has joined. ${state.participantCount} members are making a key that " +
"takes ${session?.threshold ?: 0} of them to sign with.",
style = MaterialTheme.typography.bodyLarge
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
DkgApprovalPoint(
"Your device publishes its contribution to the key. Every member's is mixed in, " +
"so no single device ever holds the whole key."
)
DkgApprovalPoint("Your share of it never leaves this device.")
DkgApprovalPoint("You will be asked once more, to check the combined result before it is final.")
}
Text(
text = "Taking part with these members",
style = MaterialTheme.typography.titleSmall
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
state.ritualMembers.forEach { member ->
val key = member.participant.participantPublicKey
Text(
text = if (key == activeUserPublicKey) {
"You"
} else {
member.profile?.humanReadableNameOrPubkey() ?: key.take(12)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}

View File

@@ -0,0 +1,81 @@
package press.mantra.compose.ui.composable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import press.mantra.compose.database.model.types.DkgApprovalStep
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.DkgRepository
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import fr.acinq.phoenix.data.ActiveWallet
import kotlinx.coroutines.flow.StateFlow
/**
* Asks whether to confirm the combined result.
*
* The one approval of the three that is a check rather than a decision. Every
* member signs to say the coordinator's combined result matches what they
* themselves sent, and it is that agreement — not the coordinator's word — which
* makes the key final. A coordinator trying to substitute a key the members never
* contributed to is caught precisely here.
*
* The check itself is done by the device, which has the member's own round-1
* message to compare against and would refuse a mismatch outright. So this screen
* is not asking the member to verify anything by eye; it is asking whether to put
* their name to a result their device has already found consistent.
*/
@Composable
fun DkgRound2ApprovalScreen(
activeUserPublicKey: HexKey,
chatRoomId: String,
activeWalletStateFlow: StateFlow<ActiveWallet?>,
chatRepository: ChatRepository,
dkgRepository: DkgRepository,
onDone: () -> Unit,
) {
DkgApprovalScaffold(
step = DkgApprovalStep.ROUND_2,
title = "Confirm the shared key?",
approveLabel = "Confirm",
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
activeWalletStateFlow = activeWalletStateFlow,
chatRepository = chatRepository,
dkgRepository = dkgRepository,
onDone = onDone
) { state ->
val session = state.session
Text(
text = "Everyone's contributions have been combined into a " +
"${session?.threshold ?: 0}-of-${state.participantCount} key.",
style = MaterialTheme.typography.bodyLarge
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
DkgApprovalPoint(
"Your device has checked the combined result against what it sent, and they match."
)
DkgApprovalPoint(
"Confirming signs to say so. The key becomes final once every member has confirmed."
)
DkgApprovalPoint(
"It is this step that stops anyone substituting a key the group did not make together."
)
}
Text(
text = "This is the last thing the ceremony needs from you.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@@ -26,7 +26,10 @@ import press.mantra.compose.ui.composable.ChatRoomCreationScreen
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.DkgRitualScreen
import press.mantra.compose.ui.composable.DkgRound1ApprovalScreen
import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen
import press.mantra.compose.ui.composable.HomeScreen
import press.mantra.compose.ui.composable.ImplementationPendingScreen
import press.mantra.compose.ui.composable.KeyPackageManagementScreen
@@ -56,7 +59,10 @@ import press.mantra.compose.ui.composable.navigation.routes.ChatRoomCreationRout
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute
import press.mantra.compose.ui.composable.navigation.routes.CreateProfileRoute
import press.mantra.compose.ui.composable.navigation.routes.DkgJoinApprovalRoute
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
import press.mantra.compose.ui.composable.navigation.routes.DkgRound1ApprovalRoute
import press.mantra.compose.ui.composable.navigation.routes.DkgRound2ApprovalRoute
import press.mantra.compose.ui.composable.navigation.routes.HomeRoute
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
import press.mantra.compose.ui.composable.navigation.routes.KeyPackageManagementRoute
@@ -452,7 +458,46 @@ fun MantraNavHost(
chatRoomId = route.chatRoomId,
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
chatRepository = databaseChatRepository,
dkgRepository = databaseDkgRepository
dkgRepository = databaseDkgRepository,
onNavigateToRoute = { approvalRoute ->
navController.navigate(route = approvalRoute)
}
)
}
composable<DkgJoinApprovalRoute> { backStackEntry ->
val route = backStackEntry.toRoute<DkgJoinApprovalRoute>()
DkgJoinApprovalScreen(
activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId,
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
chatRepository = databaseChatRepository,
dkgRepository = databaseDkgRepository,
onDone = { navController.popBackStack() }
)
}
composable<DkgRound1ApprovalRoute> { backStackEntry ->
val route = backStackEntry.toRoute<DkgRound1ApprovalRoute>()
DkgRound1ApprovalScreen(
activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId,
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
chatRepository = databaseChatRepository,
dkgRepository = databaseDkgRepository,
onDone = { navController.popBackStack() }
)
}
composable<DkgRound2ApprovalRoute> { backStackEntry ->
val route = backStackEntry.toRoute<DkgRound2ApprovalRoute>()
DkgRound2ApprovalScreen(
activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId,
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
chatRepository = databaseChatRepository,
dkgRepository = databaseDkgRepository,
onDone = { navController.popBackStack() }
)
}
composable<SelectChatRoomTypeRoute> { backStackEntry ->

View File

@@ -0,0 +1,17 @@
package press.mantra.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
/**
* Joining a shared-key ceremony somebody else opened.
*
* Its own route rather than a step on a shared one: each approval is a separate
* decision with its own case to make, and being separately addressable is what
* lets the chat line and the ritual screen send a member straight to the one the
* ceremony is actually waiting on.
*/
@Serializable
data class DkgJoinApprovalRoute(
val activeUserPublicKey: String,
val chatRoomId: String
): Route()

View File

@@ -0,0 +1,17 @@
package press.mantra.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
/**
* Contributing this device's share to a shared key.
*
* Its own route rather than a step on a shared one: each approval is a separate
* decision with its own case to make, and being separately addressable is what
* lets the chat line and the ritual screen send a member straight to the one the
* ceremony is actually waiting on.
*/
@Serializable
data class DkgRound1ApprovalRoute(
val activeUserPublicKey: String,
val chatRoomId: String
): Route()

View File

@@ -0,0 +1,17 @@
package press.mantra.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
/**
* Confirming the combined result of a shared-key ceremony.
*
* Its own route rather than a step on a shared one: each approval is a separate
* decision with its own case to make, and being separately addressable is what
* lets the chat line and the ritual screen send a member straight to the one the
* ceremony is actually waiting on.
*/
@Serializable
data class DkgRound2ApprovalRoute(
val activeUserPublicKey: String,
val chatRoomId: String
): Route()

View File

@@ -22,6 +22,12 @@ import androidx.compose.material.icons.filled.Check
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.CallMerge
import androidx.compose.material.icons.filled.FactCheck
import androidx.compose.material.icons.filled.PanTool
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material.icons.filled.WorkspacePremium
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.KeyOff
@@ -255,6 +261,29 @@ class ChatMessageListViewModel(
)
} else {
// A request line is answered when the step it asked for
// has since been published by this device -- which is
// exactly what approving it does. The transcript already
// records that as an authored line, so the answer is here
// in the list rather than in the session, and it stays
// right for a room that has run more than one ceremony.
val answeredRequests = chatRoomDetailMessageListUIState
.chatMessageList
.mapNotNull { request ->
val published = ChatMessage
.DKG_REQUEST_FULFILMENTS[request.chatMessage.messageType]
?: return@mapNotNull null
val done = chatRoomDetailMessageListUIState.chatMessageList.any {
it.chatMessage.messageType == published &&
it.chatMessage.isUserMessage &&
it.chatMessage.createdAt >= request.chatMessage.createdAt
}
request.chatMessage.id.takeIf { done }
}
.toSet()
LazyColumn(
modifier = Modifier.fillMaxWidth().padding(5.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
@@ -312,6 +341,7 @@ class ChatMessageListViewModel(
if (localChatMessage.chatMessage.messageType in ChatMessage.DKG_TYPES) {
RitualNotice(
localChatMessage = localChatMessage,
isAnswered = localChatMessage.chatMessage.id in answeredRequests,
onClick = onOpenSharedKey
)
return@items
@@ -482,18 +512,44 @@ class ChatMessageListViewModel(
@Composable
private fun RitualNotice(
localChatMessage: LocalChatMessage,
isAnswered: Boolean,
onClick: () -> Unit,
) {
val chatMessage = localChatMessage.chatMessage
// One per stage. A ceremony puts a dozen-odd lines in a row into the transcript,
// and with a single icon on all of them the reader has to actually read each to
// tell "somebody joined" from "somebody contributed" from "you are being asked
// for something". A request shares its stage's icon rather than getting a
// distinct one: it is the same step, before rather than after, and the primary
// tint and the Review affordance already say which.
val icon = when (chatMessage.messageType) {
ChatMessage.TYPE_DKG_STARTED -> Icons.Default.Key
ChatMessage.TYPE_DKG_HOST_KEY -> Icons.Default.PersonAdd
ChatMessage.TYPE_DKG_ROUND_1 -> Icons.Default.Upload
ChatMessage.TYPE_DKG_COORDINATOR_ROUND_1 -> Icons.Default.CallMerge
ChatMessage.TYPE_DKG_ROUND_2 -> Icons.Default.FactCheck
ChatMessage.TYPE_DKG_CERTIFICATE -> Icons.Default.WorkspacePremium
ChatMessage.TYPE_DKG_COMPLETE -> Icons.Default.CheckCircle
ChatMessage.TYPE_DKG_FAILED -> Icons.Default.ErrorOutline
else -> Icons.Default.Key
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_HOST_KEY -> Icons.Default.PersonAdd
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1 -> Icons.Default.Upload
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_2 -> Icons.Default.FactCheck
else -> Icons.Default.PanTool
}
val tint = when (chatMessage.messageType) {
ChatMessage.TYPE_DKG_FAILED -> MaterialTheme.colorScheme.error
// The requests are the ritual lines that ask rather than report, and the ones
// the ceremony cannot get past on its own. Everything else here is deliberately
// quiet; these are not.
// An answered request is history, not a summons: it keeps its stage's icon so
// the step is still recognisable, but drops the colour and the call to action.
val isRequest = chatMessage.messageType in ChatMessage.DKG_REQUEST_TYPES && !isAnswered
val tint = when {
chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED -> MaterialTheme.colorScheme.error
isRequest -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
@@ -551,10 +607,24 @@ private fun RitualNotice(
)
}
if (isRequest) {
Text(
text = "Review",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
} else if (isAnswered) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "You approved this",
tint = MaterialTheme.colorScheme.primary
)
}
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = "Open the shared key ceremony",
tint = MaterialTheme.colorScheme.onSurfaceVariant
tint = tint
)
}
}

View File

@@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import press.mantra.compose.database.model.types.DkgApprovalStep
import press.mantra.compose.database.model.types.DkgRitualStage
import press.mantra.compose.database.model.types.ChatRoomType
import press.mantra.compose.managers.ChillDkgRitualManager
@@ -112,7 +113,10 @@ class DkgRitualViewModel(
val loaded = (dkgRitualUIState as? DkgRitualUIState.Loaded)
?: DkgRitualUIState.Loaded(localChatRoom = localChatRoom)
dkgRitualUIState = loaded.copy(session = session)
dkgRitualUIState = loaded.copy(
session = session,
pendingApproval = session?.let { dkgRepository.pendingApproval(it) }
)
// Re-point the message watcher at whatever session is current. An
// abandoned ritual's counts must not keep ticking over the new one.
@@ -130,7 +134,12 @@ class DkgRitualViewModel(
dkgRitualUIState = current.copy(
hostKeyParticipants = senders(DkgRitualEvents.HOST_KEY),
round1Participants = senders(DkgRitualEvents.ROUND_1),
round2Participants = senders(DkgRitualEvents.ROUND_2)
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) }
)
}
}
@@ -172,6 +181,41 @@ class DkgRitualViewModel(
}
}
/**
* Agrees to whatever the ritual is currently waiting on from this device.
*
* Takes the step rather than reading it back from the state so a screen opened
* for one step cannot approve a different one it has since moved on to — the
* manager checks it against the ritual again and ignores a mismatch.
*/
fun approve(step: DkgApprovalStep) {
if (isActionPending.value) return
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
val session = loaded.session ?: return
val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()
if (nostrPrivateKey == null) {
dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
return
}
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
dkgRepository.approve(
localChatRoom = loaded.localChatRoom,
sessionId = session.id,
step = step,
nostrPrivateKey = nostrPrivateKey.value.toByteArray()
)
isActionPending.value = false
// The session flow delivers the updated row and with it the next pending
// step, so nothing is set here.
}
}
override fun onCleared() {
messageObserver?.cancel()
super.onCleared()

View File

@@ -4,6 +4,7 @@ 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 com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.managers.ChillDkgRitualManager
@@ -23,6 +24,15 @@ sealed interface DkgRitualUIState {
val hostKeyParticipants: Set<HexKey> = emptySet(),
val round1Participants: Set<HexKey> = emptySet(),
val round2Participants: Set<HexKey> = emptySet(),
/**
* What the ritual is waiting on this device's owner for, if anything.
*
* Null covers two very different situations that look the same from here —
* waiting on somebody else, and nothing left to wait for — because the
* screen already distinguishes them from [session]'s stage. What it cannot
* derive is this, which needs the stored messages as well as the row.
*/
val pendingApproval: DkgApprovalStep? = null,
): DkgRitualUIState {
val hostKeyCount: Int get() = hostKeyParticipants.size
val round1Count: Int get() = round1Participants.size