Merge branch 'mantra' into claude/distracted-franklin-e95ba4
This commit is contained in:
commit
5321e4af72
51 files changed
+15593
-608
No files matched your search
@@ -169,6 +169,15 @@ android {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
testOptions {
|
||||
unitTests {
|
||||
// Quartz logs through android.util.Log on the error paths -- parsing a
|
||||
// malformed event, for one. Unmocked, those methods throw, so a test
|
||||
// covering such a path fails on the log line rather than on what it
|
||||
// came to check. Default values let the code under test carry on.
|
||||
isReturnDefaultValues = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -16,6 +16,7 @@ import press.mantra.compose.database.dao.ChatMessageNostrEventRelationDao
|
||||
import press.mantra.compose.database.dao.ChatRoomDao
|
||||
import press.mantra.compose.database.dao.ConnectionDao
|
||||
import press.mantra.compose.database.dao.DkgSessionDao
|
||||
import press.mantra.compose.database.dao.FrostSigningSessionDao
|
||||
import press.mantra.compose.database.dao.GiftWrapMessageDao
|
||||
import press.mantra.compose.database.dao.GiftWrapPayloadDao
|
||||
import press.mantra.compose.database.dao.GiftWrapSealDao
|
||||
@@ -102,6 +103,8 @@ import press.mantra.compose.database.model.Reaction
|
||||
import press.mantra.compose.database.model.RecentSearch
|
||||
import press.mantra.compose.database.model.DkgParticipantMessage
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.FrostSignerMessage
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.Relay
|
||||
import press.mantra.compose.database.model.RepostedRelation
|
||||
import press.mantra.compose.database.model.SynchronizeNostrEventRequest
|
||||
@@ -124,6 +127,8 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
ChatRoom::class,
|
||||
DkgParticipantMessage::class,
|
||||
DkgSession::class,
|
||||
FrostSignerMessage::class,
|
||||
FrostSigningSession::class,
|
||||
GiftWrapMessage::class,
|
||||
GiftWrapSeal::class,
|
||||
GiftWrapPayload::class,
|
||||
@@ -164,7 +169,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
UnsignedNostrEvent::class,
|
||||
Zap::class
|
||||
],
|
||||
version = 4,
|
||||
version = 6,
|
||||
autoMigrations = [
|
||||
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
|
||||
// generate the migration itself — nothing existing changes shape.
|
||||
@@ -173,11 +178,23 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
// 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)
|
||||
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.
|
||||
//
|
||||
// v5 adds the nullable MarmotInnerEvent.payloadEventId, which names the
|
||||
// nip30303 event a SubmissionEvent rumor carries. Rumors queued before
|
||||
// this come back null, which reads as "not a submission" -- correct,
|
||||
// since none of them were.
|
||||
AutoMigration(from = 4, to = 5),
|
||||
// v6 adds the FrostSigningSession/FrostSignerMessage tables and the
|
||||
// nullable DkgSession.publicShares. New tables and a nullable column are
|
||||
// both shapes Room can migrate itself. A ceremony that completed before
|
||||
// this reads back null, and signing falls back to not cross-checking
|
||||
// shares rather than refusing to run.
|
||||
AutoMigration(from = 5, to = 6)
|
||||
]
|
||||
)
|
||||
@ColumnTypeConverters(MantraConverters::class)
|
||||
@@ -197,6 +214,8 @@ abstract class MantraDatabase: RoomDatabase() {
|
||||
|
||||
abstract fun dkgSessionDao(): DkgSessionDao
|
||||
|
||||
abstract fun frostSigningSessionDao(): FrostSigningSessionDao
|
||||
|
||||
abstract fun connectionDao(): ConnectionDao
|
||||
|
||||
abstract fun giftWrapMessageDao(): GiftWrapMessageDao
|
||||
|
||||
@@ -27,6 +27,16 @@ interface DkgSessionDao {
|
||||
@Query("SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1")
|
||||
suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession?
|
||||
|
||||
/**
|
||||
* Every ceremony this device came out of holding a share, newest first.
|
||||
*
|
||||
* Signing happens in the #admins room, whose id is derived from the key
|
||||
* rather than from the room the ceremony ran in, so the key is found by
|
||||
* matching that derivation rather than by a stored room id.
|
||||
*/
|
||||
@Query("SELECT * FROM DkgSession WHERE thresholdPublicKey IS NOT NULL AND secretShare IS NOT NULL ORDER BY createdAt DESC")
|
||||
suspend fun getKeyHoldingSessions(): List<DkgSession>
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(dkgSession: DkgSession)
|
||||
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package press.mantra.compose.database.dao
|
||||
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Upsert
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import press.mantra.compose.database.model.FrostSignerMessage
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
|
||||
@Dao
|
||||
interface FrostSigningSessionDao {
|
||||
@Query("SELECT * FROM FrostSigningSession WHERE id = :sessionId")
|
||||
suspend fun getSessionById(sessionId: String): FrostSigningSession?
|
||||
|
||||
@Query("SELECT * FROM FrostSigningSession WHERE id = :sessionId")
|
||||
fun observeSessionById(sessionId: String): Flow<FrostSigningSession?>
|
||||
|
||||
/**
|
||||
* A room's signing sessions, newest first. Unlike a DKG a group signs
|
||||
* repeatedly, so there is no single "current" one to observe.
|
||||
*/
|
||||
@Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC")
|
||||
fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<FrostSigningSession>>
|
||||
|
||||
@Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1")
|
||||
suspend fun getLatestSessionForChatRoom(chatRoomId: String): FrostSigningSession?
|
||||
|
||||
@Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC")
|
||||
suspend fun getSessionsForChatRoom(chatRoomId: String): List<FrostSigningSession>
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(frostSigningSession: FrostSigningSession)
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(frostSignerMessage: FrostSignerMessage)
|
||||
|
||||
@Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind ORDER BY createdAt ASC")
|
||||
suspend fun getMessagesByKind(sessionId: String, kind: Kind): List<FrostSignerMessage>
|
||||
|
||||
@Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId ORDER BY createdAt ASC")
|
||||
fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind")
|
||||
suspend fun countMessagesByKind(sessionId: String, kind: Kind): Int
|
||||
|
||||
@Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind AND signerPublicKey = :signerPublicKey")
|
||||
suspend fun getMessage(sessionId: String, kind: Kind, signerPublicKey: HexKey): FrostSignerMessage?
|
||||
}
|
||||
@@ -3,7 +3,10 @@ package press.mantra.compose.database.dao
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Transaction
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.model.ChatMessage
|
||||
import press.mantra.compose.database.model.MantraArtifact
|
||||
@@ -21,6 +24,7 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChunkEvent
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
import press.mantra.compose.nostr.nip30303.SubmissionEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
||||
@@ -28,6 +32,7 @@ import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
|
||||
import press.mantra.compose.repository.MantraRepository.Companion.DEFAULT_LICENSE
|
||||
import press.mantra.compose.repository.MantraRepository.Companion.DEFAULT_VISIBILITY
|
||||
import press.mantra.compose.text.Markdown
|
||||
import kotlin.time.Instant
|
||||
|
||||
@Dao
|
||||
abstract class MantraDao(
|
||||
@@ -35,6 +40,82 @@ abstract class MantraDao(
|
||||
) {
|
||||
val logger = Logger.withTag("NostrDao")
|
||||
|
||||
/**
|
||||
* The unsigned nip30303 event [template] describes, authored by [publicKey].
|
||||
*
|
||||
* Its id is computed the same way the matching Mantra* entity computes
|
||||
* its own, so the row on disk and the payload on the wire are the same
|
||||
* event rather than two copies of one.
|
||||
*/
|
||||
private fun rumorOf(
|
||||
template: EventTemplate<out Event>,
|
||||
publicKey: HexKey,
|
||||
): Event = Event(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = publicKey,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
),
|
||||
pubKey = publicKey,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
// A rumor. What signs for this reaching the group is the kind:445 the
|
||||
// outbound pipeline wraps it in, not the payload itself.
|
||||
sig = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* Queue [payload] for the group inside a [SubmissionEvent] that
|
||||
* [submitterPublicKey] authors.
|
||||
*
|
||||
* nip30303 events are never sent on their own. Wrapping them means the
|
||||
* payload keeps whoever wrote it as its author while the group still knows
|
||||
* which member put it there -- see [SubmissionEvent] for why the two are
|
||||
* worth telling apart.
|
||||
*
|
||||
* The submission is stored as an unprocessed rumor (null marmotGroupEventId),
|
||||
* which is what the outbound pipeline picks up and encrypts into a kind:445
|
||||
* group event for the chat room.
|
||||
*/
|
||||
private suspend fun submitToGroup(
|
||||
chatRoomId: String,
|
||||
submitterPublicKey: HexKey,
|
||||
payload: Event,
|
||||
text: String,
|
||||
): MarmotInnerEvent {
|
||||
val submissionTemplate = SubmissionEvent.build(payload = payload)
|
||||
|
||||
val submissionInnerEvent = MarmotInnerEvent(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = submitterPublicKey,
|
||||
createdAt = submissionTemplate.createdAt,
|
||||
kind = submissionTemplate.kind,
|
||||
tags = submissionTemplate.tags,
|
||||
content = submissionTemplate.content,
|
||||
),
|
||||
publicKey = submitterPublicKey,
|
||||
kind = SubmissionEvent.KIND,
|
||||
createdAt = Instant.fromEpochSeconds(submissionTemplate.createdAt),
|
||||
tags = submissionTemplate.tags,
|
||||
content = submissionTemplate.content,
|
||||
payloadEventId = payload.id,
|
||||
chatRoomId = chatRoomId,
|
||||
)
|
||||
|
||||
sendMarmotInnerEvent(
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = submitterPublicKey,
|
||||
text = text,
|
||||
marmotInnerEvent = submissionInnerEvent,
|
||||
)
|
||||
|
||||
return submissionInnerEvent
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun addDialect(
|
||||
localChatRoom: LocalChatRoom,
|
||||
@@ -42,7 +123,7 @@ abstract class MantraDao(
|
||||
country: String,
|
||||
language: String,
|
||||
userPublicKey: HexKey,
|
||||
): MarmotInnerEvent? {
|
||||
): MantraDialect? {
|
||||
logger.d("addDialect: $name")
|
||||
|
||||
val dialectEventTemplate = DialectEvent.build(
|
||||
@@ -57,26 +138,17 @@ abstract class MantraDao(
|
||||
userPublicKey = userPublicKey,
|
||||
) ?: return null
|
||||
|
||||
val dialectInnerEvent = MarmotInnerEvent(
|
||||
id = mantraDialect.id,
|
||||
publicKey = mantraDialect.publicKey,
|
||||
kind = DialectEvent.KIND,
|
||||
createdAt = mantraDialect.createdAt,
|
||||
tags = dialectEventTemplate.tags,
|
||||
content = dialectEventTemplate.content,
|
||||
chatRoomId = mantraDialect.chatRoomId,
|
||||
)
|
||||
|
||||
return try {
|
||||
database.mantraDialectDao().upsert(mantraDialect)
|
||||
database.marmotInnerEventDao().upsert(dialectInnerEvent)
|
||||
|
||||
sendMarmotInnerEvent(
|
||||
localChatRoom = localChatRoom,
|
||||
submitToGroup(
|
||||
chatRoomId = mantraDialect.chatRoomId,
|
||||
submitterPublicKey = userPublicKey,
|
||||
payload = rumorOf(dialectEventTemplate, userPublicKey),
|
||||
text = "Added $name as a dialect",
|
||||
marmotInnerEvent = dialectInnerEvent
|
||||
)
|
||||
dialectInnerEvent
|
||||
|
||||
mantraDialect
|
||||
} catch (error: Throwable) {
|
||||
logger.e("Failed to add dialect \"$name\" to chat room ${localChatRoom.chatRoom.id}", error)
|
||||
null
|
||||
@@ -94,7 +166,7 @@ abstract class MantraDao(
|
||||
userPublicKey: HexKey,
|
||||
visibility: String = DEFAULT_VISIBILITY,
|
||||
license: String = DEFAULT_LICENSE,
|
||||
): MarmotInnerEvent? {
|
||||
): MantraArtifact? {
|
||||
// TODO: Verify the active user is an admin of the chat room before allowing this.
|
||||
val artifactEventTemplate = ArtifactEvent.build(
|
||||
name = name,
|
||||
@@ -110,32 +182,19 @@ abstract class MantraDao(
|
||||
userPublicKey = userPublicKey,
|
||||
) ?: return null
|
||||
|
||||
// Persist the artifact together with an unprocessed marmot inner event
|
||||
// (a rumor). Inner events with a null marmotGroupEventId are later
|
||||
// picked up by the outbound pipeline and encrypted into a kind:445
|
||||
// group event for the chat room.
|
||||
// Persist the artifact locally and submit it to the group.
|
||||
//
|
||||
// This covers the "private" visibility case. Permissioned artifacts
|
||||
// (published as a PublicMessage) and public artifacts (published as a
|
||||
// plain nostr event) are not implemented yet.
|
||||
val artifactInnerEvent = MarmotInnerEvent(
|
||||
id = mantraArtifact.id,
|
||||
publicKey = mantraArtifact.publicKey,
|
||||
kind = ArtifactEvent.KIND,
|
||||
createdAt = mantraArtifact.createdAt,
|
||||
tags = artifactEventTemplate.tags,
|
||||
content = artifactEventTemplate.content,
|
||||
chatRoomId = mantraArtifact.chatRoomId,
|
||||
)
|
||||
|
||||
return try {
|
||||
database.mantraArtifactDao().upsert(mantraArtifact)
|
||||
database.marmotInnerEventDao().upsert(artifactInnerEvent)
|
||||
|
||||
sendMarmotInnerEvent(
|
||||
localChatRoom = localChatRoom,
|
||||
submitToGroup(
|
||||
chatRoomId = mantraArtifact.chatRoomId,
|
||||
submitterPublicKey = userPublicKey,
|
||||
payload = rumorOf(artifactEventTemplate, userPublicKey),
|
||||
text = "Added $name to artifacts",
|
||||
marmotInnerEvent = artifactInnerEvent
|
||||
)
|
||||
|
||||
// Every artifact starts with an initial version.
|
||||
@@ -146,7 +205,7 @@ abstract class MantraDao(
|
||||
userPublicKey = userPublicKey,
|
||||
)
|
||||
|
||||
artifactInnerEvent
|
||||
mantraArtifact
|
||||
} catch (error: Throwable) {
|
||||
logger.e("Failed to add artifact \"$name\" to chat room ${localChatRoom.chatRoom.id}", error)
|
||||
null
|
||||
@@ -159,7 +218,7 @@ abstract class MantraDao(
|
||||
artifactId: HexKey,
|
||||
versionLabel: String,
|
||||
userPublicKey: HexKey,
|
||||
): MarmotInnerEvent? {
|
||||
): MantraArtifactVersion? {
|
||||
return addArtifactVersionInternal(
|
||||
localChatRoom = localChatRoom,
|
||||
artifactId = artifactId,
|
||||
@@ -173,7 +232,7 @@ abstract class MantraDao(
|
||||
artifactId: HexKey,
|
||||
versionLabel: String,
|
||||
userPublicKey: HexKey,
|
||||
): MarmotInnerEvent? {
|
||||
): MantraArtifactVersion? {
|
||||
// The version label is carried in the event content (see
|
||||
// MantraArtifactVersion.fromArtifactVersionEvent).
|
||||
val artifactVersionEventTemplate = ArtifactVersionEvent.build(
|
||||
@@ -188,25 +247,16 @@ abstract class MantraDao(
|
||||
userPublicKey = userPublicKey,
|
||||
) ?: return null
|
||||
|
||||
val versionInnerEvent = MarmotInnerEvent(
|
||||
id = mantraArtifactVersion.id,
|
||||
publicKey = mantraArtifactVersion.publicKey,
|
||||
kind = ArtifactVersionEvent.KIND,
|
||||
createdAt = mantraArtifactVersion.createdAt,
|
||||
tags = artifactVersionEventTemplate.tags,
|
||||
content = artifactVersionEventTemplate.content,
|
||||
chatRoomId = mantraArtifactVersion.chatRoomId,
|
||||
)
|
||||
|
||||
database.mantraArtifactVersionDao().upsert(mantraArtifactVersion)
|
||||
database.marmotInnerEventDao().upsert(versionInnerEvent)
|
||||
|
||||
sendMarmotInnerEvent(
|
||||
localChatRoom = localChatRoom,
|
||||
submitToGroup(
|
||||
chatRoomId = mantraArtifactVersion.chatRoomId,
|
||||
submitterPublicKey = userPublicKey,
|
||||
payload = rumorOf(artifactVersionEventTemplate, userPublicKey),
|
||||
text = "Add the ${artifactVersionEventTemplate.content} version",
|
||||
marmotInnerEvent = versionInnerEvent
|
||||
)
|
||||
return versionInnerEvent
|
||||
|
||||
return mantraArtifactVersion
|
||||
}
|
||||
|
||||
@Transaction
|
||||
@@ -216,7 +266,7 @@ abstract class MantraDao(
|
||||
originalText: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MarmotInnerEvent? {
|
||||
): MantraChapter? {
|
||||
|
||||
// Chapters attach to an artifact version; use the latest one.
|
||||
val version = database.mantraArtifactVersionDao()
|
||||
@@ -244,24 +294,12 @@ abstract class MantraDao(
|
||||
|
||||
return try {
|
||||
database.mantraChapterDao().upsert(chapter)
|
||||
val chapterInnerEvent = MarmotInnerEvent(
|
||||
id = chapter.id,
|
||||
publicKey = chapter.publicKey,
|
||||
kind = ChapterEvent.KIND,
|
||||
createdAt = chapter.createdAt,
|
||||
tags = chapterEventTemplate.tags,
|
||||
content = chapterEventTemplate.content,
|
||||
chatRoomId = chapter.chatRoomId,
|
||||
)
|
||||
database.marmotInnerEventDao().upsert(
|
||||
chapterInnerEvent
|
||||
)
|
||||
|
||||
sendMarmotInnerEvent(
|
||||
submitToGroup(
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = userPublicKey,
|
||||
submitterPublicKey = userPublicKey,
|
||||
payload = rumorOf(chapterEventTemplate, userPublicKey),
|
||||
text = "Added chapter to artifact", // TODO: Get artifact to use in text...
|
||||
marmotInnerEvent = chapterInnerEvent
|
||||
)
|
||||
|
||||
// Split the markdown into paragraph chunks.
|
||||
@@ -279,30 +317,17 @@ abstract class MantraDao(
|
||||
userPublicKey = userPublicKey,
|
||||
)?.let { chunk ->
|
||||
database.mantraChunkDao().upsert(chunk)
|
||||
val chunkInnerEvent = MarmotInnerEvent(
|
||||
id = chunk.id,
|
||||
publicKey = chunk.publicKey,
|
||||
kind = ChunkEvent.KIND,
|
||||
createdAt = chunk.createdAt,
|
||||
tags = chunkEventTemplate.tags,
|
||||
content = chunkEventTemplate.content,
|
||||
chatRoomId = chunk.chatRoomId,
|
||||
)
|
||||
|
||||
database.marmotInnerEventDao().upsert(
|
||||
chunkInnerEvent
|
||||
)
|
||||
|
||||
sendMarmotInnerEvent(
|
||||
submitToGroup(
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = userPublicKey,
|
||||
submitterPublicKey = userPublicKey,
|
||||
payload = rumorOf(chunkEventTemplate, userPublicKey),
|
||||
text = "${chapter.name} added chunk $chunkIndex", // TODO: Use a portion of the actual chunked text...
|
||||
marmotInnerEvent = chunkInnerEvent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
chapterInnerEvent
|
||||
chapter
|
||||
} catch (error: Throwable) {
|
||||
logger.e("Failed to add chapter \"$name\" to artifact $artifactId", error)
|
||||
null
|
||||
@@ -315,7 +340,7 @@ abstract class MantraDao(
|
||||
dialectId: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MarmotInnerEvent? {
|
||||
): MantraTranslationArtifactVersion? {
|
||||
val artifact = database.mantraArtifactDao().getArtifactById(artifactId) ?: return null
|
||||
val version = database.mantraArtifactVersionDao()
|
||||
.getArtifactVersionsByArtifactId(artifactId)
|
||||
@@ -339,21 +364,12 @@ abstract class MantraDao(
|
||||
|
||||
return try {
|
||||
database.mantraTranslationArtifactVersionDao().upsert(translationVersion)
|
||||
val translationArtifactVersionInnerEvent = MarmotInnerEvent(
|
||||
id = translationVersion.id,
|
||||
publicKey = translationVersion.publicKey,
|
||||
kind = TranslationArtifactVersionEvent.KIND,
|
||||
createdAt = translationVersion.createdAt,
|
||||
tags = translationVersionTemplate.tags,
|
||||
content = translationVersionTemplate.content,
|
||||
chatRoomId = translationVersion.chatRoomId,
|
||||
)
|
||||
|
||||
sendMarmotInnerEvent(
|
||||
submitToGroup(
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = userPublicKey,
|
||||
submitterPublicKey = userPublicKey,
|
||||
payload = rumorOf(translationVersionTemplate, userPublicKey),
|
||||
text = "Added ${dialect.name} translation",
|
||||
marmotInnerEvent = translationArtifactVersionInnerEvent
|
||||
)
|
||||
|
||||
// Mirror the source structure: a translation chapter per chapter and
|
||||
@@ -372,21 +388,12 @@ abstract class MantraDao(
|
||||
) ?: return@forEach
|
||||
|
||||
database.mantraTranslationChapterDao().upsert(translationChapter)
|
||||
val translationChapterInnerEvent = MarmotInnerEvent(
|
||||
id = translationChapter.id,
|
||||
publicKey = translationChapter.publicKey,
|
||||
kind = TranslationChapterEvent.KIND,
|
||||
createdAt = translationChapter.createdAt,
|
||||
tags = translationChapterTemplate.tags,
|
||||
content = translationChapterTemplate.content,
|
||||
chatRoomId = translationChapter.chatRoomId,
|
||||
)
|
||||
|
||||
sendMarmotInnerEvent(
|
||||
submitToGroup(
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = userPublicKey,
|
||||
submitterPublicKey = userPublicKey,
|
||||
payload = rumorOf(translationChapterTemplate, userPublicKey),
|
||||
text = "Prepared ${dialect.name} translation of the chapter ${chapter.name}",
|
||||
marmotInnerEvent = translationChapterInnerEvent
|
||||
)
|
||||
|
||||
// TODO: Figure out if we seriously need the scaffolding
|
||||
@@ -427,7 +434,7 @@ abstract class MantraDao(
|
||||
// }
|
||||
}
|
||||
|
||||
translationArtifactVersionInnerEvent
|
||||
translationVersion
|
||||
} catch (error: Throwable) {
|
||||
logger.e("Failed to add translation for artifact $artifactId", error)
|
||||
null
|
||||
@@ -460,30 +467,25 @@ abstract class MantraDao(
|
||||
return try {
|
||||
// Replace any existing translation chunk for this source chunk. Its id
|
||||
// is derived from the (now changed) content, so it becomes a new row —
|
||||
// drop the old one (and its rumor) to keep one per source chunk.
|
||||
// drop the old one (and the submission carrying it) to keep one per
|
||||
// source chunk. The submission is found by what it carries, since its
|
||||
// own id is the envelope's rather than the chunk's.
|
||||
database.mantraTranslationChunkDao()
|
||||
.getTranslationChunksByTranslationChapterId(translationChapterId)
|
||||
.filter { it.chunkId == chunkId && it.id != translationChunk.id }
|
||||
.forEach { stale ->
|
||||
database.mantraTranslationChunkDao().deleteById(stale.id)
|
||||
database.marmotInnerEventDao().deleteById(stale.id)
|
||||
database.marmotInnerEventDao().deleteByPayloadEventId(stale.id)
|
||||
}
|
||||
|
||||
database.mantraTranslationChunkDao().upsert(translationChunk)
|
||||
val translationChunkInnerEvent = MarmotInnerEvent(
|
||||
id = translationChunk.id,
|
||||
publicKey = translationChunk.publicKey,
|
||||
kind = TranslationChunkEvent.KIND,
|
||||
createdAt = translationChunk.createdAt,
|
||||
tags = translationChunkTemplate.tags,
|
||||
content = translationChunkTemplate.content,
|
||||
chatRoomId = translationChunk.chatRoomId,
|
||||
)
|
||||
sendMarmotInnerEvent(
|
||||
|
||||
submitToGroup(
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = userPublicKey,
|
||||
submitterPublicKey = userPublicKey,
|
||||
payload = rumorOf(translationChunkTemplate, userPublicKey),
|
||||
text = "Translated chunk ${translationChunk.index}", // TODO: Use a portion of the translation and name the language
|
||||
marmotInnerEvent = translationChunkInnerEvent
|
||||
)
|
||||
|
||||
translationChunk
|
||||
@@ -493,19 +495,6 @@ abstract class MantraDao(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendMarmotInnerEvent(
|
||||
localChatRoom: LocalChatRoom,
|
||||
text: String,
|
||||
marmotInnerEvent: MarmotInnerEvent
|
||||
) {
|
||||
sendMarmotInnerEvent(
|
||||
chatRoomId = localChatRoom.chatRoom.id,
|
||||
userPublicKey = localChatRoom.chatRoom.userPublicKey,
|
||||
text = text,
|
||||
marmotInnerEvent = marmotInnerEvent
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun sendMarmotInnerEvent(
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
|
||||
+19
@@ -15,6 +15,25 @@ interface MarmotInnerEventDao {
|
||||
@Upsert
|
||||
suspend fun upsert(marmotInnerEvent: MarmotInnerEvent)
|
||||
|
||||
/**
|
||||
* A room's inner events of the given kinds, oldest first.
|
||||
*
|
||||
* Used to replay a protocol backlog: a session's messages are stored as they
|
||||
* decrypt, but one that arrives before the proposal opening its session has
|
||||
* nowhere to be filed at the time.
|
||||
*/
|
||||
@Query("SELECT * FROM MarmotInnerEvent WHERE chatRoomId = :chatRoomId AND kind IN (:kinds) ORDER BY createdAt ASC")
|
||||
suspend fun getByChatRoomAndKinds(chatRoomId: String, kinds: List<Int>): List<MarmotInnerEvent>
|
||||
|
||||
@Query("DELETE FROM MarmotInnerEvent WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
|
||||
/**
|
||||
* Drop the submissions carrying [payloadEventId].
|
||||
*
|
||||
* A submission's id is the envelope's, not the payload's, so a superseded
|
||||
* nip30303 event cannot be un-queued by its own id.
|
||||
*/
|
||||
@Query("DELETE FROM MarmotInnerEvent WHERE payloadEventId = :payloadEventId")
|
||||
suspend fun deleteByPayloadEventId(payloadEventId: String)
|
||||
}
|
||||
@@ -31,6 +31,9 @@ import press.mantra.compose.exceptions.MarmotWelcomeEventMissingKeyPackageEventI
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.managers.ChillDkgRitualManager
|
||||
import press.mantra.compose.nostr.dkg.DkgRitualEvents
|
||||
import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
import press.mantra.compose.managers.FrostSigningManager
|
||||
import press.mantra.compose.managers.MlsGroupCache
|
||||
import press.mantra.compose.managers.MarmotInboundManager
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -43,6 +46,7 @@ import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage
|
||||
import com.vitorpamplona.quartz.marmot.mls.tree.Credential
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
@@ -385,9 +389,21 @@ abstract class NostrDao(
|
||||
val localChatRoom = database.chatRoomDao().findChatRoomById(chatRoomId)
|
||||
|
||||
if (localChatRoom != null) {
|
||||
val mlsGroup = localChatRoom.chatRoom.toMlsGroup()
|
||||
|
||||
if (mlsGroup != null) {
|
||||
// Through the cache rather than rebuilt here, so the secret
|
||||
// tree's skipped-generation keys survive from one message to
|
||||
// the next. Two events published in the same instant arrive in
|
||||
// whatever order the relay feels like, and rebuilding between
|
||||
// them loses the earlier one for good -- see MlsGroupCache.
|
||||
val handled = MlsGroupCache.withGroup(
|
||||
chatRoomId = chatRoomId,
|
||||
storedStateHex = localChatRoom.chatRoom.mlsGroupState,
|
||||
build = { localChatRoom.chatRoom.toMlsGroup() },
|
||||
save = { stateHex ->
|
||||
database.chatRoomDao().upsert(
|
||||
localChatRoom.chatRoom.copy(mlsGroupState = stateHex)
|
||||
)
|
||||
}
|
||||
) { mlsGroup ->
|
||||
val memberPubkeys = mlsGroup.members().mapNotNull { (leafIndex, leafNode) ->
|
||||
|
||||
val pubkey = when (val cred = leafNode.credential) {
|
||||
@@ -434,12 +450,6 @@ abstract class NostrDao(
|
||||
}
|
||||
}
|
||||
|
||||
// Save the mls chatRoom state...
|
||||
database.chatRoomDao().upsert(
|
||||
localChatRoom.chatRoom.copy(
|
||||
mlsGroupState = mlsGroup.saveState().encodeTls().toHex()
|
||||
)
|
||||
)
|
||||
ChatMessage.fromGroupEventResult(
|
||||
database = database,
|
||||
activeKeyPair = activeKeyPair,
|
||||
@@ -451,11 +461,34 @@ abstract class NostrDao(
|
||||
chatMessage
|
||||
)
|
||||
}
|
||||
|
||||
// A FROST signing message for this group. Driven from
|
||||
// here rather than from ChatMessage because the
|
||||
// manager needs the room to publish its own replies
|
||||
// into, and because it writes its transcript lines
|
||||
// itself. The manager is idempotent, so a redelivered
|
||||
// message re-runs a step it has already taken.
|
||||
if (groupEventResult is GroupEventResult.ApplicationMessage) {
|
||||
Event.fromJsonOrNull(groupEventResult.innerEventJson)
|
||||
?.takeIf { FrostSigningEvents.isFrostSigningKind(it.kind) }
|
||||
?.let { innerEvent ->
|
||||
FrostSigningManager.processSigningPayload(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
innerEvent = innerEvent,
|
||||
userPublicKey = activeKeyPair.pubKey.toHex()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw MarmotNotMemberOfChatGroupException("We are not a member of the chat room ${localChatRoom.chatRoom.id}")
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
// Null means the room has no usable group state, which is what
|
||||
// a failed toMlsGroup() meant before the cache existed.
|
||||
if (handled == null) {
|
||||
throw MarmotMissingNostrGroupDataExtension("Couldn't find chatRoom for $nostrEvent")
|
||||
}
|
||||
} else {
|
||||
|
||||
+425
-286
@@ -17,11 +17,13 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChunkEvent
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
import press.mantra.compose.nostr.nip30303.SubmissionEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionContributorListEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
||||
@@ -178,6 +180,65 @@ data class ChatMessage(
|
||||
* A finished ceremony is the exception, and has no actor: the group ends up
|
||||
* with a key, nobody hands it to them.
|
||||
*/
|
||||
/**
|
||||
* A FROST signing session, as lines in the group's chat.
|
||||
*
|
||||
* The same shape as the ceremony's, and for the same reason: signing with
|
||||
* the group's key is otherwise a black box, and a session stalled on one
|
||||
* member gives no way to see whose door to knock on.
|
||||
*/
|
||||
const val TYPE_FROST_STARTED = "frostStarted"
|
||||
const val TYPE_FROST_NONCE = "frostNonce"
|
||||
const val TYPE_FROST_SIGNER_SET = "frostSignerSet"
|
||||
const val TYPE_FROST_PARTIAL_SIGNATURE = "frostPartialSignature"
|
||||
const val TYPE_FROST_SIGNATURE = "frostSignature"
|
||||
const val TYPE_FROST_COMPLETE = "frostComplete"
|
||||
const val TYPE_FROST_FAILED = "frostFailed"
|
||||
|
||||
/** Addressed to the reader rather than said by anyone -- see [DKG_REQUEST_TYPES]. */
|
||||
const val TYPE_FROST_APPROVAL_NEEDED = "frostApprovalNeeded"
|
||||
|
||||
/**
|
||||
* Answering the request publishes this device's nonce, not its signature:
|
||||
* approving is agreeing to take part, and the coordinator may then pick a
|
||||
* quorum that does not include this member. Keying the answer on the
|
||||
* partial signature would leave a member who agreed, and was not needed,
|
||||
* looking like they never replied.
|
||||
*/
|
||||
val FROST_REQUEST_FULFILMENTS = mapOf(
|
||||
TYPE_FROST_APPROVAL_NEEDED to TYPE_FROST_NONCE,
|
||||
)
|
||||
|
||||
/** The signing lines that ask rather than report. */
|
||||
val FROST_REQUEST_TYPES = setOf(TYPE_FROST_APPROVAL_NEEDED)
|
||||
|
||||
/** Every signing line, for rendering them as system lines rather than bubbles. */
|
||||
val FROST_TYPES = setOf(
|
||||
TYPE_FROST_STARTED,
|
||||
TYPE_FROST_NONCE,
|
||||
TYPE_FROST_SIGNER_SET,
|
||||
TYPE_FROST_PARTIAL_SIGNATURE,
|
||||
TYPE_FROST_SIGNATURE,
|
||||
TYPE_FROST_COMPLETE,
|
||||
TYPE_FROST_FAILED,
|
||||
TYPE_FROST_APPROVAL_NEEDED,
|
||||
)
|
||||
|
||||
/**
|
||||
* The signing lines somebody did, as opposed to ones that simply happened.
|
||||
* Their content is written as a predicate for the actor's name to be read
|
||||
* in front of. A finished signature has no actor: the group ends up with
|
||||
* one, nobody hands it to them.
|
||||
*/
|
||||
val FROST_AUTHORED_TYPES = setOf(
|
||||
TYPE_FROST_STARTED,
|
||||
TYPE_FROST_NONCE,
|
||||
TYPE_FROST_SIGNER_SET,
|
||||
TYPE_FROST_PARTIAL_SIGNATURE,
|
||||
TYPE_FROST_SIGNATURE,
|
||||
TYPE_FROST_FAILED,
|
||||
)
|
||||
|
||||
val DKG_AUTHORED_TYPES = setOf(
|
||||
TYPE_DKG_STARTED,
|
||||
TYPE_DKG_HOST_KEY,
|
||||
@@ -199,6 +260,25 @@ data class ChatMessage(
|
||||
val event = Event.fromJsonOrNull(groupEventResult.innerEventJson)
|
||||
?: throw MarmotUnprocessableInnerEventException("Can't process ${groupEventResult.innerEventJson}")
|
||||
|
||||
// A submission is an envelope: the nip30303 event it delivers
|
||||
// is in its content, written by whoever wrote it. Everything
|
||||
// else on the wire is the nip30303 event itself. Either way the
|
||||
// row on disk is the outer event -- that is what the group sent
|
||||
// and what the chat line is attributed to.
|
||||
val submission = if (event.kind == SubmissionEvent.KIND) {
|
||||
SubmissionEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val payload = submission?.payload()
|
||||
|
||||
database.marmotInnerEventDao().upsert(
|
||||
MarmotInnerEvent(
|
||||
id = event.id,
|
||||
@@ -208,296 +288,37 @@ data class ChatMessage(
|
||||
content = event.content,
|
||||
chatRoomId = groupEventResult.groupId,
|
||||
kind = event.kind,
|
||||
payloadEventId = payload?.id,
|
||||
createdAt = Instant.fromEpochSeconds(event.createdAt)
|
||||
)
|
||||
)
|
||||
when (event.kind) {
|
||||
ChatEvent.KIND -> {
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "message",
|
||||
marmotGroupEventId = groupEvent.id,
|
||||
marmotInnerEventId = event.id,
|
||||
senderPublicKey = event.pubKey,
|
||||
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
|
||||
chatRoomId = groupEventResult.groupId,
|
||||
createdAt = Instant.fromEpochSeconds(event.createdAt),
|
||||
content = event.content, // TODO: Figure out what to do here...
|
||||
)
|
||||
}
|
||||
ArtifactEvent.KIND -> {
|
||||
MantraArtifact.fromArtifactEvent(
|
||||
artifactEvent = ArtifactEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig,
|
||||
),
|
||||
chatRoomId = groupEventResult.groupId,
|
||||
)?.let { mantraArtifact ->
|
||||
database.mantraArtifactDao().upsert(
|
||||
mantraArtifact.copy(
|
||||
marmotGroupEventId = groupEvent.id,
|
||||
)
|
||||
)
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "artifact",
|
||||
marmotGroupEventId = groupEvent.id,
|
||||
marmotInnerEventId = event.id,
|
||||
senderPublicKey = event.pubKey,
|
||||
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
|
||||
chatRoomId = groupEventResult.groupId,
|
||||
createdAt = Instant.fromEpochSeconds(event.createdAt),
|
||||
content = "Added ${mantraArtifact.name} to artifacts"
|
||||
)
|
||||
}
|
||||
}
|
||||
ArtifactVersionEvent.KIND -> {
|
||||
MantraArtifactVersion.fromArtifactVersionEvent(
|
||||
artifactVersionEvent = ArtifactVersionEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
),
|
||||
chatRoomId = groupEventResult.groupId,
|
||||
)?.let { mantraArtifactVersion ->
|
||||
database.mantraArtifactVersionDao().upsert(
|
||||
mantraArtifactVersion.copy(
|
||||
marmotGroupEventId = groupEvent.id,
|
||||
)
|
||||
)
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "artifactVersion",
|
||||
marmotGroupEventId = groupEvent.id,
|
||||
marmotInnerEventId = event.id,
|
||||
senderPublicKey = event.pubKey,
|
||||
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
|
||||
chatRoomId = groupEventResult.groupId,
|
||||
createdAt = Instant.fromEpochSeconds(event.createdAt),
|
||||
content = "Added ${mantraArtifactVersion.versionLabel} to artifact versions" // TODO: Use artifact name...
|
||||
)
|
||||
}
|
||||
}
|
||||
ChapterEvent.KIND -> {
|
||||
MantraChapter.fromChapterEvent(
|
||||
chapterEvent = ChapterEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
),
|
||||
chatRoomId = groupEventResult.groupId,
|
||||
)?.let { mantraChapter ->
|
||||
database.mantraChapterDao().upsert(
|
||||
mantraChapter.copy(
|
||||
marmotGroupEventId = groupEvent.id,
|
||||
)
|
||||
)
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "chapter",
|
||||
marmotGroupEventId = groupEvent.id,
|
||||
marmotInnerEventId = event.id,
|
||||
senderPublicKey = event.pubKey,
|
||||
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
|
||||
chatRoomId = groupEventResult.groupId,
|
||||
createdAt = Instant.fromEpochSeconds(event.createdAt),
|
||||
content = "Added ${mantraChapter.name} to chapters" // TODO: Use artifact name...
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ChunkEvent.KIND -> {
|
||||
MantraChunk.fromChunkEvent(
|
||||
chunkEvent = ChunkEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
),
|
||||
chatRoomId = groupEventResult.groupId,
|
||||
)?.let { mantraChunk ->
|
||||
database.mantraChunkDao().upsert(
|
||||
mantraChunk.copy(
|
||||
marmotGroupEventId = groupEvent.id,
|
||||
)
|
||||
)
|
||||
|
||||
// TODO: Chunks might be too noisy to show in chat...
|
||||
}
|
||||
null
|
||||
}
|
||||
DialectEvent.KIND -> {
|
||||
MantraDialect.fromDialectEvent(
|
||||
dialectEvent = DialectEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
),
|
||||
chatRoomId = groupEventResult.groupId,
|
||||
)?.let { mantraDialect ->
|
||||
database.mantraDialectDao().upsert(
|
||||
mantraDialect.copy(
|
||||
marmotGroupEventId = groupEvent.id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "dialect",
|
||||
marmotGroupEventId = groupEvent.id,
|
||||
marmotInnerEventId = event.id,
|
||||
senderPublicKey = event.pubKey,
|
||||
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
|
||||
chatRoomId = groupEventResult.groupId,
|
||||
createdAt = Instant.fromEpochSeconds(event.createdAt),
|
||||
content = "Added ${mantraDialect.name} to dialects" // TODO: Use artifact name...
|
||||
)
|
||||
}
|
||||
}
|
||||
TranslationArtifactVersionEvent.KIND -> {
|
||||
MantraTranslationArtifactVersion.fromTranslationArtifactVersionEvent(
|
||||
translationArtifactVersionEvent = TranslationArtifactVersionEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
),
|
||||
chatRoomId = groupEventResult.groupId,
|
||||
)?.let { mantraTranslationArtifactVersion ->
|
||||
database.mantraTranslationArtifactVersionDao().upsert(
|
||||
mantraTranslationArtifactVersion.copy(
|
||||
marmotGroupEventId = groupEvent.id,
|
||||
)
|
||||
)
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "translationArtifactVersion",
|
||||
marmotGroupEventId = groupEvent.id,
|
||||
marmotInnerEventId = event.id,
|
||||
senderPublicKey = event.pubKey,
|
||||
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
|
||||
chatRoomId = groupEventResult.groupId,
|
||||
createdAt = Instant.fromEpochSeconds(event.createdAt),
|
||||
content = "Added ${mantraTranslationArtifactVersion.name} to translation artifact versions" // TODO: Use artifact name...
|
||||
)
|
||||
}
|
||||
}
|
||||
TranslationArtifactVersionContributorListEvent.KIND -> {
|
||||
// TODO: Consume contributors...
|
||||
null
|
||||
}
|
||||
TranslationChapterEvent.KIND -> {
|
||||
MantraTranslationChapter.fromTranslationChapterEvent(
|
||||
translationChapterEvent = TranslationChapterEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
),
|
||||
chatRoomId = groupEventResult.groupId,
|
||||
)?.let { mantraTranslationChapter ->
|
||||
database.mantraTranslationChapterDao().upsert(
|
||||
mantraTranslationChapter.copy(
|
||||
marmotGroupEventId = groupEvent.id,
|
||||
)
|
||||
)
|
||||
// TODO: translation chapter might be too noisy for chat updates
|
||||
}
|
||||
null
|
||||
}
|
||||
TranslationChunkEvent.KIND -> {
|
||||
MantraTranslationChunk.fromTranslationChunkEvent(
|
||||
translationChunkEvent = TranslationChunkEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
),
|
||||
chatRoomId = groupEventResult.groupId,
|
||||
)?.let { mantraTranslationChunk ->
|
||||
database.mantraTranslationChunkDao().upsert(
|
||||
mantraTranslationChunk.copy(
|
||||
marmotGroupEventId = groupEvent.id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
null
|
||||
}
|
||||
TranslationContributorListEvent.KIND -> {
|
||||
// TODO: Consume contributors...
|
||||
null
|
||||
}
|
||||
TranslationEvent.KIND -> {
|
||||
MantraTranslation.fromTranslationEvent(
|
||||
translationEvent = TranslationEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
),
|
||||
chatRoomId = groupEventResult.groupId,
|
||||
)?.let { mantraTranslation ->
|
||||
database.mantraTranslationDao().upsert(
|
||||
mantraTranslation.copy(
|
||||
marmotGroupEventId = groupEvent.id,
|
||||
)
|
||||
)
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "translation",
|
||||
marmotGroupEventId = groupEvent.id,
|
||||
marmotInnerEventId = event.id,
|
||||
senderPublicKey = event.pubKey,
|
||||
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
|
||||
chatRoomId = groupEventResult.groupId,
|
||||
createdAt = Instant.fromEpochSeconds(event.createdAt),
|
||||
content = "Added ${mantraTranslation.text} to translations" // TODO: Reference original text
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "unsupported",
|
||||
marmotGroupEventId = groupEvent.id,
|
||||
marmotInnerEventId = event.id,
|
||||
senderPublicKey = groupEvent.pubKey,
|
||||
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
|
||||
chatRoomId = groupEventResult.groupId,
|
||||
createdAt = Instant.fromEpochSeconds(groupEvent.createdAt),
|
||||
content = groupEventResult.innerEventJson, // TODO: Figure out what to do here...
|
||||
)
|
||||
}
|
||||
// A submission whose payload will not parse, or which carries
|
||||
// another submission, is kept but not applied: there is nothing
|
||||
// here we can turn into a row.
|
||||
if (submission != null && (payload == null || payload.kind == SubmissionEvent.KIND)) {
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "unsupported",
|
||||
marmotGroupEventId = groupEvent.id,
|
||||
marmotInnerEventId = event.id,
|
||||
senderPublicKey = event.pubKey,
|
||||
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
|
||||
chatRoomId = groupEventResult.groupId,
|
||||
createdAt = Instant.fromEpochSeconds(event.createdAt),
|
||||
content = event.content,
|
||||
)
|
||||
} else {
|
||||
applyInnerEvent(
|
||||
database = database,
|
||||
groupId = groupEventResult.groupId,
|
||||
event = payload ?: event,
|
||||
marmotGroupEventId = groupEvent.id,
|
||||
marmotInnerEventId = event.id,
|
||||
senderPublicKey = event.pubKey,
|
||||
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
|
||||
createdAt = Instant.fromEpochSeconds(event.createdAt),
|
||||
)
|
||||
}
|
||||
}
|
||||
is GroupEventResult.CommitPending -> {
|
||||
@@ -581,5 +402,323 @@ data class ChatMessage(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one nip30303 [event] the group delivered, and describe it for
|
||||
* the transcript.
|
||||
*
|
||||
* [event] is what is being applied; the other parameters are how it
|
||||
* arrived. They come apart for submissions: the payload is written by
|
||||
* whoever wrote it -- possibly nobody in this group -- while
|
||||
* [marmotInnerEventId], [senderPublicKey] and [createdAt] all belong to
|
||||
* the envelope a member actually sent. Entity rows take their author
|
||||
* from [event], so the chat line says who added it and the row says who
|
||||
* wrote it.
|
||||
*/
|
||||
internal suspend fun applyInnerEvent(
|
||||
database: MantraDatabase,
|
||||
groupId: String,
|
||||
event: Event,
|
||||
marmotGroupEventId: HexKey?,
|
||||
marmotInnerEventId: HexKey?,
|
||||
senderPublicKey: HexKey,
|
||||
isUserMessage: Boolean,
|
||||
createdAt: Instant,
|
||||
): ChatMessage? {
|
||||
return when (event.kind) {
|
||||
ChatEvent.KIND -> {
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "message",
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
marmotInnerEventId = marmotInnerEventId,
|
||||
senderPublicKey = senderPublicKey,
|
||||
isUserMessage = isUserMessage,
|
||||
chatRoomId = groupId,
|
||||
createdAt = createdAt,
|
||||
content = event.content, // TODO: Figure out what to do here...
|
||||
)
|
||||
}
|
||||
ArtifactEvent.KIND -> {
|
||||
MantraArtifact.fromArtifactEvent(
|
||||
artifactEvent = ArtifactEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig,
|
||||
),
|
||||
chatRoomId = groupId,
|
||||
)?.let { mantraArtifact ->
|
||||
database.mantraArtifactDao().upsert(
|
||||
mantraArtifact.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
)
|
||||
)
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "artifact",
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
marmotInnerEventId = marmotInnerEventId,
|
||||
senderPublicKey = senderPublicKey,
|
||||
isUserMessage = isUserMessage,
|
||||
chatRoomId = groupId,
|
||||
createdAt = createdAt,
|
||||
content = "Added ${mantraArtifact.name} to artifacts"
|
||||
)
|
||||
}
|
||||
}
|
||||
ArtifactVersionEvent.KIND -> {
|
||||
MantraArtifactVersion.fromArtifactVersionEvent(
|
||||
artifactVersionEvent = ArtifactVersionEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
),
|
||||
chatRoomId = groupId,
|
||||
)?.let { mantraArtifactVersion ->
|
||||
database.mantraArtifactVersionDao().upsert(
|
||||
mantraArtifactVersion.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
)
|
||||
)
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "artifactVersion",
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
marmotInnerEventId = marmotInnerEventId,
|
||||
senderPublicKey = senderPublicKey,
|
||||
isUserMessage = isUserMessage,
|
||||
chatRoomId = groupId,
|
||||
createdAt = createdAt,
|
||||
content = "Added ${mantraArtifactVersion.versionLabel} to artifact versions" // TODO: Use artifact name...
|
||||
)
|
||||
}
|
||||
}
|
||||
ChapterEvent.KIND -> {
|
||||
MantraChapter.fromChapterEvent(
|
||||
chapterEvent = ChapterEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
),
|
||||
chatRoomId = groupId,
|
||||
)?.let { mantraChapter ->
|
||||
database.mantraChapterDao().upsert(
|
||||
mantraChapter.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
)
|
||||
)
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "chapter",
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
marmotInnerEventId = marmotInnerEventId,
|
||||
senderPublicKey = senderPublicKey,
|
||||
isUserMessage = isUserMessage,
|
||||
chatRoomId = groupId,
|
||||
createdAt = createdAt,
|
||||
content = "Added ${mantraChapter.name} to chapters" // TODO: Use artifact name...
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ChunkEvent.KIND -> {
|
||||
MantraChunk.fromChunkEvent(
|
||||
chunkEvent = ChunkEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
),
|
||||
chatRoomId = groupId,
|
||||
)?.let { mantraChunk ->
|
||||
database.mantraChunkDao().upsert(
|
||||
mantraChunk.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
)
|
||||
)
|
||||
|
||||
// TODO: Chunks might be too noisy to show in chat...
|
||||
}
|
||||
null
|
||||
}
|
||||
DialectEvent.KIND -> {
|
||||
MantraDialect.fromDialectEvent(
|
||||
dialectEvent = DialectEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
),
|
||||
chatRoomId = groupId,
|
||||
)?.let { mantraDialect ->
|
||||
database.mantraDialectDao().upsert(
|
||||
mantraDialect.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "dialect",
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
marmotInnerEventId = marmotInnerEventId,
|
||||
senderPublicKey = senderPublicKey,
|
||||
isUserMessage = isUserMessage,
|
||||
chatRoomId = groupId,
|
||||
createdAt = createdAt,
|
||||
content = "Added ${mantraDialect.name} to dialects" // TODO: Use artifact name...
|
||||
)
|
||||
}
|
||||
}
|
||||
TranslationArtifactVersionEvent.KIND -> {
|
||||
MantraTranslationArtifactVersion.fromTranslationArtifactVersionEvent(
|
||||
translationArtifactVersionEvent = TranslationArtifactVersionEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
),
|
||||
chatRoomId = groupId,
|
||||
)?.let { mantraTranslationArtifactVersion ->
|
||||
database.mantraTranslationArtifactVersionDao().upsert(
|
||||
mantraTranslationArtifactVersion.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
)
|
||||
)
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "translationArtifactVersion",
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
marmotInnerEventId = marmotInnerEventId,
|
||||
senderPublicKey = senderPublicKey,
|
||||
isUserMessage = isUserMessage,
|
||||
chatRoomId = groupId,
|
||||
createdAt = createdAt,
|
||||
content = "Added ${mantraTranslationArtifactVersion.name} to translation artifact versions" // TODO: Use artifact name...
|
||||
)
|
||||
}
|
||||
}
|
||||
TranslationArtifactVersionContributorListEvent.KIND -> {
|
||||
// TODO: Consume contributors...
|
||||
null
|
||||
}
|
||||
TranslationChapterEvent.KIND -> {
|
||||
MantraTranslationChapter.fromTranslationChapterEvent(
|
||||
translationChapterEvent = TranslationChapterEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
),
|
||||
chatRoomId = groupId,
|
||||
)?.let { mantraTranslationChapter ->
|
||||
database.mantraTranslationChapterDao().upsert(
|
||||
mantraTranslationChapter.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
)
|
||||
)
|
||||
// TODO: translation chapter might be too noisy for chat updates
|
||||
}
|
||||
null
|
||||
}
|
||||
TranslationChunkEvent.KIND -> {
|
||||
MantraTranslationChunk.fromTranslationChunkEvent(
|
||||
translationChunkEvent = TranslationChunkEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
),
|
||||
chatRoomId = groupId,
|
||||
)?.let { mantraTranslationChunk ->
|
||||
database.mantraTranslationChunkDao().upsert(
|
||||
mantraTranslationChunk.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
null
|
||||
}
|
||||
TranslationContributorListEvent.KIND -> {
|
||||
// TODO: Consume contributors...
|
||||
null
|
||||
}
|
||||
TranslationEvent.KIND -> {
|
||||
MantraTranslation.fromTranslationEvent(
|
||||
translationEvent = TranslationEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
),
|
||||
chatRoomId = groupId,
|
||||
)?.let { mantraTranslation ->
|
||||
database.mantraTranslationDao().upsert(
|
||||
mantraTranslation.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
)
|
||||
)
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "translation",
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
marmotInnerEventId = marmotInnerEventId,
|
||||
senderPublicKey = senderPublicKey,
|
||||
isUserMessage = isUserMessage,
|
||||
chatRoomId = groupId,
|
||||
createdAt = createdAt,
|
||||
content = "Added ${mantraTranslation.text} to translations" // TODO: Reference original text
|
||||
)
|
||||
}
|
||||
}
|
||||
// A signing session's own protocol messages. FrostSigningManager
|
||||
// applies them and writes its own transcript lines naming who did
|
||||
// what, so an "unsupported" row here would be a second, worse
|
||||
// account of the same thing.
|
||||
in FrostSigningEvents.ALL -> null
|
||||
|
||||
else -> {
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "unsupported",
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
marmotInnerEventId = marmotInnerEventId,
|
||||
senderPublicKey = senderPublicKey,
|
||||
isUserMessage = isUserMessage,
|
||||
chatRoomId = groupId,
|
||||
createdAt = createdAt,
|
||||
content = event.toJson(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ 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 com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import fr.acinq.bitcoin.ByteVector
|
||||
import fr.acinq.bitcoin.PublicKey
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -82,6 +85,17 @@ data class DkgSession(
|
||||
/** Result, secret: this device's FROST secret share, hex. */
|
||||
val secretShare: HexKey? = null,
|
||||
|
||||
/**
|
||||
* Result: every participant's public share, comma-separated hex, in
|
||||
* participant order.
|
||||
*
|
||||
* Not secret, and not needed to finish the ceremony -- kept because signing
|
||||
* is what the key is for and FROST validates each signer's secret share
|
||||
* against its public one. Null on a ceremony that completed before this
|
||||
* column existed; signing still works there, without that check.
|
||||
*/
|
||||
val publicShares: String? = null,
|
||||
|
||||
/** Result: recovery data, to be backed up alongside the host key. */
|
||||
val recoveryData: HexKey? = null,
|
||||
|
||||
@@ -125,4 +139,11 @@ data class DkgSession(
|
||||
override val savedAt: Instant = createdAt,
|
||||
): TimestampedEntity, LocalStoreEntity {
|
||||
fun isCoordinator(): Boolean = coordinatorPublicKey == userPublicKey
|
||||
|
||||
/** The participants' public shares in participant order, or null if unrecorded. */
|
||||
fun publicShareList(): List<PublicKey>? = publicShares
|
||||
?.split(",")
|
||||
?.mapNotNull { hex -> hex.trim().takeIf { it.isNotEmpty() } }
|
||||
?.map { PublicKey(ByteVector(it.hexToByteArray())) }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package press.mantra.compose.database.model
|
||||
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.ForeignKey
|
||||
import androidx.room3.Index
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* One signing message from one member, keyed so a duplicate delivery overwrites
|
||||
* rather than accumulates — relays redeliver, and feeding the same nonce in
|
||||
* twice would make the coordinator's signer set the wrong length.
|
||||
*
|
||||
* Covers the nonce and the partial signature; the coordinator's own broadcasts
|
||||
* live on [FrostSigningSession] because there is only ever one of each.
|
||||
*
|
||||
* Keyed on `(sessionId, signerPublicKey, kind)`, which also means a member
|
||||
* cannot replace their own nonce once the coordinator has aggregated it — a
|
||||
* second nonce from the same signer overwrites the first, and the aggregate
|
||||
* built from it simply stops matching. The session's own write-once rule on the
|
||||
* aggregate is what makes that a stalled session rather than a leaked share.
|
||||
*/
|
||||
@Entity(
|
||||
primaryKeys = ["sessionId", "signerPublicKey", "kind"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = FrostSigningSession::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["sessionId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
Index("sessionId"),
|
||||
],
|
||||
)
|
||||
data class FrostSignerMessage(
|
||||
val sessionId: String,
|
||||
|
||||
/** The member's nostr public key — who sent it. */
|
||||
val signerPublicKey: HexKey,
|
||||
|
||||
/** One of the `FrostSigningEvents` kinds. */
|
||||
val kind: Kind,
|
||||
|
||||
/** Hex of the protocol bytes: a public nonce, or a partial signature. */
|
||||
val payload: HexKey,
|
||||
|
||||
val createdAt: Instant = Clock.System.now(),
|
||||
)
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package press.mantra.compose.database.model
|
||||
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.ForeignKey
|
||||
import androidx.room3.Index
|
||||
import androidx.room3.PrimaryKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import press.mantra.compose.database.model.traits.LocalStoreEntity
|
||||
import press.mantra.compose.database.model.traits.TimestampedEntity
|
||||
import press.mantra.compose.database.model.types.FrostSigningStage
|
||||
|
||||
/**
|
||||
* One FROST signing session, as this device sees it.
|
||||
*
|
||||
* Like [DkgSession] this stores *inputs* rather than protocol state, so a
|
||||
* session survives the app being killed halfway through: every step is
|
||||
* recomputed from what is on the row. Unlike a DKG that choice is not merely
|
||||
* convenient here, it is forced -- `fr.acinq.bitcoin.crypto.frost.SecretNonce`
|
||||
* cannot be serialised and refuses to be used twice, by design.
|
||||
*
|
||||
* ### The nonce, and why one session means one message
|
||||
*
|
||||
* [nonceRandom] is secret, and regenerating this device's nonce from it is safe
|
||||
* for exactly one reason: a session signs one message and can never be made to
|
||||
* sign another. `SecretNonce.generate` mixes the message in, so the same
|
||||
* randomness under a different message would be a different nonce -- but the
|
||||
* same randomness under the same message with two *different* aggregated nonces
|
||||
* would produce two partial signatures over one secret nonce, which is how a
|
||||
* secret share is extracted.
|
||||
*
|
||||
* Two rules keep that impossible, and both are load-bearing:
|
||||
*
|
||||
* - [eventId] is written when the session is created and a proposal that
|
||||
* disagrees with it is rejected rather than applied.
|
||||
* - [aggregatedNonce] and [signerIds] are written once. A second, different
|
||||
* signer set for the same session is ignored, not honoured.
|
||||
*/
|
||||
@Entity(
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = ChatRoom::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["chatRoomId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
Index("chatRoomId"),
|
||||
Index("dkgSessionId"),
|
||||
],
|
||||
)
|
||||
data class FrostSigningSession(
|
||||
/** Minted by the proposer, carried on every message as `frost_session`. */
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
|
||||
val chatRoomId: String,
|
||||
|
||||
/** The member who proposed the signature, who also aggregates for it. */
|
||||
val coordinatorPublicKey: HexKey,
|
||||
|
||||
/** Whose device this row belongs to, for multi-account support. */
|
||||
val userPublicKey: HexKey,
|
||||
|
||||
/** The ceremony whose key this signs with — a group may hold more than one. */
|
||||
val dkgSessionId: String,
|
||||
|
||||
/** The `t` of the t-of-n: how many partial signatures make a signature. */
|
||||
val threshold: Int,
|
||||
|
||||
/** The `n` the key was generated for. FROST needs it to place signer ids. */
|
||||
val participantCount: Int,
|
||||
|
||||
/** This device's FROST id: its index in the ceremony's participant order. */
|
||||
val signerId: Int,
|
||||
|
||||
val stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
|
||||
|
||||
/**
|
||||
* The unsigned event, as JSON. Kept whole so a member can be shown what
|
||||
* they are being asked to sign rather than a hash of it.
|
||||
*/
|
||||
val unsignedEventJson: String,
|
||||
|
||||
/**
|
||||
* The event id, which is the 32 bytes actually signed.
|
||||
*
|
||||
* Recomputed from the event's own fields on arrival, never taken from the
|
||||
* proposal. It pins the session to one message -- see the class note on why
|
||||
* that is what makes reusing [nonceRandom] safe.
|
||||
*/
|
||||
val eventId: HexKey,
|
||||
|
||||
/** Secret. 32 bytes of fresh randomness, the seed for this device's nonce. */
|
||||
val nonceRandom: HexKey,
|
||||
|
||||
/** The coordinator's `AggregatedNonce` once it arrives, hex. Written once. */
|
||||
val aggregatedNonce: HexKey? = null,
|
||||
|
||||
/** The chosen signers' FROST ids in aggregation order, comma separated. Written once. */
|
||||
val signerIds: String? = null,
|
||||
|
||||
/** Result: the finished 64-byte BIP-340 signature over [eventId], hex. */
|
||||
val signature: HexKey? = null,
|
||||
|
||||
val failureReason: String? = null,
|
||||
|
||||
/**
|
||||
* When this device's owner agreed to sign, and with it to everything the
|
||||
* session does on their behalf. Null until they do, and nothing of theirs
|
||||
* goes out before it is set.
|
||||
*
|
||||
* One gate rather than the DKG's three. What a signer is consenting to is
|
||||
* the event, and the event is fixed before they are asked: the second round
|
||||
* puts no new question to them, so asking again would be asking the same
|
||||
* question twice about a decision already made.
|
||||
*/
|
||||
val signApprovedAt: Instant? = null,
|
||||
|
||||
/** Whether the chat line asking for that approval has been written. */
|
||||
val approvalRequestedAt: Instant? = null,
|
||||
|
||||
override val createdAt: Instant = Clock.System.now(),
|
||||
override val updatedAt: Instant = createdAt,
|
||||
override val savedAt: Instant = createdAt,
|
||||
): TimestampedEntity, LocalStoreEntity {
|
||||
fun isCoordinator(): Boolean = coordinatorPublicKey == userPublicKey
|
||||
|
||||
/** The chosen signers, or null while the coordinator has yet to choose. */
|
||||
fun signerIdList(): List<Int>? = signerIds
|
||||
?.split(",")
|
||||
?.mapNotNull { it.trim().toIntOrNull() }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
/** Whether this device was picked to sign. A t-of-n key does not need everyone. */
|
||||
fun isSigner(): Boolean = signerIdList()?.contains(signerId) ?: false
|
||||
}
|
||||
+11
@@ -64,6 +64,15 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload...
|
||||
val content: String,
|
||||
val quotedEventId: String? = null,
|
||||
|
||||
/**
|
||||
* For a SubmissionEvent rumor, the id of the nip30303 event it carries.
|
||||
*
|
||||
* The submission's own id is derived from the envelope, so it is the only
|
||||
* handle the queue has on what is actually being submitted. Null for every
|
||||
* other kind, where the row *is* the event.
|
||||
*/
|
||||
val payloadEventId: HexKey? = null,
|
||||
|
||||
/**
|
||||
* Associated MarmotGroupEvent
|
||||
*/
|
||||
@@ -117,6 +126,7 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload...
|
||||
if (!tags.contentDeepEquals(other.tags)) return false
|
||||
if (content != other.content) return false
|
||||
if (quotedEventId != other.quotedEventId) return false
|
||||
if (payloadEventId != other.payloadEventId) return false
|
||||
if (marmotGroupEventId != other.marmotGroupEventId) return false
|
||||
if (createdAt != other.createdAt) return false
|
||||
if (updatedAt != other.updatedAt) return false
|
||||
@@ -136,6 +146,7 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload...
|
||||
result = 31 * result + tags.contentDeepHashCode()
|
||||
result = 31 * result + content.hashCode()
|
||||
result = 31 * result + (quotedEventId?.hashCode() ?: 0)
|
||||
result = 31 * result + (payloadEventId?.hashCode() ?: 0)
|
||||
result = 31 * result + (marmotGroupEventId?.hashCode() ?: 0)
|
||||
result = 31 * result + createdAt.hashCode()
|
||||
result = 31 * result + updatedAt.hashCode()
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package press.mantra.compose.database.model.types
|
||||
|
||||
/**
|
||||
* How far a FROST signing session has got, from the point of view of the device
|
||||
* holding the row. Coordinator and signers move through the same ladder; the
|
||||
* coordinator simply has extra work to do at [COLLECTING_NONCES] and
|
||||
* [COLLECTING_PARTIAL_SIGNATURES].
|
||||
*
|
||||
* Declaration order is the ladder: `FrostSigningManager` compares ordinals to
|
||||
* keep the label moving forwards when messages arrive out of order, so the
|
||||
* collecting stages must stay in the order the session runs them.
|
||||
*/
|
||||
enum class FrostSigningStage {
|
||||
/** Proposal seen; waiting for enough signers to offer a nonce. */
|
||||
COLLECTING_NONCES,
|
||||
|
||||
/** The signer set is fixed; waiting on their partial signatures. */
|
||||
COLLECTING_PARTIAL_SIGNATURES,
|
||||
|
||||
/** Aggregated and verified. The event is signed. */
|
||||
COMPLETE,
|
||||
|
||||
/** Abandoned. See `FrostSigningSession.failureReason`. */
|
||||
FAILED
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package press.mantra.compose.database.repository
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.model.FrostSignerMessage
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.FrostSigningStage
|
||||
import press.mantra.compose.managers.FrostSigningManager
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
|
||||
class DatabaseFrostSigningRepository(
|
||||
private val database: MantraDatabase,
|
||||
private val scope: CoroutineScope
|
||||
): FrostSigningRepository {
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
override fun observeSessionById(sessionId: String): Flow<FrostSigningSession?> =
|
||||
database.frostSigningSessionDao().observeSessionById(sessionId)
|
||||
|
||||
override fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<FrostSigningSession>> =
|
||||
database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId)
|
||||
|
||||
override fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>> =
|
||||
database.frostSigningSessionDao().observeMessages(sessionId)
|
||||
|
||||
override suspend fun getSessionById(sessionId: String): FrostSigningSession? =
|
||||
database.frostSigningSessionDao().getSessionById(sessionId)
|
||||
|
||||
override suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession? {
|
||||
val sessions = database.frostSigningSessionDao().getSessionsForChatRoom(chatRoomId)
|
||||
|
||||
return sessions.firstOrNull {
|
||||
it.stage != FrostSigningStage.COMPLETE && it.stage != FrostSigningStage.FAILED
|
||||
} ?: sessions.firstOrNull()
|
||||
}
|
||||
|
||||
override suspend fun canSign(chatRoomId: String): Boolean =
|
||||
FrostSigningManager.canSign(database, chatRoomId)
|
||||
|
||||
override suspend fun proposeSigning(
|
||||
localChatRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
kind: Kind,
|
||||
tags: Array<Array<String>>,
|
||||
content: String
|
||||
): FrostSigningSession? = try {
|
||||
FrostSigningManager.proposeSigning(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = userPublicKey,
|
||||
kind = kind,
|
||||
tags = tags,
|
||||
content = content
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
// Proposing throws when the group has no key or this device was not in the
|
||||
// ceremony. Both are states the UI is supposed to have checked for, so this
|
||||
// is a null the caller reports rather than a crash.
|
||||
logger.e("Error proposing a signature in ${localChatRoom.chatRoom.id}", e)
|
||||
null
|
||||
}
|
||||
|
||||
override suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String) {
|
||||
try {
|
||||
FrostSigningManager.approve(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
sessionId = sessionId
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
// The session fails itself and tells the group; swallowing here keeps a
|
||||
// protocol fault from taking the screen down with it.
|
||||
logger.e("Error approving signing session $sessionId", e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) {
|
||||
try {
|
||||
FrostSigningManager.decline(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
sessionId = sessionId
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
logger.e("Error declining signing session $sessionId", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun signedEvent(session: FrostSigningSession): Event? =
|
||||
FrostSigningManager.signedEvent(session)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DatabaseFrostSigningRepository"
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -44,7 +44,7 @@ class DatabaseMantraRepository(
|
||||
dialectId: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MarmotInnerEvent? {
|
||||
): MantraTranslationArtifactVersion? {
|
||||
return database.mantraDao().addTranslationArtifactVersion(
|
||||
artifactId = artifactId,
|
||||
dialectId = dialectId,
|
||||
@@ -99,7 +99,7 @@ class DatabaseMantraRepository(
|
||||
originalText: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MarmotInnerEvent? {
|
||||
): MantraChapter? {
|
||||
return database.mantraDao().addChapter(
|
||||
artifactId = artifactId,
|
||||
name = name,
|
||||
@@ -121,7 +121,7 @@ class DatabaseMantraRepository(
|
||||
country: String,
|
||||
language: String,
|
||||
userPublicKey: HexKey,
|
||||
): MarmotInnerEvent? {
|
||||
): MantraDialect? {
|
||||
return database.mantraDao().addDialect(
|
||||
localChatRoom = localChatRoom,
|
||||
name = name,
|
||||
@@ -140,7 +140,7 @@ class DatabaseMantraRepository(
|
||||
userPublicKey: HexKey,
|
||||
visibility: String,
|
||||
license: String,
|
||||
): MarmotInnerEvent? {
|
||||
): MantraArtifact? {
|
||||
return database.mantraDao().addArtifact(
|
||||
localChatRoom = localChatRoom,
|
||||
name = name,
|
||||
@@ -158,7 +158,7 @@ class DatabaseMantraRepository(
|
||||
artifactId: HexKey,
|
||||
versionLabel: String,
|
||||
userPublicKey: HexKey,
|
||||
): MarmotInnerEvent? {
|
||||
): MantraArtifactVersion? {
|
||||
return database.mantraDao().addArtifactVersion(
|
||||
localChatRoom = localChatRoom,
|
||||
artifactId = artifactId,
|
||||
|
||||
+6
@@ -574,6 +574,12 @@ object ChillDkgRitualManager {
|
||||
stage = DkgRitualStage.COMPLETE,
|
||||
thresholdPublicKey = output.thresholdPublicKey?.value?.toHex(),
|
||||
secretShare = output.secretShare?.value?.toHex(),
|
||||
// Kept for signing, which needs every participant's public
|
||||
// share to place and check the signers. In participant order,
|
||||
// the same order the ids are derived from.
|
||||
publicShares = output.publicShares
|
||||
.joinToString(",") { share -> share.value.toHex() }
|
||||
.takeIf { output.publicShares.isNotEmpty() },
|
||||
recoveryData = output.recovery?.toHex()
|
||||
)
|
||||
}
|
||||
|
||||
+1210
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,152 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Keeps a room's [MlsGroup] alive between messages instead of rebuilding it
|
||||
* from the stored state every time.
|
||||
*
|
||||
* ### The bug this exists for
|
||||
*
|
||||
* MLS is specified to tolerate out-of-order delivery within an epoch: a
|
||||
* receiver that gets generation N+1 before N derives and caches the key for N
|
||||
* so the older message can still be read when it turns up. Quartz's
|
||||
* `SecretTree` does exactly that, in a private `skippedKeys` map.
|
||||
*
|
||||
* `SecretTree.exportSenderStates()` does not include that map, so
|
||||
* `MlsGroup.saveState()` does not carry it. Rebuilding the group from stored
|
||||
* state therefore throws the skipped keys away, and a message for a generation
|
||||
* the ratchet has already passed fails
|
||||
* `require(generation >= state.applicationGeneration)` and is dropped. There is
|
||||
* no recovering it afterwards: the key is gone and the sender will not resend.
|
||||
*
|
||||
* Nostr relays offer no ordering whatsoever, so this is not an edge case. Two
|
||||
* events published in the same second race, and exactly one survives — which is
|
||||
* how a signing session's proposal was lost while the nonce sent immediately
|
||||
* behind it arrived fine.
|
||||
*
|
||||
* ### What this fixes, and what it does not
|
||||
*
|
||||
* Holding the instance means `skippedKeys` survives for as long as the process
|
||||
* does and nothing else writes the room's state. That covers the case that
|
||||
* actually bites — a burst of messages arriving in one sync — because they are
|
||||
* decrypted one after another against the same tree.
|
||||
*
|
||||
* It does not survive a restart, and it does not survive another writer, so
|
||||
* reordering across app launches still loses messages. The real fix is for
|
||||
* `exportSenderStates` to carry the skipped keys; see
|
||||
* `docs/mls-skipped-keys.md`.
|
||||
*
|
||||
* ### Staleness
|
||||
*
|
||||
* The group is only reused when the stored state is still exactly what this
|
||||
* cache last wrote. Anything else that saves a room's state — sending a message
|
||||
* advances the sender ratchet and saves, so does adding a member — changes the
|
||||
* hex, and the next read rebuilds rather than carrying on from a group that has
|
||||
* been overtaken. Losing the skipped keys there is the same behaviour as
|
||||
* before this existed, so the fallback is never worse than not caching.
|
||||
*/
|
||||
object MlsGroupCache {
|
||||
private val cache = LiveInstanceCache<MlsGroup> { it.saveState().encodeTls().toHex() }
|
||||
|
||||
/**
|
||||
* Runs [block] against the room's live group, then stores whatever state it
|
||||
* left behind.
|
||||
*
|
||||
* [storedStateHex] is the room's state as the database currently has it, and
|
||||
* [build] turns it into a group. [save] is handed the state to persist.
|
||||
*
|
||||
* Returns null without calling [block] when the room has no usable group
|
||||
* state, which is the same thing a failed `toMlsGroup()` meant before.
|
||||
*/
|
||||
suspend fun <T> withGroup(
|
||||
chatRoomId: String,
|
||||
storedStateHex: String?,
|
||||
build: () -> MlsGroup?,
|
||||
save: suspend (String) -> Unit,
|
||||
block: suspend (MlsGroup) -> T,
|
||||
): T? = cache.withInstance(
|
||||
key = chatRoomId,
|
||||
storedState = storedStateHex,
|
||||
build = build,
|
||||
save = save,
|
||||
block = block
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One live instance per key, reused only while the stored state is still the one
|
||||
* this cache last wrote.
|
||||
*
|
||||
* Split out from [MlsGroupCache] so the decision it makes can be tested without
|
||||
* standing up an MLS group. That decision is the whole safety argument: reuse
|
||||
* when nothing else has written, rebuild when something has, and never carry on
|
||||
* with an instance whose last use failed part-way through.
|
||||
*/
|
||||
internal class LiveInstanceCache<T : Any>(
|
||||
/** The persisted form of an instance, for spotting another writer. */
|
||||
private val stateOf: (T) -> String,
|
||||
) {
|
||||
private val logger = Logger.withTag("LiveInstanceCache")
|
||||
|
||||
private class Entry<T>(val instance: T, val state: String)
|
||||
|
||||
private val entries = mutableMapOf<String, Entry<T>>()
|
||||
|
||||
/**
|
||||
* Serialises use of one key's instance.
|
||||
*
|
||||
* The instance is mutable and [block] advances it, so two callers running at
|
||||
* once would corrupt it. One lock per key rather than one overall, so a busy
|
||||
* key cannot hold up a quiet one.
|
||||
*
|
||||
* Held across [block], which may touch the database. Safe here because a
|
||||
* caller only ever takes this lock while it is already running -- it never
|
||||
* waits on a resource the holder is itself waiting for.
|
||||
*/
|
||||
private val locks = mutableMapOf<String, Mutex>()
|
||||
private val locksGuard = Mutex()
|
||||
|
||||
private suspend fun lockFor(key: String): Mutex =
|
||||
locksGuard.withLock { locks.getOrPut(key) { Mutex() } }
|
||||
|
||||
suspend fun <R> withInstance(
|
||||
key: String,
|
||||
storedState: String?,
|
||||
build: () -> T?,
|
||||
save: suspend (String) -> Unit,
|
||||
block: suspend (T) -> R,
|
||||
): R? = lockFor(key).withLock {
|
||||
val cached = entries[key]
|
||||
|
||||
val instance = if (cached != null && cached.state == storedState) {
|
||||
cached.instance
|
||||
} else {
|
||||
if (cached != null) {
|
||||
logger.d("$key was written elsewhere; rebuilding")
|
||||
}
|
||||
// Dropped before the block runs, so a build that fails does not leave
|
||||
// the old instance behind to be picked up by the next caller.
|
||||
entries.remove(key)
|
||||
build() ?: return@withLock null
|
||||
}
|
||||
|
||||
// Deliberately not in a finally: an instance whose use threw part-way is
|
||||
// in an unknown state, and the next caller should rebuild from whatever
|
||||
// was last persisted rather than carry on with it.
|
||||
val result = block(instance)
|
||||
|
||||
val state = stateOf(instance)
|
||||
save(state)
|
||||
entries[key] = Entry(instance = instance, state = state)
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/** How many instances are held. For tests. */
|
||||
internal fun size(): Int = entries.size
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package press.mantra.compose.nostr.frost
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import press.mantra.compose.nostr.frost.tags.FrostKeyTag
|
||||
import press.mantra.compose.nostr.frost.tags.FrostSessionIdTag
|
||||
import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag
|
||||
|
||||
/**
|
||||
* The nostr kinds a FROST signing session is carried on.
|
||||
*
|
||||
* These are **rumor** kinds: they only ever exist inside a Marmot group event,
|
||||
* MLS-encrypted to the group and then wrapped again under the group's exporter
|
||||
* secret, so no relay sees them and the replaceable semantics normally implied
|
||||
* by the 3xxxx range never apply.
|
||||
*
|
||||
* Who talks to whom, in order:
|
||||
*
|
||||
* ```
|
||||
* proposer --[ 30320 proposal ]-> everyone the unsigned event to sign
|
||||
* signer --[ 30321 nonce ]-> everyone this device's public nonce
|
||||
* proposer --[ 30322 signer set ]-> everyone who is signing, and their aggregated nonce
|
||||
* signer --[ 30323 partial ]-> everyone this device's partial signature
|
||||
* proposer --[ 30324 signature ]-> everyone the finished 64-byte signature
|
||||
* anyone --[ 30325 failure ]-> everyone abandon + blame
|
||||
* ```
|
||||
*
|
||||
* ### Why 3032x
|
||||
*
|
||||
* These share the inner-event space with the nip30303 document kinds, which run
|
||||
* 30300 up to [press.mantra.compose.nostr.nip30303.SubmissionEvent] at 30312 --
|
||||
* the same space, because both are Marmot inner events and both are dispatched
|
||||
* on kind by the same inbound path. Starting at 30320 leaves that family room to
|
||||
* grow into.
|
||||
*
|
||||
* The DKG's 30310-30316 look like a clash and are not: those exist only inside
|
||||
* NIP-17 gift wraps, and nothing reads a kind across both transports. It is
|
||||
* worth knowing that the numbers already overlap there -- the DKG's proposal and
|
||||
* host-key kinds sit on 30310 and 30311 alongside two nip30303 kinds, and its
|
||||
* round-1 kind is 30312, alongside SubmissionEvent -- because that separation is
|
||||
* an accident of routing rather than a decision, and the next family added
|
||||
* should not rely on it.
|
||||
*/
|
||||
object FrostSigningEvents {
|
||||
/**
|
||||
* Opens a session. Content is the unsigned nostr event, as JSON; the key to
|
||||
* sign with is named in [FrostKeyTag].
|
||||
*/
|
||||
val PROPOSAL: Kind = 30320
|
||||
|
||||
/** A signer's `IndividualNonce`, hex encoded. */
|
||||
val NONCE: Kind = 30321
|
||||
|
||||
/**
|
||||
* The coordinator's chosen signers, in [FrostSignerIdsTag], with their
|
||||
* `AggregatedNonce` as the content, hex encoded.
|
||||
*/
|
||||
val SIGNER_SET: Kind = 30322
|
||||
|
||||
/** A signer's 32-byte partial signature, hex encoded. */
|
||||
val PARTIAL_SIGNATURE: Kind = 30323
|
||||
|
||||
/** The finished 64-byte BIP-340 signature over the event id, hex encoded. */
|
||||
val SIGNATURE: Kind = 30324
|
||||
|
||||
/** Session abandoned. Content is the reason, for showing to the group. */
|
||||
val FAILURE: Kind = 30325
|
||||
|
||||
/** Every kind above, for filtering inbound payloads in one check. */
|
||||
val ALL: Set<Kind> = setOf(
|
||||
PROPOSAL,
|
||||
NONCE,
|
||||
SIGNER_SET,
|
||||
PARTIAL_SIGNATURE,
|
||||
SIGNATURE,
|
||||
FAILURE
|
||||
)
|
||||
|
||||
fun isFrostSigningKind(kind: Kind): Boolean = kind in ALL
|
||||
|
||||
/**
|
||||
* Tags for a signing message. The session id is on every kind so a message
|
||||
* from an abandoned attempt can be dropped rather than mixed in.
|
||||
*/
|
||||
fun assembleTags(
|
||||
sessionId: String,
|
||||
dkgSessionId: String? = null,
|
||||
signerIds: List<Int>? = null
|
||||
): Array<Array<String>> = buildList {
|
||||
add(FrostSessionIdTag.assemble(sessionId))
|
||||
dkgSessionId?.let { add(FrostKeyTag.assemble(it)) }
|
||||
signerIds?.let { add(FrostSignerIdsTag.assemble(it)) }
|
||||
}.toTypedArray()
|
||||
|
||||
fun parseSessionId(tags: Array<Array<String>>): String? =
|
||||
tags.firstNotNullOfOrNull(FrostSessionIdTag::parse)?.sessionId
|
||||
|
||||
fun parseKey(tags: Array<Array<String>>): String? =
|
||||
tags.firstNotNullOfOrNull(FrostKeyTag::parse)?.dkgSessionId
|
||||
|
||||
fun parseSignerIds(tags: Array<Array<String>>): List<Int>? =
|
||||
tags.firstNotNullOfOrNull(FrostSignerIdsTag::parse)?.signerIds
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package press.mantra.compose.nostr.frost.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* Which key the group is being asked to sign with, named by the ceremony that
|
||||
* created it.
|
||||
*
|
||||
* A group can hold more than one shared key -- a ceremony is re-runnable, and
|
||||
* a member leaving is a reason to run another -- and a signer holds a
|
||||
* different secret share under each. Signing with the share from the wrong
|
||||
* ceremony produces a partial signature that cannot aggregate, so the session
|
||||
* says which one from the start rather than leaving each device to guess at
|
||||
* its most recent.
|
||||
*/
|
||||
class FrostKeyTag(
|
||||
val dkgSessionId: String,
|
||||
) {
|
||||
fun toTagArray() = assemble(dkgSessionId = dkgSessionId)
|
||||
|
||||
companion object {
|
||||
const val TAG_NAME = "frost_key"
|
||||
|
||||
fun parse(tag: Array<String>): FrostKeyTag? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
|
||||
return FrostKeyTag(dkgSessionId = tag[1])
|
||||
}
|
||||
|
||||
fun assemble(dkgSessionId: String): Array<String> = arrayOf(TAG_NAME, dkgSessionId)
|
||||
|
||||
fun assemble(frostKeyTag: FrostKeyTag) = assemble(dkgSessionId = frostKeyTag.dkgSessionId)
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package press.mantra.compose.nostr.frost.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* Binds a signing message to one session.
|
||||
*
|
||||
* A group signs more than once with the same key, and an abandoned attempt at
|
||||
* signing one event must never have its messages fed into a live attempt at
|
||||
* another. That is stricter here than it is for a DKG: every signer's secret
|
||||
* nonce is derived per session, so two sessions sharing an id would be two
|
||||
* different messages signed under one nonce -- which is how a secret share
|
||||
* leaks.
|
||||
*/
|
||||
class FrostSessionIdTag(
|
||||
val sessionId: String,
|
||||
) {
|
||||
fun toTagArray() = assemble(sessionId = sessionId)
|
||||
|
||||
companion object {
|
||||
const val TAG_NAME = "frost_session"
|
||||
|
||||
fun parse(tag: Array<String>): FrostSessionIdTag? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
|
||||
return FrostSessionIdTag(sessionId = tag[1])
|
||||
}
|
||||
|
||||
fun assemble(sessionId: String): Array<String> = arrayOf(TAG_NAME, sessionId)
|
||||
|
||||
fun assemble(frostSessionIdTag: FrostSessionIdTag) =
|
||||
assemble(sessionId = frostSessionIdTag.sessionId)
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package press.mantra.compose.nostr.frost.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The participants the coordinator chose to sign with, as their FROST ids, in
|
||||
* the order their nonces were aggregated.
|
||||
*
|
||||
* A t-of-n key does not need everyone, so somebody has to pick which t, and
|
||||
* only the coordinator sees every nonce. Every signer then has to build the
|
||||
* same session from the same set in the same order -- FROST binds the set into
|
||||
* the challenge, so a device that disagrees about who is signing produces a
|
||||
* partial signature that will not aggregate.
|
||||
*/
|
||||
class FrostSignerIdsTag(
|
||||
val signerIds: List<Int>,
|
||||
) {
|
||||
fun toTagArray() = assemble(signerIds = signerIds)
|
||||
|
||||
companion object {
|
||||
const val TAG_NAME = "frost_signers"
|
||||
|
||||
fun parse(tag: Array<String>): FrostSignerIdsTag? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
|
||||
// Order is meaningful, so a single unparseable entry invalidates the
|
||||
// whole list rather than quietly shortening it.
|
||||
val ids = tag[1].split(",").map { it.trim().toIntOrNull() ?: return null }
|
||||
if (ids.isEmpty()) return null
|
||||
|
||||
return FrostSignerIdsTag(signerIds = ids)
|
||||
}
|
||||
|
||||
fun assemble(signerIds: List<Int>): Array<String> =
|
||||
arrayOf(TAG_NAME, signerIds.joinToString(","))
|
||||
|
||||
fun assemble(frostSignerIdsTag: FrostSignerIdsTag) =
|
||||
assemble(signerIds = frostSignerIdsTag.signerIds)
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package press.mantra.compose.nostr.nip30303
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import press.mantra.compose.nostr.nip30303.tags.PayloadIdTag
|
||||
import press.mantra.compose.nostr.nip30303.tags.PayloadKindTag
|
||||
|
||||
/**
|
||||
* 30312
|
||||
*
|
||||
* An envelope that carries one nip30303 event into a group.
|
||||
*
|
||||
* Every other nip30303 kind describes a thing -- an artifact, a dialect, a
|
||||
* chapter, a translated chunk. A submission describes an act: *this member is
|
||||
* putting this event in front of this group*. The two are separate on purpose,
|
||||
* because they answer different questions and often have different answers.
|
||||
*
|
||||
* The payload travels whole, in [content], keeping its own id, author and
|
||||
* signature. Nothing is rewritten to make it look like the submitter's work.
|
||||
* That buys two things:
|
||||
*
|
||||
* - A group can take in work written by somebody who is not in it. A
|
||||
* translation lifted from a public archive, a chapter transcribed by an
|
||||
* outside contributor, an artifact somebody published years ago -- an admin
|
||||
* submits it and the group applies it, with the original author still named
|
||||
* on the row.
|
||||
* - Authorship stops being a claim the transport makes. Before this, being
|
||||
* the sender of a group message *was* being the author of the event inside
|
||||
* it, so the only events a group could hold were ones its own members had
|
||||
* written under their own keys.
|
||||
*
|
||||
* A submission is not an endorsement and grants nothing: a payload's author is
|
||||
* whoever signed it, and who may submit is the group's business.
|
||||
*/
|
||||
@Immutable
|
||||
class SubmissionEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
|
||||
/**
|
||||
* The submitted event, parsed out of [content].
|
||||
*
|
||||
* Null when the content is not an event at all -- treat that as a
|
||||
* submission that cannot be applied, not as an empty one.
|
||||
*/
|
||||
fun payload(): Event? = Event.fromJsonOrNull(content)
|
||||
|
||||
fun payloadKind() = tags.firstNotNullOfOrNull(PayloadKindTag::parse)?.kind
|
||||
|
||||
fun payloadIdReference() = tags.firstNotNullOfOrNull(PayloadIdTag::parse)?.ref
|
||||
fun payloadId() = payloadIdReference()?.eventId
|
||||
|
||||
/** Who wrote the payload, which is not who sent this submission. */
|
||||
fun payloadAuthor() = payloadIdReference()?.author
|
||||
|
||||
companion object {
|
||||
const val KIND = 30312
|
||||
const val ALT_DESCRIPTION = "Submission"
|
||||
|
||||
fun build(
|
||||
payload: Event,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<SubmissionEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, payload.toJson(), createdAt) {
|
||||
alt(ALT_DESCRIPTION)
|
||||
addUnique(PayloadKindTag.assemble(payload.kind))
|
||||
addUnique(PayloadIdTag.assemble(eventId = payload.id, pubkey = payload.pubKey))
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package press.mantra.compose.nostr.nip30303.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference
|
||||
import com.vitorpamplona.quartz.utils.arrayOfNotNull
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The event a submission carries, named on the submission itself.
|
||||
*
|
||||
* The payload is already in the submission's content, so this tag is not how a
|
||||
* reader gets at it -- it is how they filter for it. The author slot matters
|
||||
* more than usual here: on a submission it names whoever wrote the payload,
|
||||
* who is not necessarily the member who submitted it.
|
||||
*/
|
||||
data class PayloadIdTag(
|
||||
val ref: EventReference,
|
||||
) {
|
||||
constructor(eventId: String, relayHint: NormalizedRelayUrl?, pubkey: String?) : this(
|
||||
EventReference(eventId, pubkey, relayHint)
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val TAG_NAME = "payloadId"
|
||||
|
||||
fun parse(tag: Array<String>): PayloadIdTag? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].length == 64) { return null }
|
||||
|
||||
val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
|
||||
return PayloadIdTag(tag[1], relayHint, tag.getOrNull(3))
|
||||
}
|
||||
|
||||
fun assemble(
|
||||
eventId: HexKey,
|
||||
relay: NormalizedRelayUrl? = null,
|
||||
pubkey: String? = null,
|
||||
) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, pubkey)
|
||||
|
||||
fun assemble(ref: EventReference) = assemble(ref.eventId, ref.relayHint, ref.author)
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package press.mantra.compose.nostr.nip30303.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The kind of the event a submission carries.
|
||||
*
|
||||
* Lets a client decide whether it can apply a submission without parsing the
|
||||
* payload JSON out of the content first.
|
||||
*/
|
||||
class PayloadKindTag(
|
||||
val kind: Int,
|
||||
) {
|
||||
fun toTagArray() = assemble(
|
||||
kind = kind,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val TAG_NAME = "payloadKind"
|
||||
|
||||
fun parse(tag: Array<String>): PayloadKindTag? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
|
||||
val kind = tag[1].toIntOrNull() ?: return null
|
||||
return PayloadKindTag(
|
||||
kind = kind,
|
||||
)
|
||||
}
|
||||
|
||||
fun assemble(
|
||||
kind: Int,
|
||||
): Array<String> = arrayOf(
|
||||
TAG_NAME,
|
||||
kind.toString()
|
||||
)
|
||||
|
||||
fun assemble(payloadKindTag: PayloadKindTag) = assemble(
|
||||
kind = payloadKindTag.kind,
|
||||
)
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package press.mantra.compose.repository
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import press.mantra.compose.database.model.FrostSignerMessage
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
|
||||
/**
|
||||
* Reads, opens and answers FROST signing sessions.
|
||||
*
|
||||
* Advancing one is the inbound path's job, with one exception: a session waiting
|
||||
* on this device's owner does not advance until [approve] is called, because it
|
||||
* will not sign on their behalf until they say so.
|
||||
*/
|
||||
interface FrostSigningRepository {
|
||||
fun observeSessionById(sessionId: String): Flow<FrostSigningSession?>
|
||||
|
||||
/** Every session the room has run, newest first. Unlike a ceremony, signing recurs. */
|
||||
fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<FrostSigningSession>>
|
||||
|
||||
fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>>
|
||||
|
||||
suspend fun getSessionById(sessionId: String): FrostSigningSession?
|
||||
|
||||
/**
|
||||
* The session a room is currently running, or its most recent one if none is.
|
||||
*
|
||||
* For callers that mean "the signing going on here" without holding an id --
|
||||
* a transcript line, mostly. Prefers a live session because that is the one
|
||||
* anybody tapping through wants to act on; a finished one is only what is
|
||||
* left to show when there is nothing live.
|
||||
*/
|
||||
suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession?
|
||||
|
||||
/** Whether this room holds a key it can sign with, so the UI offers nothing that would fail. */
|
||||
suspend fun canSign(chatRoomId: String): Boolean
|
||||
|
||||
/**
|
||||
* Opens a session asking the group to sign an event with these fields. The
|
||||
* author is the group's key, not the proposer's, and is filled in here.
|
||||
*/
|
||||
suspend fun proposeSigning(
|
||||
localChatRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
kind: Kind,
|
||||
tags: Array<Array<String>>,
|
||||
content: String
|
||||
): FrostSigningSession?
|
||||
|
||||
/** Agrees to sign, letting the session publish this device's part and run on. */
|
||||
suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String)
|
||||
|
||||
/** Refuses, and says so, since a t-of-n group can proceed without this member. */
|
||||
suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String)
|
||||
|
||||
/** The finished event, or null while the session is still running. */
|
||||
fun signedEvent(session: FrostSigningSession): Event?
|
||||
|
||||
companion object {
|
||||
val NO_OP_FROST_SIGNING_REPOSITORY: FrostSigningRepository = object : FrostSigningRepository {
|
||||
override fun observeSessionById(sessionId: String): Flow<FrostSigningSession?> = flowOf(null)
|
||||
|
||||
override fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<FrostSigningSession>> =
|
||||
flowOf(emptyList())
|
||||
|
||||
override fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>> =
|
||||
flowOf(emptyList())
|
||||
|
||||
override suspend fun getSessionById(sessionId: String): FrostSigningSession? = null
|
||||
|
||||
override suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession? = null
|
||||
|
||||
override suspend fun canSign(chatRoomId: String): Boolean = false
|
||||
|
||||
override suspend fun proposeSigning(
|
||||
localChatRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
kind: Kind,
|
||||
tags: Array<Array<String>>,
|
||||
content: String
|
||||
): FrostSigningSession? = null
|
||||
|
||||
override suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String) = Unit
|
||||
|
||||
override suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) = Unit
|
||||
|
||||
override fun signedEvent(session: FrostSigningSession): Event? = null
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-11
@@ -9,7 +9,6 @@ import press.mantra.compose.database.model.MantraDialect
|
||||
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
|
||||
import press.mantra.compose.database.model.MantraTranslationChapter
|
||||
import press.mantra.compose.database.model.MantraTranslationChunk
|
||||
import press.mantra.compose.database.model.MarmotInnerEvent
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
|
||||
interface MantraRepository {
|
||||
@@ -65,7 +64,7 @@ interface MantraRepository {
|
||||
dialectId: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MarmotInnerEvent?
|
||||
): MantraTranslationArtifactVersion?
|
||||
|
||||
/**
|
||||
* Add a chapter (markdown [originalText]) to the artifact's latest version.
|
||||
@@ -79,7 +78,7 @@ interface MantraRepository {
|
||||
originalText: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MarmotInnerEvent?
|
||||
): MantraChapter?
|
||||
|
||||
suspend fun getDialects(chatRoomId: String): List<MantraDialect>
|
||||
|
||||
@@ -91,7 +90,7 @@ interface MantraRepository {
|
||||
country: String,
|
||||
language: String,
|
||||
userPublicKey: HexKey,
|
||||
): MarmotInnerEvent?
|
||||
): MantraDialect?
|
||||
|
||||
suspend fun addArtifact(
|
||||
localChatRoom: LocalChatRoom,
|
||||
@@ -102,14 +101,14 @@ interface MantraRepository {
|
||||
userPublicKey: HexKey,
|
||||
visibility: String = DEFAULT_VISIBILITY,
|
||||
license: String = DEFAULT_LICENSE,
|
||||
): MarmotInnerEvent?
|
||||
): MantraArtifact?
|
||||
|
||||
suspend fun addArtifactVersion(
|
||||
localChatRoom: LocalChatRoom,
|
||||
artifactId: HexKey,
|
||||
versionLabel: String,
|
||||
userPublicKey: HexKey,
|
||||
): MarmotInnerEvent?
|
||||
): MantraArtifactVersion?
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_VISIBILITY = "private"
|
||||
@@ -155,7 +154,7 @@ interface MantraRepository {
|
||||
dialectId: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MarmotInnerEvent? = null
|
||||
): MantraTranslationArtifactVersion? = null
|
||||
|
||||
override suspend fun addChapter(
|
||||
artifactId: String,
|
||||
@@ -163,7 +162,7 @@ interface MantraRepository {
|
||||
originalText: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MarmotInnerEvent? = null
|
||||
): MantraChapter? = null
|
||||
|
||||
override suspend fun getDialects(chatRoomId: String): List<MantraDialect> = emptyList()
|
||||
|
||||
@@ -175,7 +174,7 @@ interface MantraRepository {
|
||||
country: String,
|
||||
language: String,
|
||||
userPublicKey: HexKey,
|
||||
): MarmotInnerEvent? = null
|
||||
): MantraDialect? = null
|
||||
|
||||
override suspend fun addArtifact(
|
||||
localChatRoom: LocalChatRoom,
|
||||
@@ -186,14 +185,14 @@ interface MantraRepository {
|
||||
userPublicKey: HexKey,
|
||||
visibility: String,
|
||||
license: String,
|
||||
): MarmotInnerEvent? = null
|
||||
): MantraArtifact? = null
|
||||
|
||||
override suspend fun addArtifactVersion(
|
||||
localChatRoom: LocalChatRoom,
|
||||
artifactId: HexKey,
|
||||
versionLabel: String,
|
||||
userPublicKey: HexKey,
|
||||
): MarmotInnerEvent? = null
|
||||
): MantraArtifactVersion? = null
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
-117
@@ -18,15 +18,14 @@ import androidx.compose.material.icons.filled.Label
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.LocalOffer
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Title
|
||||
import androidx.compose.material.icons.filled.Translate
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.BottomAppBarDefaults
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -36,6 +35,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -45,6 +45,8 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.disabled
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -108,14 +110,21 @@ fun AddArtifactScreen(
|
||||
val nameFieldState = rememberTextFieldState()
|
||||
val urlFieldState = rememberTextFieldState()
|
||||
val versionLabelFieldState = rememberTextFieldState("1.0")
|
||||
val dialectNameFieldState = rememberTextFieldState()
|
||||
val dialectCountryFieldState = rememberTextFieldState()
|
||||
val dialectLanguageFieldState = rememberTextFieldState()
|
||||
|
||||
// null = "New dialect" (show the create fields); otherwise the id of
|
||||
// an existing dialect to reuse.
|
||||
// The id of the group dialect this artifact is written in. Null until
|
||||
// one is picked; dialects are defined from the group detail screen.
|
||||
var selectedDialectId: String? by remember { mutableStateOf(null) }
|
||||
|
||||
// Of the required fields this is the only one the screen cannot ask
|
||||
// for again: a dialect has to already exist, and nothing here can
|
||||
// create one. So an unpicked dialect is a dead end rather than
|
||||
// something to submit and be told about, and the button says so.
|
||||
val canAddArtifact = selectedDialectId != null
|
||||
|
||||
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
|
||||
// other button in the app uses rather than inventing a shade here.
|
||||
val buttonColors = ButtonDefaults.buttonColors()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -132,16 +141,33 @@ fun AddArtifactScreen(
|
||||
actions = {},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
modifier = if (canAddArtifact) {
|
||||
Modifier
|
||||
} else {
|
||||
// Looking unavailable is not being unavailable:
|
||||
// without this a screen reader still announces
|
||||
// a button it is happy to press.
|
||||
Modifier.semantics { disabled() }
|
||||
},
|
||||
containerColor = if (canAddArtifact) {
|
||||
FloatingActionButtonDefaults.containerColor
|
||||
} else {
|
||||
buttonColors.disabledContainerColor
|
||||
},
|
||||
contentColor = if (canAddArtifact) {
|
||||
contentColorFor(FloatingActionButtonDefaults.containerColor)
|
||||
} else {
|
||||
buttonColors.disabledContentColor
|
||||
},
|
||||
onClick = {
|
||||
if (!canAddArtifact) return@ExtendedFloatingActionButton
|
||||
|
||||
addArtifactViewModel.addArtifact(
|
||||
localChatRoom = addArtifactUIState.localChatRoom,
|
||||
nameField = nameFieldState,
|
||||
urlField = urlFieldState,
|
||||
versionLabelField = versionLabelFieldState,
|
||||
existingDialectId = selectedDialectId,
|
||||
dialectNameField = dialectNameFieldState,
|
||||
dialectCountryField = dialectCountryFieldState,
|
||||
dialectLanguageField = dialectLanguageFieldState,
|
||||
dialectId = selectedDialectId,
|
||||
onSuccess = { artifactId ->
|
||||
// Open the newly created artifact, removing this
|
||||
// add screen from the back stack.
|
||||
@@ -266,112 +292,24 @@ fun AddArtifactScreen(
|
||||
|
||||
Text("Source dialect")
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
addArtifactUIState.dialects.forEach { dialect ->
|
||||
FilterChip(
|
||||
selected = selectedDialectId == dialect.id,
|
||||
onClick = { selectedDialectId = dialect.id },
|
||||
label = { Text(dialect.name) }
|
||||
)
|
||||
if (addArtifactUIState.dialects.isEmpty()) {
|
||||
Text(
|
||||
text = "No dialects have been defined in this group yet. Add one from the group's detail screen first.",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
} else {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
addArtifactUIState.dialects.forEach { dialect ->
|
||||
FilterChip(
|
||||
selected = selectedDialectId == dialect.id,
|
||||
onClick = { selectedDialectId = dialect.id },
|
||||
label = { Text(dialect.name) }
|
||||
)
|
||||
}
|
||||
}
|
||||
FilterChip(
|
||||
selected = selectedDialectId == null,
|
||||
onClick = { selectedDialectId = null },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = "Create a new dialect"
|
||||
)
|
||||
},
|
||||
label = { Text("New dialect") }
|
||||
)
|
||||
}
|
||||
|
||||
// Only collect new-dialect details when not reusing an existing one.
|
||||
if (selectedDialectId == null) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.background(BottomAppBarDefaults.containerColor),
|
||||
state = dialectNameFieldState,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
disabledBorderColor = Color.Transparent
|
||||
),
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Title,
|
||||
contentDescription = "Name of the dialect"
|
||||
)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = "Dialect Name"
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "eg. Sesotho"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.background(BottomAppBarDefaults.containerColor),
|
||||
state = dialectCountryFieldState,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
disabledBorderColor = Color.Transparent
|
||||
),
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Public,
|
||||
contentDescription = "Country of the dialect"
|
||||
)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = "Country"
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "eg. Lesotho"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.background(BottomAppBarDefaults.containerColor),
|
||||
state = dialectLanguageFieldState,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
disabledBorderColor = Color.Transparent
|
||||
),
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Translate,
|
||||
contentDescription = "Language of the dialect"
|
||||
)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = "Language"
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "eg. st"
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Add Visibility
|
||||
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
package press.mantra.compose.ui.composable
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.text.input.rememberTextFieldState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Title
|
||||
import androidx.compose.material.icons.filled.Translate
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.BottomAppBarDefaults
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
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.semantics.disabled
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.repository.NostrRepository
|
||||
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
|
||||
import press.mantra.compose.ui.theme.TorchTheme
|
||||
import press.mantra.compose.ui.view.model.AddDialectViewModel
|
||||
import press.mantra.compose.ui.view.state.AddDialectUIState
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddDialectScreen(
|
||||
activeUserPublicKey: HexKey,
|
||||
chatRoomId: String,
|
||||
relayHint: String?,
|
||||
initialAddDialectUIState: AddDialectUIState = AddDialectUIState.Loading,
|
||||
nostrRepository: NostrRepository,
|
||||
chatRepository: ChatRepository,
|
||||
frostSigningRepository: FrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
|
||||
onNavigateToRoute: (Route) -> Unit,
|
||||
) {
|
||||
val addDialectViewModel: AddDialectViewModel = viewModel(
|
||||
factory = AddDialectViewModel.factory(
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint,
|
||||
initialAddDialectUIState = initialAddDialectUIState,
|
||||
nostrRepository = nostrRepository,
|
||||
chatRepository = chatRepository,
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
frostSigningRepository = frostSigningRepository
|
||||
)
|
||||
)
|
||||
|
||||
when (val addDialectUIState = addDialectViewModel.addDialectUIState) {
|
||||
is AddDialectUIState.Error -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier.height(50.dp)
|
||||
)
|
||||
Text(
|
||||
text = addDialectUIState.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
is AddDialectUIState.Loaded -> {
|
||||
val nameFieldState = rememberTextFieldState()
|
||||
val countryFieldState = rememberTextFieldState()
|
||||
val languageFieldState = rememberTextFieldState()
|
||||
|
||||
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
|
||||
// other button in the app uses rather than inventing a shade here.
|
||||
val buttonColors = ButtonDefaults.buttonColors()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
addDialectUIState.localChatRoom.RenderChatRoomTitleText()
|
||||
},
|
||||
actions = {
|
||||
|
||||
}
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
BottomAppBar(
|
||||
actions = {},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
modifier = if (addDialectUIState.canSign) {
|
||||
Modifier
|
||||
} else {
|
||||
// Looking unavailable is not being unavailable.
|
||||
Modifier.semantics { disabled() }
|
||||
},
|
||||
containerColor = if (addDialectUIState.canSign) {
|
||||
FloatingActionButtonDefaults.containerColor
|
||||
} else {
|
||||
buttonColors.disabledContainerColor
|
||||
},
|
||||
contentColor = if (addDialectUIState.canSign) {
|
||||
contentColorFor(FloatingActionButtonDefaults.containerColor)
|
||||
} else {
|
||||
buttonColors.disabledContentColor
|
||||
},
|
||||
onClick = {
|
||||
if (!addDialectUIState.canSign) return@ExtendedFloatingActionButton
|
||||
|
||||
addDialectViewModel.addDialect(
|
||||
localChatRoom = addDialectUIState.localChatRoom,
|
||||
nameField = nameFieldState,
|
||||
countryField = countryFieldState,
|
||||
languageField = languageFieldState,
|
||||
onSuccess = { sessionId ->
|
||||
// Onto the session rather than back to
|
||||
// the group. Nothing has been created
|
||||
// yet -- the dialect appears when enough
|
||||
// members sign -- so landing on the list
|
||||
// it is not in would read as a failure.
|
||||
onNavigateToRouteAndPopUpInclusive.invoke(
|
||||
FrostSigningRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
sessionId = sessionId
|
||||
)
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
onNavigateToRoute.invoke(
|
||||
ImplementationPendingRoute("Failed Dialect")
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = "Propose dialect"
|
||||
)
|
||||
Text("Propose Dialect")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Add a dialect the group can translate into")
|
||||
|
||||
if (!addDialectUIState.canSign) {
|
||||
Text(
|
||||
text = "This group has no shared key, so it cannot sign a " +
|
||||
"dialect into existence. Run a shared key ceremony first.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.background(BottomAppBarDefaults.containerColor),
|
||||
state = nameFieldState,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
disabledBorderColor = Color.Transparent
|
||||
),
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Title,
|
||||
contentDescription = "Name of the dialect"
|
||||
)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = "Dialect Name"
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "eg. Sesotho"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.background(BottomAppBarDefaults.containerColor),
|
||||
state = countryFieldState,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
disabledBorderColor = Color.Transparent
|
||||
),
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Public,
|
||||
contentDescription = "Country of the dialect"
|
||||
)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = "Country"
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "eg. Lesotho"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.background(BottomAppBarDefaults.containerColor),
|
||||
state = languageFieldState,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
disabledBorderColor = Color.Transparent
|
||||
),
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Translate,
|
||||
contentDescription = "Language of the dialect"
|
||||
)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = "Language"
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "eg. st"
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AddDialectUIState.Loading -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(
|
||||
20.dp
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "Add Dialect",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
LoadingDataIndicator(
|
||||
fillScreen = false
|
||||
)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier.weight(2f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(true) {
|
||||
if (initialAddDialectUIState == AddDialectUIState.Loading) {
|
||||
addDialectViewModel.initiateAddDialect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun AddDialectScreenPreview() {
|
||||
TorchTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
AddDialectScreen(
|
||||
activeUserPublicKey = "",
|
||||
chatRoomId = "publicKey",
|
||||
relayHint = null,
|
||||
initialAddDialectUIState = AddDialectUIState.Loaded(
|
||||
localChatRoom = LocalChatRoom(
|
||||
chatRoom = ChatRoom(
|
||||
id = "",
|
||||
userPublicKey = "",
|
||||
subject = "Message title",
|
||||
description = "See something. Say somethin",
|
||||
initialGiftWrapPayloadId = "sdfaer",
|
||||
mlsGroupState = null
|
||||
),
|
||||
)
|
||||
),
|
||||
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
|
||||
onNavigateToRouteAndPopUpInclusive = {},
|
||||
onNavigateToRoute = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.DeleteForever
|
||||
import androidx.compose.material.icons.filled.LibraryBooks
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material.icons.filled.Schema
|
||||
import androidx.compose.material.icons.filled.Translate
|
||||
import androidx.compose.material.icons.filled.Unsubscribe
|
||||
import androidx.compose.material.icons.filled.WaterfallChart
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@@ -60,6 +61,7 @@ import press.mantra.compose.ui.view.model.ChatRoomDetailViewModel
|
||||
import press.mantra.compose.ui.view.state.ChatRoomDetailUIState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
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.ArtifactDetailRoute
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@@ -217,6 +219,72 @@ fun ChatRoomDetailScreen(
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
item {
|
||||
// Dialects
|
||||
Text(
|
||||
text = "Dialects",
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
|
||||
if (chatRoomDetailUIState.dialects.isEmpty()) {
|
||||
item {
|
||||
Text("No dialects have been defined in this group.")
|
||||
}
|
||||
} else {
|
||||
items(
|
||||
items = chatRoomDetailUIState.dialects,
|
||||
key = { dialect -> dialect.id }
|
||||
) { dialect ->
|
||||
Card {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Default.Translate,
|
||||
contentDescription = "Dialect"
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(text = dialect.name)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(text = "${dialect.language} \u00b7 ${dialect.country}")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onNavigateToRoute.invoke(
|
||||
AddDialectRoute(
|
||||
chatRoomId = chatRoomId,
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
relayHint = relayHint
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
|
||||
Icon(
|
||||
Icons.Default.Translate,
|
||||
contentDescription = "Add new dialect"
|
||||
)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier.width(10.dp)
|
||||
)
|
||||
|
||||
Text("Add Dialect")
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
// TODO: Add projects...
|
||||
|
||||
item {
|
||||
|
||||
+11
@@ -43,6 +43,7 @@ import press.mantra.compose.repository.ChatRepository
|
||||
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.Route
|
||||
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
|
||||
import press.mantra.compose.ui.theme.TorchTheme
|
||||
@@ -141,6 +142,16 @@ fun ChatRoomMessagingScreen(
|
||||
chatRoomId = chatRoomId
|
||||
)
|
||||
)
|
||||
},
|
||||
onOpenSigning = {
|
||||
// No session id: a chat row carries none, and the
|
||||
// screen resolves the room's live one.
|
||||
onNavigateToRoute.invoke(
|
||||
FrostSigningRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+373
@@ -0,0 +1,373 @@
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Draw
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material.icons.filled.HourglassEmpty
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
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.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.FrostSigningStage
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar
|
||||
import press.mantra.compose.ui.theme.TorchTheme
|
||||
import press.mantra.compose.ui.view.model.FrostSigningViewModel
|
||||
import press.mantra.compose.ui.view.state.FrostSigningUIState
|
||||
|
||||
/**
|
||||
* One signing session, and the member's decision about it.
|
||||
*
|
||||
* A ceremony gets three approval screens because it asks three different
|
||||
* questions. Signing asks one — sign this or do not — so there is one screen,
|
||||
* and it has to carry the whole case for the answer: what is being signed, who
|
||||
* else has agreed, and what the group is still waiting on.
|
||||
*
|
||||
* It stays useful after the decision. A session cannot finish until enough
|
||||
* members take part, so a member who has already signed still needs to see
|
||||
* whose door to knock on, and the ladder is the only place that says.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FrostSigningScreen(
|
||||
activeUserPublicKey: HexKey,
|
||||
chatRoomId: String,
|
||||
sessionId: String?,
|
||||
initialFrostSigningUIState: FrostSigningUIState = FrostSigningUIState.Loading,
|
||||
chatRepository: ChatRepository,
|
||||
frostSigningRepository: FrostSigningRepository,
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
val frostSigningViewModel: FrostSigningViewModel = viewModel(
|
||||
factory = FrostSigningViewModel.factory(
|
||||
chatRoomId = chatRoomId,
|
||||
sessionId = sessionId,
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
initialFrostSigningUIState = initialFrostSigningUIState,
|
||||
chatRepository = chatRepository,
|
||||
frostSigningRepository = frostSigningRepository
|
||||
)
|
||||
)
|
||||
|
||||
// Nothing loads the room or starts watching the session until this runs.
|
||||
LaunchedEffect(true) {
|
||||
if (initialFrostSigningUIState == FrostSigningUIState.Loading) {
|
||||
frostSigningViewModel.initiate()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Sign with the group's key",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
when (val state = frostSigningViewModel.frostSigningUIState) {
|
||||
is FrostSigningUIState.Loading -> Loading(padding)
|
||||
|
||||
is FrostSigningUIState.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 FrostSigningUIState.Loaded -> {
|
||||
// Loaded means the room is loaded, not the session: initiate() sets
|
||||
// this state and only then starts collecting, so the first emission
|
||||
// always has a null session. Reading that as "no such session" would
|
||||
// flash an error on the way in.
|
||||
val session = state.session ?: return@Scaffold Loading(padding)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(15.dp)
|
||||
) {
|
||||
WhatIsBeingSigned(frostSigningViewModel.proposedEvent(session))
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Text(
|
||||
text = statusOf(session),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
session.failureReason?.let { reason ->
|
||||
Text(
|
||||
text = reason,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Text(
|
||||
text = "Members",
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
|
||||
// Named rather than counted, for the same reason the ceremony's
|
||||
// ladder names people: "1 of 2" does not tell anyone whose door
|
||||
// to knock on, and a session stalls until somebody knocks.
|
||||
state.localChatRoom.localParticipants
|
||||
.distinctBy { it.participant.participantPublicKey }
|
||||
.forEach { localParticipant ->
|
||||
val member = localParticipant.participant.participantPublicKey
|
||||
|
||||
Card {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
ProfileAvatar(
|
||||
publicKey = member,
|
||||
profile = localParticipant.profile
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
when {
|
||||
member in state.signed -> Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = "Signed",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
member in state.offeredNonce -> Icon(
|
||||
Icons.Default.HourglassEmpty,
|
||||
contentDescription = "Ready to sign"
|
||||
)
|
||||
|
||||
else -> Icon(
|
||||
Icons.Default.RadioButtonUnchecked,
|
||||
contentDescription = "Not yet",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = localParticipant.profile
|
||||
?.humanReadableNameOrPubkey()
|
||||
?: member
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = when {
|
||||
member in state.signed -> "Signed their part"
|
||||
member in state.offeredNonce -> "Ready to sign"
|
||||
else -> "Has not taken part yet"
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (session.signApprovedAt == null &&
|
||||
session.stage != FrostSigningStage.COMPLETE &&
|
||||
session.stage != FrostSigningStage.FAILED
|
||||
) {
|
||||
HorizontalDivider()
|
||||
|
||||
Text(
|
||||
text = "Nothing has been published from this device yet. Signing " +
|
||||
"puts your share behind this event; it cannot be taken back.",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
enabled = !frostSigningViewModel.isActionPending.value,
|
||||
onClick = { frostSigningViewModel.approve(onNavigateBack) }
|
||||
) {
|
||||
Icon(Icons.Default.Draw, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text("Sign")
|
||||
}
|
||||
|
||||
TextButton(
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
),
|
||||
enabled = !frostSigningViewModel.isActionPending.value,
|
||||
onClick = { frostSigningViewModel.decline(onNavigateBack) }
|
||||
) {
|
||||
Text("Don't sign")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Loading(padding: androidx.compose.foundation.layout.PaddingValues) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(50.dp))
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the group is being asked to put its name to.
|
||||
*
|
||||
* Shown as the thing rather than as an event: a member deciding whether to sign
|
||||
* is deciding about a dialect or an artifact, and "kind 30304" answers a
|
||||
* question nobody asked. The raw kind stays for anything not recognised, since
|
||||
* refusing to describe an event is better than describing it wrongly.
|
||||
*/
|
||||
@Composable
|
||||
private fun WhatIsBeingSigned(event: Event?) {
|
||||
if (event == null) {
|
||||
Text(
|
||||
text = "This session's event could not be read, so there is nothing to check " +
|
||||
"before signing. Don't sign it.",
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val (label, detail) = when (event.kind) {
|
||||
DialectEvent.KIND -> "New dialect" to DialectEvent(
|
||||
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
|
||||
).let { dialect ->
|
||||
listOfNotNull(dialect.name(), dialect.country(), dialect.language())
|
||||
.joinToString(" · ")
|
||||
}
|
||||
|
||||
ArtifactEvent.KIND -> "New artifact" to event.content
|
||||
|
||||
ChapterEvent.KIND -> "New chapter" to ChapterEvent(
|
||||
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
|
||||
).name().orEmpty()
|
||||
|
||||
else -> "Event of kind ${event.kind}" to event.content
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text(text = label, style = MaterialTheme.typography.labelMedium)
|
||||
|
||||
Text(text = detail, style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
Text(
|
||||
text = "Signed by the group, not by you. Once enough members sign, this is " +
|
||||
"published under the group's shared key.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun statusOf(session: FrostSigningSession): String = when (session.stage) {
|
||||
FrostSigningStage.COLLECTING_NONCES ->
|
||||
"Waiting for ${session.threshold} of ${session.participantCount} members to take part."
|
||||
|
||||
FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES ->
|
||||
if (session.isSigner()) {
|
||||
"You are one of the signers. Waiting on the rest of them."
|
||||
} else {
|
||||
"Enough members took part without needing you. Waiting on them to sign."
|
||||
}
|
||||
|
||||
FrostSigningStage.COMPLETE -> "Signed."
|
||||
|
||||
FrostSigningStage.FAILED -> "Abandoned. Nothing was signed, and it is safe to ask again."
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun FrostSigningScreenPreview() {
|
||||
TorchTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
FrostSigningScreen(
|
||||
activeUserPublicKey = "",
|
||||
chatRoomId = "chatRoomId",
|
||||
sessionId = "sessionId",
|
||||
initialFrostSigningUIState = FrostSigningUIState.Loaded(
|
||||
localChatRoom = LocalChatRoom(
|
||||
chatRoom = ChatRoom(
|
||||
id = "chatRoomId",
|
||||
userPublicKey = "",
|
||||
subject = "Group (#admins)",
|
||||
description = null,
|
||||
initialGiftWrapPayloadId = "sdfaer",
|
||||
mlsGroupState = null
|
||||
)
|
||||
)
|
||||
),
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
|
||||
onNavigateBack = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -16,6 +16,7 @@ import androidx.navigation.toRoute
|
||||
import press.mantra.compose.MantraGlobal
|
||||
import press.mantra.compose.database.repository.DatabaseChatRepository
|
||||
import press.mantra.compose.database.repository.DatabaseDkgRepository
|
||||
import press.mantra.compose.database.repository.DatabaseFrostSigningRepository
|
||||
import press.mantra.compose.database.repository.DatabaseMarmotRepository
|
||||
import press.mantra.compose.database.repository.DatabaseNostrRepository
|
||||
import press.mantra.compose.database.repository.DatabaseSearchRepository
|
||||
@@ -103,6 +104,8 @@ import kotlinx.coroutines.launch
|
||||
import press.mantra.compose.database.repository.DatabaseMantraRepository
|
||||
import press.mantra.compose.ui.composable.AddArtifactScreen
|
||||
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.FrostSigningRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
|
||||
@@ -112,6 +115,8 @@ import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRo
|
||||
import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute
|
||||
import press.mantra.compose.ui.composable.ArtifactDetailScreen
|
||||
import press.mantra.compose.ui.composable.AddChapterScreen
|
||||
import press.mantra.compose.ui.composable.AddDialectScreen
|
||||
import press.mantra.compose.ui.composable.FrostSigningScreen
|
||||
import press.mantra.compose.ui.composable.AddTranslationArtifactVersionScreen
|
||||
import press.mantra.compose.ui.composable.ChapterDetailScreen
|
||||
import press.mantra.compose.ui.composable.TranslateChunkScreen
|
||||
@@ -162,6 +167,13 @@ fun MantraNavHost(
|
||||
)
|
||||
}
|
||||
|
||||
val databaseFrostSigningRepository = remember {
|
||||
DatabaseFrostSigningRepository(
|
||||
database = auxDatabaseManager.auxDatabase,
|
||||
applicationIOScope
|
||||
)
|
||||
}
|
||||
|
||||
val databaseMarmotRepository = remember {
|
||||
DatabaseMarmotRepository(
|
||||
database = auxDatabaseManager.auxDatabase,
|
||||
@@ -814,6 +826,46 @@ fun MantraNavHost(
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<AddDialectRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<AddDialectRoute>()
|
||||
|
||||
AddDialectScreen(
|
||||
activeUserPublicKey = route.activeUserPublicKey,
|
||||
chatRoomId = route.chatRoomId,
|
||||
relayHint = route.relayHint,
|
||||
nostrRepository = databaseNostrRepository,
|
||||
chatRepository = databaseChatRepository,
|
||||
frostSigningRepository = databaseFrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive = { signingRoute ->
|
||||
// Replace this add screen so back returns to the group rather
|
||||
// than to a form whose proposal has already gone out.
|
||||
navController.navigate(route = signingRoute) {
|
||||
popUpTo<AddDialectRoute> {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
},
|
||||
onNavigateToRoute = { actionRoute ->
|
||||
navController.navigate(
|
||||
route = actionRoute
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<FrostSigningRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<FrostSigningRoute>()
|
||||
|
||||
FrostSigningScreen(
|
||||
activeUserPublicKey = route.activeUserPublicKey,
|
||||
chatRoomId = route.chatRoomId,
|
||||
sessionId = route.sessionId,
|
||||
chatRepository = databaseChatRepository,
|
||||
frostSigningRepository = databaseFrostSigningRepository,
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<ArtifactDetailRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<ArtifactDetailRoute>()
|
||||
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package press.mantra.compose.ui.composable.navigation.routes
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AddDialectRoute(
|
||||
val activeUserPublicKey: String,
|
||||
val chatRoomId: String, // TODO: have this as a publicKey
|
||||
val relayHint: String?
|
||||
): Route()
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package press.mantra.compose.ui.composable.navigation.routes
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* One signing session.
|
||||
*
|
||||
* Carries a session id rather than only a room, because a group signs
|
||||
* repeatedly and can have more than one session open at a time -- unlike a
|
||||
* ceremony, where "the room's ritual" identifies it.
|
||||
*/
|
||||
@Serializable
|
||||
data class FrostSigningRoute(
|
||||
val activeUserPublicKey: String,
|
||||
val chatRoomId: String,
|
||||
|
||||
/**
|
||||
* Null when the caller does not know which session it means.
|
||||
*
|
||||
* A transcript line is the main case: chat rows carry no session, and adding
|
||||
* a column for one feature to a table every message uses is a poor trade for
|
||||
* a lookup the screen can do. It resolves to the room's live session, which
|
||||
* is the one a line is talking about in every case but a group running two
|
||||
* at once.
|
||||
*/
|
||||
val sessionId: String? = null
|
||||
): Route()
|
||||
+7
-33
@@ -62,10 +62,7 @@ class AddArtifactViewModel(
|
||||
nameField: TextFieldState,
|
||||
urlField: TextFieldState,
|
||||
versionLabelField: TextFieldState,
|
||||
existingDialectId: HexKey?,
|
||||
dialectNameField: TextFieldState,
|
||||
dialectCountryField: TextFieldState,
|
||||
dialectLanguageField: TextFieldState,
|
||||
dialectId: HexKey?,
|
||||
visibility: String = MantraRepository.DEFAULT_VISIBILITY,
|
||||
license: String = MantraRepository.DEFAULT_LICENSE,
|
||||
onSuccess: (artifactId: String) -> Unit,
|
||||
@@ -75,16 +72,10 @@ class AddArtifactViewModel(
|
||||
val name = nameField.text.toString()
|
||||
val url = urlField.text.toString()
|
||||
val versionLabel = versionLabelField.text.toString()
|
||||
val dialectName = dialectNameField.text.toString()
|
||||
val dialectCountry = dialectCountryField.text.toString()
|
||||
val dialectLanguage = dialectLanguageField.text.toString()
|
||||
|
||||
// When no existing dialect is selected, the new-dialect fields are required.
|
||||
val creatingNewDialect = existingDialectId.isNullOrBlank()
|
||||
val newDialectIncomplete = dialectName.isBlank() || dialectCountry.isBlank() || dialectLanguage.isBlank()
|
||||
if (name.isBlank() || url.isBlank() || versionLabel.isBlank() ||
|
||||
(creatingNewDialect && newDialectIncomplete)
|
||||
) {
|
||||
// addArtifact requires an existing source dialect; they are defined
|
||||
// from the group detail screen, not here.
|
||||
if (name.isBlank() || url.isBlank() || versionLabel.isBlank() || dialectId.isNullOrBlank()) {
|
||||
onFailure.invoke()
|
||||
return
|
||||
}
|
||||
@@ -94,21 +85,7 @@ class AddArtifactViewModel(
|
||||
isActionPending.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val artifactInnerEvent = runCatching {
|
||||
// Reuse the selected dialect, or create a new source dialect and
|
||||
// reference it by id. addArtifact requires a valid dialectId.
|
||||
val dialectId = if (creatingNewDialect) {
|
||||
mantraRepository.addDialect(
|
||||
localChatRoom = localChatRoom,
|
||||
name = dialectName,
|
||||
country = dialectCountry,
|
||||
language = dialectLanguage,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
)?.id ?: return@runCatching null
|
||||
} else {
|
||||
existingDialectId
|
||||
}
|
||||
|
||||
val artifact = runCatching {
|
||||
mantraRepository.addArtifact(
|
||||
localChatRoom = localChatRoom,
|
||||
name = name,
|
||||
@@ -123,16 +100,13 @@ class AddArtifactViewModel(
|
||||
logger.e("Failed to add artifact", error)
|
||||
}.getOrNull()
|
||||
|
||||
if (artifactInnerEvent != null) {
|
||||
if (artifact != null) {
|
||||
nameField.clearText()
|
||||
urlField.clearText()
|
||||
versionLabelField.clearText()
|
||||
dialectNameField.clearText()
|
||||
dialectCountryField.clearText()
|
||||
dialectLanguageField.clearText()
|
||||
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
onSuccess.invoke(artifactInnerEvent.id)
|
||||
onSuccess.invoke(artifact.id)
|
||||
}
|
||||
} else {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package press.mantra.compose.ui.view.model
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
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
|
||||
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.launch
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.repository.NostrRepository
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
import press.mantra.compose.ui.view.state.AddDialectUIState
|
||||
|
||||
class AddDialectViewModel(
|
||||
val chatRoomId: String,
|
||||
val activeUserPublicKey: HexKey,
|
||||
val relayHint: String?,
|
||||
initialAddDialectUIState: AddDialectUIState,
|
||||
val nostrRepository: NostrRepository,
|
||||
val chatRepository: ChatRepository,
|
||||
val frostSigningRepository: FrostSigningRepository,
|
||||
): ViewModel() {
|
||||
|
||||
var addDialectUIState: AddDialectUIState by mutableStateOf(initialAddDialectUIState)
|
||||
private set
|
||||
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
|
||||
|
||||
/**
|
||||
* Whether this group holds a key it can sign with.
|
||||
*
|
||||
* Read before the form is offered: a group without one cannot make a dialect
|
||||
* this way at all, and a button that always fails is worse than no button.
|
||||
*/
|
||||
suspend fun canSign(): Boolean = frostSigningRepository.canSign(chatRoomId)
|
||||
|
||||
fun initiateAddDialect() {
|
||||
logger.d("compressed (most likely chat room): $chatRoomId")
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
|
||||
|
||||
addDialectUIState = if (localChatRoom == null) {
|
||||
AddDialectUIState.Error("Couldn't find the chat room")
|
||||
} else {
|
||||
AddDialectUIState.Loaded(
|
||||
localChatRoom = localChatRoom,
|
||||
canSign = frostSigningRepository.canSign(chatRoomId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the group to sign a new dialect into existence.
|
||||
*
|
||||
* The dialect is not created here and does not exist yet. What goes out is a
|
||||
* proposal to sign it, and the dialect appears -- on every member's device at
|
||||
* once, authored by the group's shared key rather than by whoever typed it --
|
||||
* when enough members have signed.
|
||||
*
|
||||
* That is the difference from submitting one. A submission says "I am putting
|
||||
* this in front of the group" and the group's only recourse afterwards is
|
||||
* social. A signature is the group saying it, and it takes a quorum to say.
|
||||
*/
|
||||
fun addDialect(
|
||||
localChatRoom: LocalChatRoom,
|
||||
nameField: TextFieldState,
|
||||
countryField: TextFieldState,
|
||||
languageField: TextFieldState,
|
||||
onSuccess: (sessionId: String) -> Unit,
|
||||
onFailure: () -> Unit
|
||||
) {
|
||||
val name = nameField.text.toString()
|
||||
val country = countryField.text.toString()
|
||||
val language = languageField.text.toString()
|
||||
|
||||
if (name.isBlank() || country.isBlank() || language.isBlank()) {
|
||||
onFailure.invoke()
|
||||
return
|
||||
}
|
||||
|
||||
// Guard against double submits from repeated FAB taps.
|
||||
if (isActionPending.value) return
|
||||
isActionPending.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val dialectEventTemplate = DialectEvent.build(
|
||||
name = name,
|
||||
country = country,
|
||||
language = language,
|
||||
)
|
||||
|
||||
val session = runCatching {
|
||||
frostSigningRepository.proposeSigning(
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
kind = dialectEventTemplate.kind,
|
||||
tags = dialectEventTemplate.tags,
|
||||
content = dialectEventTemplate.content,
|
||||
)
|
||||
}.onFailure { error ->
|
||||
logger.e("Failed to propose a dialect for signing", error)
|
||||
}.getOrNull()
|
||||
|
||||
if (session != null) {
|
||||
nameField.clearText()
|
||||
countryField.clearText()
|
||||
languageField.clearText()
|
||||
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
onSuccess.invoke(session.id)
|
||||
}
|
||||
} else {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
onFailure.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
isActionPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AddDialectViewModel"
|
||||
|
||||
fun factory(
|
||||
activeUserPublicKey: HexKey,
|
||||
chatRoomId: String,
|
||||
relayHint: String?,
|
||||
initialAddDialectUIState: AddDialectUIState = AddDialectUIState.Loading,
|
||||
nostrRepository: NostrRepository,
|
||||
chatRepository: ChatRepository,
|
||||
frostSigningRepository: FrostSigningRepository
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
AddDialectViewModel(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint,
|
||||
initialAddDialectUIState = initialAddDialectUIState,
|
||||
nostrRepository = nostrRepository,
|
||||
chatRepository = chatRepository,
|
||||
frostSigningRepository = frostSigningRepository
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
-6
@@ -24,6 +24,8 @@ 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.Draw
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.PanTool
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material.icons.filled.Upload
|
||||
@@ -215,7 +217,7 @@ class ChatMessageListViewModel(
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun RenderMessages(onOpenSharedKey: () -> Unit) {
|
||||
fun RenderMessages(onOpenSharedKey: () -> Unit, onOpenSigning: () -> Unit) {
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -270,8 +272,10 @@ class ChatMessageListViewModel(
|
||||
val answeredRequests = chatRoomDetailMessageListUIState
|
||||
.chatMessageList
|
||||
.mapNotNull { request ->
|
||||
val published = ChatMessage
|
||||
.DKG_REQUEST_FULFILMENTS[request.chatMessage.messageType]
|
||||
val published = (
|
||||
ChatMessage.DKG_REQUEST_FULFILMENTS +
|
||||
ChatMessage.FROST_REQUEST_FULFILMENTS
|
||||
)[request.chatMessage.messageType]
|
||||
?: return@mapNotNull null
|
||||
|
||||
val done = chatRoomDetailMessageListUIState.chatMessageList.any {
|
||||
@@ -347,6 +351,20 @@ class ChatMessageListViewModel(
|
||||
return@items
|
||||
}
|
||||
|
||||
// Signing lines are the same kind of thing and get
|
||||
// the same treatment -- nobody said them either --
|
||||
// but they lead somewhere else, because what a
|
||||
// reader needs from one is the event being signed
|
||||
// rather than the state of the key.
|
||||
if (localChatMessage.chatMessage.messageType in ChatMessage.FROST_TYPES) {
|
||||
RitualNotice(
|
||||
localChatMessage = localChatMessage,
|
||||
isAnswered = localChatMessage.chatMessage.id in answeredRequests,
|
||||
onClick = onOpenSigning
|
||||
)
|
||||
return@items
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
@@ -537,6 +555,15 @@ private fun RitualNotice(
|
||||
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1 -> Icons.Default.Upload
|
||||
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_2 -> Icons.Default.FactCheck
|
||||
|
||||
ChatMessage.TYPE_FROST_STARTED -> Icons.Default.Draw
|
||||
ChatMessage.TYPE_FROST_NONCE -> Icons.Default.Upload
|
||||
ChatMessage.TYPE_FROST_SIGNER_SET -> Icons.Default.Groups
|
||||
ChatMessage.TYPE_FROST_PARTIAL_SIGNATURE -> Icons.Default.Draw
|
||||
ChatMessage.TYPE_FROST_SIGNATURE -> Icons.Default.WorkspacePremium
|
||||
ChatMessage.TYPE_FROST_COMPLETE -> Icons.Default.CheckCircle
|
||||
ChatMessage.TYPE_FROST_FAILED -> Icons.Default.ErrorOutline
|
||||
ChatMessage.TYPE_FROST_APPROVAL_NEEDED -> Icons.Default.Draw
|
||||
|
||||
else -> Icons.Default.PanTool
|
||||
}
|
||||
|
||||
@@ -545,10 +572,15 @@ private fun RitualNotice(
|
||||
// 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 isRequest = (
|
||||
chatMessage.messageType in ChatMessage.DKG_REQUEST_TYPES ||
|
||||
chatMessage.messageType in ChatMessage.FROST_REQUEST_TYPES
|
||||
) && !isAnswered
|
||||
|
||||
val tint = when {
|
||||
chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED -> MaterialTheme.colorScheme.error
|
||||
chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED ||
|
||||
chatMessage.messageType == ChatMessage.TYPE_FROST_FAILED ->
|
||||
MaterialTheme.colorScheme.error
|
||||
isRequest -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
@@ -578,7 +610,9 @@ private fun RitualNotice(
|
||||
// from the joined profile rather than written into the content,
|
||||
// so it follows a rename and is not stuck on the "LOADING..."
|
||||
// placeholder a member is given the moment they are first seen.
|
||||
if (chatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES) {
|
||||
if (chatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES ||
|
||||
chatMessage.messageType in ChatMessage.FROST_AUTHORED_TYPES
|
||||
) {
|
||||
withStyle(
|
||||
SpanStyle(color = ProfileColor.fromPublicKey(chatMessage.senderPublicKey))
|
||||
) {
|
||||
|
||||
+1
@@ -55,6 +55,7 @@ class ChatRoomDetailViewModel(
|
||||
ChatRoomDetailUIState.Loaded(
|
||||
localChatRoom = localChatRoom,
|
||||
artifacts = mantraRepository.getArtifacts(chatRoomId),
|
||||
dialects = mantraRepository.getDialects(chatRoomId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
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
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.ui.view.state.FrostSigningUIState
|
||||
|
||||
class FrostSigningViewModel(
|
||||
val chatRoomId: String,
|
||||
val sessionId: String?,
|
||||
val activeUserPublicKey: HexKey,
|
||||
initialFrostSigningUIState: FrostSigningUIState,
|
||||
val chatRepository: ChatRepository,
|
||||
val frostSigningRepository: FrostSigningRepository,
|
||||
): ViewModel() {
|
||||
|
||||
var frostSigningUIState: FrostSigningUIState by mutableStateOf(initialFrostSigningUIState)
|
||||
private set
|
||||
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
|
||||
|
||||
/**
|
||||
* Loads the room, then watches the session for as long as the screen lives.
|
||||
*
|
||||
* A session moves on messages arriving from other members, so a screen that
|
||||
* read it once would sit still while the rest of the group signed around it.
|
||||
*/
|
||||
fun initiate() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
|
||||
if (localChatRoom == null) {
|
||||
frostSigningUIState = FrostSigningUIState.Error("Couldn't find the group")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// A transcript line knows its room but not its session, so resolve one
|
||||
// before watching anything.
|
||||
val id = sessionId
|
||||
?: frostSigningRepository.liveSessionForChatRoom(chatRoomId)?.id
|
||||
if (id == null) {
|
||||
frostSigningUIState =
|
||||
FrostSigningUIState.Error("This group is not signing anything right now.")
|
||||
return@launch
|
||||
}
|
||||
|
||||
frostSigningUIState = FrostSigningUIState.Loaded(localChatRoom = localChatRoom)
|
||||
|
||||
combine(
|
||||
frostSigningRepository.observeSessionById(id),
|
||||
frostSigningRepository.observeMessages(id)
|
||||
) { session, messages -> session to messages }
|
||||
.collect { (session, messages) ->
|
||||
frostSigningUIState = FrostSigningUIState.Loaded(
|
||||
localChatRoom = localChatRoom,
|
||||
session = session,
|
||||
offeredNonce = messages
|
||||
.filter { it.kind == FrostSigningEvents.NONCE }
|
||||
.map { it.signerPublicKey }
|
||||
.toSet(),
|
||||
signed = messages
|
||||
.filter { it.kind == FrostSigningEvents.PARTIAL_SIGNATURE }
|
||||
.map { it.signerPublicKey }
|
||||
.toSet()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The event the group is being asked to sign, for showing it before they agree. */
|
||||
fun proposedEvent(session: FrostSigningSession): Event? =
|
||||
Event.fromJsonOrNull(session.unsignedEventJson)
|
||||
|
||||
fun signedEvent(session: FrostSigningSession): Event? =
|
||||
frostSigningRepository.signedEvent(session)
|
||||
|
||||
fun approve(onDone: () -> Unit) {
|
||||
val state = frostSigningUIState as? FrostSigningUIState.Loaded ?: return
|
||||
val sessionId = state.session?.id ?: return
|
||||
|
||||
if (isActionPending.value) return
|
||||
isActionPending.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
frostSigningRepository.approve(
|
||||
localChatRoom = state.localChatRoom,
|
||||
sessionId = sessionId
|
||||
)
|
||||
|
||||
isActionPending.value = false
|
||||
|
||||
viewModelScope.launch(Dispatchers.Main) { onDone.invoke() }
|
||||
}
|
||||
}
|
||||
|
||||
fun decline(onDone: () -> Unit) {
|
||||
val state = frostSigningUIState as? FrostSigningUIState.Loaded ?: return
|
||||
val sessionId = state.session?.id ?: return
|
||||
|
||||
if (isActionPending.value) return
|
||||
isActionPending.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
frostSigningRepository.decline(
|
||||
localChatRoom = state.localChatRoom,
|
||||
sessionId = sessionId
|
||||
)
|
||||
|
||||
isActionPending.value = false
|
||||
|
||||
viewModelScope.launch(Dispatchers.Main) { onDone.invoke() }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FrostSigningViewModel"
|
||||
|
||||
fun factory(
|
||||
chatRoomId: String,
|
||||
sessionId: String?,
|
||||
activeUserPublicKey: HexKey,
|
||||
initialFrostSigningUIState: FrostSigningUIState = FrostSigningUIState.Loading,
|
||||
chatRepository: ChatRepository,
|
||||
frostSigningRepository: FrostSigningRepository,
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
FrostSigningViewModel(
|
||||
chatRoomId = chatRoomId,
|
||||
sessionId = sessionId,
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
initialFrostSigningUIState = initialFrostSigningUIState,
|
||||
chatRepository = chatRepository,
|
||||
frostSigningRepository = frostSigningRepository
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package press.mantra.compose.ui.view.state
|
||||
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
|
||||
sealed interface AddDialectUIState {
|
||||
data class Loaded(
|
||||
val localChatRoom: LocalChatRoom,
|
||||
|
||||
/**
|
||||
* Whether the group holds a shared key. A dialect is signed into
|
||||
* existence now rather than submitted, so a group without one cannot
|
||||
* make one here at all.
|
||||
*/
|
||||
val canSign: Boolean = false,
|
||||
): AddDialectUIState
|
||||
|
||||
data class Error(
|
||||
val message: String
|
||||
): AddDialectUIState
|
||||
|
||||
data object Loading: AddDialectUIState
|
||||
}
|
||||
+2
@@ -1,12 +1,14 @@
|
||||
package press.mantra.compose.ui.view.state
|
||||
|
||||
import press.mantra.compose.database.model.MantraArtifact
|
||||
import press.mantra.compose.database.model.MantraDialect
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
|
||||
sealed interface ChatRoomDetailUIState {
|
||||
data class Loaded(
|
||||
val localChatRoom: LocalChatRoom,
|
||||
val artifacts: List<MantraArtifact> = emptyList(),
|
||||
val dialects: List<MantraDialect> = emptyList(),
|
||||
): ChatRoomDetailUIState
|
||||
|
||||
data class Error(
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package press.mantra.compose.ui.view.state
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
|
||||
sealed interface FrostSigningUIState {
|
||||
data class Loaded(
|
||||
val localChatRoom: LocalChatRoom,
|
||||
|
||||
/** Null on the first emission, before the session has been collected. */
|
||||
val session: FrostSigningSession? = null,
|
||||
|
||||
/** Who has offered a nonce, so the screen can name who it is waiting on. */
|
||||
val offeredNonce: Set<HexKey> = emptySet(),
|
||||
|
||||
/** Who has signed their part. */
|
||||
val signed: Set<HexKey> = emptySet(),
|
||||
): FrostSigningUIState
|
||||
|
||||
data class Error(
|
||||
val message: String
|
||||
): FrostSigningUIState
|
||||
|
||||
data object Loading: FrostSigningUIState
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
package press.mantra.compose.database.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChunkEvent
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
import press.mantra.compose.nostr.nip30303.SubmissionEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
|
||||
|
||||
/**
|
||||
* The row on disk and the payload on the wire have to be the same event.
|
||||
*
|
||||
* `MantraDao` writes an entity whose id comes from `MantraX.fromXEventTemplate`,
|
||||
* and separately builds the rumor it submits with `rumorOf`, which hashes the
|
||||
* template itself. Both are supposed to produce one id. Nothing checks that they
|
||||
* do, and nothing would notice if they stopped:
|
||||
*
|
||||
* - the submission would carry a `payloadId` naming an event nobody has,
|
||||
* - `MarmotInnerEvent.payloadEventId` would stop matching the row it carries,
|
||||
* so `deleteByPayloadEventId` would silently un-queue nothing and superseded
|
||||
* translations would go out anyway,
|
||||
* - and every receiver would create a *second* row rather than converging on
|
||||
* the sender's, because entity ids are content hashes and the two sides would
|
||||
* be hashing different things.
|
||||
*
|
||||
* All of that is silent. The ids are opaque hex either way.
|
||||
*/
|
||||
class RumorIdAgreementTest {
|
||||
private val author = "a".repeat(64)
|
||||
private val chatRoomId = "room"
|
||||
private val other = "b".repeat(64)
|
||||
|
||||
/** Exactly what `MantraDao.rumorOf` does, and it must stay exactly that. */
|
||||
private fun rumorIdOf(template: EventTemplate<*>): String = EventHasher.hashId(
|
||||
pubKey = author,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a dialect's row and its rumor agree`() {
|
||||
val template = DialectEvent.build(name = "Sesotho", country = "Lesotho", language = "st")
|
||||
|
||||
val entity = MantraDialect.fromDialectEventTemplate(
|
||||
dialectEventTemplate = template,
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = author
|
||||
)
|
||||
|
||||
assertEquals(rumorIdOf(template), entity?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact's row and its rumor agree`() {
|
||||
val template = ArtifactEvent.build(
|
||||
name = "In Detention",
|
||||
url = "example.com",
|
||||
visibility = "private",
|
||||
license = "cc",
|
||||
dialectId = other
|
||||
)
|
||||
|
||||
val entity = MantraArtifact.fromArtifactEventTemplate(
|
||||
artifactEventTemplate = template,
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = author
|
||||
)
|
||||
|
||||
assertEquals(rumorIdOf(template), entity?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact version's row and its rumor agree`() {
|
||||
val template = ArtifactVersionEvent.build(content = "1.0") {
|
||||
addUnique(ArtifactIdTag.assemble(other))
|
||||
}
|
||||
|
||||
val entity = MantraArtifactVersion.fromArtifactVersionEventTemplate(
|
||||
artifactVersionEventTemplate = template,
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = author
|
||||
)
|
||||
|
||||
assertEquals(rumorIdOf(template), entity?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a chapter's and a chunk's rows agree with their rumors`() {
|
||||
val chapter = ChapterEvent.build(
|
||||
artifactVersionId = other,
|
||||
name = "Chapter 1",
|
||||
originalText = "some text",
|
||||
index = 0,
|
||||
wordCount = 2,
|
||||
characterCount = 9
|
||||
)
|
||||
val chunk = ChunkEvent.build(
|
||||
chapterId = other,
|
||||
text = "some text",
|
||||
index = 0,
|
||||
wordCount = 2,
|
||||
characterCount = 9
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
rumorIdOf(chapter),
|
||||
MantraChapter.fromChapterEventTemplate(chapter, chatRoomId, author)?.id
|
||||
)
|
||||
assertEquals(
|
||||
rumorIdOf(chunk),
|
||||
MantraChunk.fromChunkEventTemplate(chunk, chatRoomId, author)?.id
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a translation version's row and its rumor agree`() {
|
||||
val template = TranslationArtifactVersionEvent.build(
|
||||
artifactVersionId = other,
|
||||
dialectId = other,
|
||||
name = "Sesotho",
|
||||
visibility = "private",
|
||||
license = "cc"
|
||||
)
|
||||
|
||||
val entity = MantraTranslationArtifactVersion.fromTranslationArtifactVersionEventTemplate(
|
||||
translationArtifactVersionEventTemplate = template,
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = author
|
||||
)
|
||||
|
||||
assertEquals(rumorIdOf(template), entity?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the submission names the id the row was written under`() {
|
||||
// The end of the chain the rest of this file checks a link of: what a
|
||||
// receiver reads out of the envelope has to be the id the sender stored.
|
||||
val template = DialectEvent.build(name = "Sesotho", country = "Lesotho", language = "st")
|
||||
val entity = MantraDialect.fromDialectEventTemplate(template, chatRoomId, author)
|
||||
|
||||
val payload = Event(
|
||||
id = rumorIdOf(template),
|
||||
pubKey = author,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = ""
|
||||
)
|
||||
val submission = SubmissionEvent.build(payload = payload)
|
||||
|
||||
val readBack = SubmissionEvent(
|
||||
id = "f".repeat(64),
|
||||
pubKey = author,
|
||||
createdAt = submission.createdAt,
|
||||
tags = submission.tags,
|
||||
content = submission.content,
|
||||
sig = ""
|
||||
)
|
||||
|
||||
assertEquals(entity?.id, readBack.payloadId())
|
||||
assertEquals(entity?.id, readBack.payload()?.id)
|
||||
assertEquals(DialectEvent.KIND, readBack.payloadKind())
|
||||
}
|
||||
}
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import fr.acinq.bitcoin.ByteVector
|
||||
import fr.acinq.bitcoin.ByteVector32
|
||||
import fr.acinq.bitcoin.PrivateKey
|
||||
import fr.acinq.bitcoin.crypto.frost.Frost
|
||||
import fr.acinq.bitcoin.crypto.frost.IndividualNonce
|
||||
import fr.acinq.bitcoin.crypto.frost.KeyMaterial
|
||||
import fr.acinq.bitcoin.crypto.frost.SecretNonce
|
||||
import fr.acinq.bitcoin.crypto.frost.Session
|
||||
import fr.acinq.bitcoin.crypto.frost.TweakCache
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
|
||||
/**
|
||||
* The two rounds a signing session runs, against real FROST.
|
||||
*
|
||||
* `FrostSigningManager` spreads these steps across arriving messages, several
|
||||
* devices and a database, none of which a unit test can stand up. What it can
|
||||
* do is run the same calls in the same order with the same arguments and check
|
||||
* that what comes out is a signature nostr will accept — which is the part
|
||||
* that was written from reading the library rather than from a working example,
|
||||
* and so the part most likely to be subtly wrong.
|
||||
*
|
||||
* A signature that verifies is the whole contract: if these calls are wired up
|
||||
* incorrectly the aggregate simply fails to verify, silently, on every device.
|
||||
*/
|
||||
class FrostSigningRoundTest {
|
||||
private val participants = 3
|
||||
private val threshold = 2
|
||||
|
||||
/** Stands in for a completed ceremony. A trusted dealer is fine here: the test is about signing. */
|
||||
private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
|
||||
thresholdSecretKey = PrivateKey(
|
||||
ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
|
||||
),
|
||||
nParticipants = participants,
|
||||
threshold = threshold
|
||||
)
|
||||
|
||||
private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey)
|
||||
|
||||
/** The group's nostr identity: the x-only key a BIP-340 signature verifies against. */
|
||||
private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex()
|
||||
|
||||
/** The 32 bytes actually signed — a nostr event id, exactly as the manager computes it. */
|
||||
private fun eventId(content: String): String = EventHasher.hashId(
|
||||
pubKey = groupPubKey,
|
||||
createdAt = 1_700_000_000L,
|
||||
kind = 1,
|
||||
tags = arrayOf(),
|
||||
content = content
|
||||
)
|
||||
|
||||
/**
|
||||
* One signer's half of the protocol, in the manager's order: regenerate the
|
||||
* nonce from stored randomness, then sign once the set is known.
|
||||
*/
|
||||
private fun nonceOf(signerId: Int, message: ByteVector, random: String): Pair<SecretNonce, IndividualNonce> =
|
||||
SecretNonce.generate(
|
||||
sessionRandom = ByteVector32(random),
|
||||
secretShare = keyMaterial.secretShares[signerId],
|
||||
publicShare = keyMaterial.publicShares[signerId],
|
||||
tweakedThresholdPublicKey = tweakCache.tweakedPublicKey,
|
||||
message = message,
|
||||
extraInput = null
|
||||
)
|
||||
|
||||
private fun sessionFor(signerIds: List<Int>, nonces: List<IndividualNonce>, message: ByteVector): Session {
|
||||
val aggregated = IndividualNonce.aggregate(nonces).right!!
|
||||
|
||||
return Session.create(
|
||||
aggregatedNonce = aggregated,
|
||||
signerIds = signerIds.map { it.toUInt() },
|
||||
signerPublicShares = signerIds.map { keyMaterial.publicShares[it] },
|
||||
nParticipants = participants,
|
||||
threshold = threshold,
|
||||
tweakCache = tweakCache,
|
||||
message = message
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a threshold of signers produces a signature nostr accepts`() {
|
||||
val id = eventId("the group agrees")
|
||||
val message = ByteVector(id.hexToByteArray())
|
||||
|
||||
// Two of the three sign, which is the point of a 2-of-3 key.
|
||||
val signerIds = listOf(0, 1)
|
||||
val nonces = signerIds.map { nonceOf(it, message, "a".repeat(63) + "${it + 1}") }
|
||||
|
||||
val session = sessionFor(signerIds, nonces.map { it.second }, message)
|
||||
|
||||
val partials = signerIds.mapIndexed { position, signerId ->
|
||||
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
|
||||
}
|
||||
|
||||
val signature = session.aggregateSigs(partials).right!!
|
||||
|
||||
assertTrue(
|
||||
Nip01Crypto.verify(
|
||||
signature = signature.toByteArray(),
|
||||
hash = id.hexToByteArray(),
|
||||
pubKey = groupPubKey.hexToByteArray()
|
||||
),
|
||||
"the aggregated signature must verify against the group's x-only key"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a different pair of signers signs the same event just as well`() {
|
||||
val id = eventId("the group agrees")
|
||||
val message = ByteVector(id.hexToByteArray())
|
||||
|
||||
// Whoever happens to be available. The coordinator picks; the signature
|
||||
// that comes out must not depend on which t it picked.
|
||||
val signerIds = listOf(1, 2)
|
||||
val nonces = signerIds.map { nonceOf(it, message, "b".repeat(63) + "${it + 1}") }
|
||||
|
||||
val session = sessionFor(signerIds, nonces.map { it.second }, message)
|
||||
val partials = signerIds.mapIndexed { position, signerId ->
|
||||
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
|
||||
}
|
||||
|
||||
val signature = session.aggregateSigs(partials).right!!
|
||||
|
||||
assertTrue(
|
||||
Nip01Crypto.verify(
|
||||
signature = signature.toByteArray(),
|
||||
hash = id.hexToByteArray(),
|
||||
pubKey = groupPubKey.hexToByteArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a signature over one event does not verify against another`() {
|
||||
val id = eventId("the group agrees")
|
||||
val message = ByteVector(id.hexToByteArray())
|
||||
|
||||
val signerIds = listOf(0, 1)
|
||||
val nonces = signerIds.map { nonceOf(it, message, "c".repeat(63) + "${it + 1}") }
|
||||
val session = sessionFor(signerIds, nonces.map { it.second }, message)
|
||||
val partials = signerIds.mapIndexed { position, signerId ->
|
||||
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
|
||||
}
|
||||
val signature = session.aggregateSigs(partials).right!!
|
||||
|
||||
assertFalse(
|
||||
Nip01Crypto.verify(
|
||||
signature = signature.toByteArray(),
|
||||
hash = eventId("the group agrees to something else").hexToByteArray(),
|
||||
pubKey = groupPubKey.hexToByteArray()
|
||||
),
|
||||
"a signature is over one event id and must not carry to another"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `regenerating a nonce from the same seed and message gives the same nonce`() {
|
||||
val id = eventId("the group agrees")
|
||||
val message = ByteVector(id.hexToByteArray())
|
||||
val random = "d".repeat(63) + "1"
|
||||
|
||||
// What makes a signing session restart-safe: SecretNonce cannot be stored,
|
||||
// so the manager keeps its seed and derives again. If that were not
|
||||
// reproducible a device that restarted mid-session would publish a partial
|
||||
// signature against a nonce nobody aggregated.
|
||||
val first = nonceOf(0, message, random).second
|
||||
val second = nonceOf(0, message, random).second
|
||||
|
||||
assertEquals(first.data.toHex(), second.data.toHex())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same seed under a different message gives a different nonce`() {
|
||||
val random = "e".repeat(63) + "1"
|
||||
|
||||
// The safety property behind reusing the seed at all: one session signs one
|
||||
// message. Were the nonce independent of the message, a session that could
|
||||
// be re-pointed at another event would sign twice under one nonce, which
|
||||
// hands over the secret share.
|
||||
val first = nonceOf(0, ByteVector(eventId("one thing").hexToByteArray()), random).second
|
||||
val second = nonceOf(0, ByteVector(eventId("another thing").hexToByteArray()), random).second
|
||||
|
||||
assertFalse(first.data.toHex() == second.data.toHex())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pure bits of a signing session's bookkeeping: who is signing, and with
|
||||
* which key.
|
||||
*/
|
||||
class FrostSigningSessionTest {
|
||||
private fun session(signerId: Int, signerIds: String?) = FrostSigningSession(
|
||||
id = "s".repeat(64),
|
||||
chatRoomId = "room",
|
||||
coordinatorPublicKey = "c".repeat(64),
|
||||
userPublicKey = "u".repeat(64),
|
||||
dkgSessionId = "k".repeat(64),
|
||||
threshold = 2,
|
||||
participantCount = 3,
|
||||
signerId = signerId,
|
||||
unsignedEventJson = "{}",
|
||||
eventId = "e".repeat(64),
|
||||
nonceRandom = "f".repeat(64),
|
||||
signerIds = signerIds
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a member left out of the signer set is not a signer`() {
|
||||
assertTrue(session(signerId = 1, signerIds = "0,1").isSigner())
|
||||
assertFalse(session(signerId = 2, signerIds = "0,1").isSigner())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nobody is a signer until the coordinator has chosen`() {
|
||||
assertFalse(session(signerId = 0, signerIds = null).isSigner())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the signer set keeps the order it was aggregated in`() {
|
||||
// FROST binds the set into the challenge, so this list is not a set of ids
|
||||
// but a sequence positionally matched to the aggregated nonce.
|
||||
assertEquals(listOf(2, 0, 1), session(signerId = 0, signerIds = "2,0,1").signerIdList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a signer set tag survives the trip through a tag array`() {
|
||||
val tags = FrostSigningEvents.assembleTags(
|
||||
sessionId = "session",
|
||||
dkgSessionId = "ceremony",
|
||||
signerIds = listOf(2, 0, 1)
|
||||
)
|
||||
|
||||
assertEquals("session", FrostSigningEvents.parseSessionId(tags))
|
||||
assertEquals("ceremony", FrostSigningEvents.parseKey(tags))
|
||||
assertEquals(listOf(2, 0, 1), FrostSigningEvents.parseSignerIds(tags))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ceremony that recorded no public shares reads back null rather than empty`() {
|
||||
// Ceremonies completed before the column existed. Signing falls back to not
|
||||
// cross-checking shares, which the FROST API allows, rather than refusing.
|
||||
val ceremony = DkgSession(
|
||||
id = "k".repeat(64),
|
||||
chatRoomId = "room",
|
||||
coordinatorPublicKey = "c".repeat(64),
|
||||
userPublicKey = "u".repeat(64),
|
||||
threshold = 2,
|
||||
participantCount = 3,
|
||||
hostPublicKey = "h".repeat(66),
|
||||
round1Random = "1".repeat(64),
|
||||
round2AuxRandom = "2".repeat(64)
|
||||
)
|
||||
|
||||
assertEquals(null, ceremony.publicShareList())
|
||||
assertEquals(
|
||||
2,
|
||||
ceremony.copy(
|
||||
publicShares = listOf(
|
||||
Hex.encode(ByteArray(33) { 2 }),
|
||||
Hex.encode(ByteArray(33) { 3 })
|
||||
).joinToString(",")
|
||||
).publicShareList()?.size
|
||||
)
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertSame
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
/**
|
||||
* The decision behind keeping an MLS group alive between messages.
|
||||
*
|
||||
* This cache exists because quartz drops a secret tree's skipped-generation keys
|
||||
* on save, so rebuilding a group between two messages loses any message that
|
||||
* arrives late -- permanently, and silently. See `docs/mls-skipped-keys.md`.
|
||||
*
|
||||
* Every one of these failures is invisible at runtime. Reuse too eagerly and a
|
||||
* group carries on from a ratchet another writer has already moved, which
|
||||
* corrupts decryption rather than failing it. Reuse too rarely and the cache
|
||||
* does nothing at all, and the bug it was written for comes straight back with
|
||||
* no symptom to notice. So the rule is asserted rather than reasoned about.
|
||||
*/
|
||||
class LiveInstanceCacheTest {
|
||||
/** Stands in for an MlsGroup: mutable, and its persisted form is its content. */
|
||||
private class Group(var state: String) {
|
||||
/** How many times this particular instance was handed to a caller. */
|
||||
var uses: Int = 0
|
||||
}
|
||||
|
||||
private fun cache() = LiveInstanceCache<Group> { it.state }
|
||||
|
||||
@Test
|
||||
fun `reuses the instance while nothing else has written`() = runBlocking {
|
||||
val cache = cache()
|
||||
var stored: String? = "start"
|
||||
var built = 0
|
||||
|
||||
val first = cache.withInstance(
|
||||
key = "room",
|
||||
storedState = stored,
|
||||
build = { built++; Group("start") },
|
||||
save = { stored = it },
|
||||
block = { it.uses++; it }
|
||||
)
|
||||
|
||||
val second = cache.withInstance(
|
||||
key = "room",
|
||||
storedState = stored,
|
||||
build = { built++; Group("start") },
|
||||
save = { stored = it },
|
||||
block = { it.uses++; it }
|
||||
)
|
||||
|
||||
// The same object, not merely an equal one: what has to survive is the
|
||||
// in-memory skipped-key map, which no amount of rebuilding recovers.
|
||||
assertSame(first, second)
|
||||
assertEquals(1, built)
|
||||
assertEquals(2, second?.uses)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rebuilds when something else wrote the stored state`() = runBlocking {
|
||||
val cache = cache()
|
||||
var stored: String? = "start"
|
||||
var built = 0
|
||||
|
||||
val first = cache.withInstance(
|
||||
key = "room",
|
||||
storedState = stored,
|
||||
build = { built++; Group("start") },
|
||||
save = { stored = it },
|
||||
block = { it }
|
||||
)
|
||||
|
||||
// Sending a message advances the sender ratchet and saves; adding a
|
||||
// member does too. Carrying on from an instance that has been overtaken
|
||||
// would diverge the ratchet, which is worse than not caching at all.
|
||||
stored = "written by someone else"
|
||||
|
||||
val second = cache.withInstance(
|
||||
key = "room",
|
||||
storedState = stored,
|
||||
build = { built++; Group("written by someone else") },
|
||||
save = { stored = it },
|
||||
block = { it }
|
||||
)
|
||||
|
||||
assertEquals(2, built)
|
||||
assertEquals(false, first === second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `persists whatever the block left behind`() = runBlocking {
|
||||
val cache = cache()
|
||||
var stored: String? = "start"
|
||||
|
||||
cache.withInstance(
|
||||
key = "room",
|
||||
storedState = stored,
|
||||
build = { Group("start") },
|
||||
save = { stored = it },
|
||||
block = { it.state = "advanced" }
|
||||
)
|
||||
|
||||
assertEquals("advanced", stored)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the state it records is the one it compares against next time`() = runBlocking {
|
||||
val cache = cache()
|
||||
var stored: String? = "start"
|
||||
var built = 0
|
||||
|
||||
repeat(3) {
|
||||
cache.withInstance(
|
||||
key = "room",
|
||||
storedState = stored,
|
||||
build = { built++; Group("start") },
|
||||
save = { stored = it },
|
||||
// Every use moves the instance on, as decrypting a message does.
|
||||
block = { group -> group.state = "advanced ${group.uses++}" }
|
||||
)
|
||||
}
|
||||
|
||||
// Recording the pre-block state instead would make every call look like
|
||||
// somebody else had written, quietly turning the cache off.
|
||||
assertEquals(1, built)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not run the block, or cache anything, when there is nothing to build`() = runBlocking {
|
||||
val cache = cache()
|
||||
var ran = false
|
||||
|
||||
val result = cache.withInstance<Unit>(
|
||||
key = "room",
|
||||
storedState = null,
|
||||
build = { null },
|
||||
save = { },
|
||||
block = { ran = true }
|
||||
)
|
||||
|
||||
assertNull(result)
|
||||
assertEquals(false, ran)
|
||||
assertEquals(0, cache.size())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an instance whose use threw is not handed to the next caller`() = runBlocking {
|
||||
val cache = cache()
|
||||
var stored: String? = "start"
|
||||
var built = 0
|
||||
|
||||
assertFailsWith<IllegalStateException> {
|
||||
cache.withInstance<Unit>(
|
||||
key = "room",
|
||||
storedState = stored,
|
||||
build = { built++; Group("start") },
|
||||
save = { stored = it },
|
||||
block = { error("decryption blew up half way") }
|
||||
)
|
||||
}
|
||||
|
||||
cache.withInstance(
|
||||
key = "room",
|
||||
storedState = stored,
|
||||
build = { built++; Group("start") },
|
||||
save = { stored = it },
|
||||
block = { it }
|
||||
)
|
||||
|
||||
// Half-advanced and never persisted: the next caller has to start from
|
||||
// what is actually on disk, not from whatever the failure left in memory.
|
||||
assertEquals(2, built)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rooms are cached independently`() = runBlocking {
|
||||
val cache = cache()
|
||||
var storedA: String? = "a"
|
||||
var storedB: String? = "b"
|
||||
|
||||
val a = cache.withInstance(
|
||||
key = "roomA",
|
||||
storedState = storedA,
|
||||
build = { Group("a") },
|
||||
save = { storedA = it },
|
||||
block = { it }
|
||||
)
|
||||
val b = cache.withInstance(
|
||||
key = "roomB",
|
||||
storedState = storedB,
|
||||
build = { Group("b") },
|
||||
save = { storedB = it },
|
||||
block = { it }
|
||||
)
|
||||
|
||||
assertEquals(2, cache.size())
|
||||
assertEquals(false, a === b)
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package press.mantra.compose.nostr.nip30303
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* What a submission has to survive: the trip through a group.
|
||||
*
|
||||
* The envelope is only worth having if the event inside it comes out the other
|
||||
* side unchanged -- same id, same author, same signature. The moment any of
|
||||
* those is rewritten in transit, a group can no longer hold work by anyone but
|
||||
* its own members, which is the whole reason submissions exist.
|
||||
*/
|
||||
class SubmissionEventTest {
|
||||
private val submitter = "a".repeat(64)
|
||||
private val outsider = "b".repeat(64)
|
||||
|
||||
/** A dialect written by somebody who is not in the group. */
|
||||
private fun outsiderDialect(): Event {
|
||||
val template = DialectEvent.build(
|
||||
name = "Sesotho",
|
||||
country = "Lesotho",
|
||||
language = "st",
|
||||
createdAt = 1_700_000_000L,
|
||||
)
|
||||
return Event(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = outsider,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
),
|
||||
pubKey = outsider,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = "c".repeat(128),
|
||||
)
|
||||
}
|
||||
|
||||
/** Send a submission template and read it back the way inbound does. */
|
||||
private fun roundTrip(payload: Event): SubmissionEvent {
|
||||
val template = SubmissionEvent.build(payload = payload, createdAt = 1_700_000_100L)
|
||||
val onTheWire = Event.fromJson(
|
||||
Event(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = submitter,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
),
|
||||
pubKey = submitter,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = "",
|
||||
).toJson()
|
||||
)
|
||||
|
||||
return SubmissionEvent(
|
||||
id = onTheWire.id,
|
||||
pubKey = onTheWire.pubKey,
|
||||
createdAt = onTheWire.createdAt,
|
||||
tags = onTheWire.tags,
|
||||
content = onTheWire.content,
|
||||
sig = onTheWire.sig,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the payload comes back as the event that went in`() {
|
||||
val dialect = outsiderDialect()
|
||||
|
||||
val payload = roundTrip(dialect).payload()
|
||||
|
||||
assertEquals(dialect.id, payload?.id)
|
||||
assertEquals(dialect.pubKey, payload?.pubKey)
|
||||
assertEquals(dialect.kind, payload?.kind)
|
||||
assertEquals(dialect.content, payload?.content)
|
||||
assertEquals(dialect.sig, payload?.sig)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `submitting does not make the submitter the author`() {
|
||||
val dialect = outsiderDialect()
|
||||
|
||||
val submission = roundTrip(dialect)
|
||||
|
||||
assertEquals(submitter, submission.pubKey)
|
||||
assertEquals(outsider, submission.payload()?.pubKey)
|
||||
assertNotEquals(submission.pubKey, submission.payload()?.pubKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the envelope names what it carries without being opened`() {
|
||||
val dialect = outsiderDialect()
|
||||
|
||||
val submission = roundTrip(dialect)
|
||||
|
||||
assertEquals(DialectEvent.KIND, submission.payloadKind())
|
||||
assertEquals(dialect.id, submission.payloadId())
|
||||
assertEquals(outsider, submission.payloadAuthor())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a payload the group cannot read is null rather than empty`() {
|
||||
val submission = SubmissionEvent(
|
||||
id = "d".repeat(64),
|
||||
pubKey = submitter,
|
||||
createdAt = 1_700_000_100L,
|
||||
tags = arrayOf(),
|
||||
content = "not an event",
|
||||
sig = "",
|
||||
)
|
||||
|
||||
assertNull(submission.payload())
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,8 @@ silent, or a decision that looked arbitrary and was not.
|
||||
| [shared-key-ceremony.md](./shared-key-ceremony.md) | ChillDKG over NIP-17: the rounds, the approval gates, the chat transcript, participant ordering |
|
||||
| [shared-key-derivation.md](./shared-key-derivation.md) | deriving further keys from the group's threshold key with FROST tweaks — why not BIP32, why no chain code, and the one rule that must not be broken |
|
||||
| [marmot-membership.md](./marmot-membership.md) | how members join an MLS group, and the epoch race that makes a missing member look like a successful invite |
|
||||
| [mls-skipped-keys.md](./mls-skipped-keys.md) | why a group event that arrives a moment late is dropped for good, which flows trigger it, the quartz fix, and the partial mitigation in this app |
|
||||
|
||||
Start with the ceremony if you are new to this area; the other two both assume it.
|
||||
Read the skipped-keys note before debugging any "the other device never got it"
|
||||
report — it is silent, and it looks like every other kind of delivery failure.
|
||||
@@ -0,0 +1,194 @@
|
||||
# Messages are lost when two arrive out of order
|
||||
|
||||
A group event that a relay hands back a moment late is dropped and cannot be
|
||||
recovered. Two messages published in the same second reliably lose one of them.
|
||||
|
||||
This is a conformance gap in quartz's MLS implementation, not in this app. What
|
||||
this app can do about it from outside the library is partial, and is described
|
||||
at the end.
|
||||
|
||||
## Symptom
|
||||
|
||||
The receiver stores the kind:445 group event and produces nothing from it. No
|
||||
inner event, no chat line, no error the user sees. `MarmotGroupEvent` is written
|
||||
*before* the message is decrypted, so the row survives while everything
|
||||
downstream of it silently does not:
|
||||
|
||||
```
|
||||
receiver, room 6d8ec3ad ("Frosty (#admins)")
|
||||
|
||||
20:36:55 kind 9 chat message decrypted, applied
|
||||
20:37:34 kind 30321 nonce decrypted, applied
|
||||
20:37:34 kind 30320 proposal group event stored, no inner event
|
||||
```
|
||||
|
||||
Both 20:37:34 events were published by the same sender in the same
|
||||
`proposeSigning` call. Every message that arrived on its own decrypted fine; the
|
||||
back-to-back pair lost exactly one.
|
||||
|
||||
Downstream the failure reads as something else entirely. In the case above a
|
||||
FROST signing session never started on the receiver, because the proposal that
|
||||
opens one never arrived — leaving a nonce filed against a session that will
|
||||
never exist. An earlier instance of the same bug dropped a dialect, and the
|
||||
artifact referencing it then failed a foreign key and rolled back its whole
|
||||
transaction.
|
||||
|
||||
## Cause
|
||||
|
||||
MLS is specified to tolerate out-of-order delivery inside an epoch. RFC 9420
|
||||
§9.1: a receiver that gets generation `N+1` before `N` derives the intermediate
|
||||
keys and keeps them, so the older message can still be read when it turns up.
|
||||
|
||||
Quartz does implement this. `SecretTree` caches them:
|
||||
|
||||
```kotlin
|
||||
// SecretTree.kt
|
||||
private val skippedKeys = mutableMapOf<Pair<Int, Int>, KeyNonceGeneration>()
|
||||
|
||||
fun applicationKeyNonceForGeneration(leafIndex: Int, generation: Int): KeyNonceGeneration {
|
||||
val cachedKey = skippedKeys.remove(Pair(leafIndex, generation))
|
||||
if (cachedKey != null) { /* ...replay check... */ return cachedKey }
|
||||
|
||||
val state = getOrInitSender(leafIndex)
|
||||
require(generation >= state.applicationGeneration) {
|
||||
"Generation $generation already consumed (current: ${state.applicationGeneration})"
|
||||
}
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The gap is that the cache is never persisted:
|
||||
|
||||
```kotlin
|
||||
// SecretTree.kt
|
||||
fun exportSenderStates(): Map<Int, SenderRatchetState> = senderState.toMap()
|
||||
|
||||
fun importSenderStates(states: Map<Int, SenderRatchetState>) {
|
||||
senderState.putAll(states)
|
||||
}
|
||||
```
|
||||
|
||||
`exportSenderStates()` returns the ratchet *positions* only. `MlsGroup.saveState()`
|
||||
calls it (`senderRatchetStates = secretTree.exportSenderStates()`) and
|
||||
`MlsGroup.restore()` calls `importSenderStates`. So `skippedKeys` exists only in
|
||||
one `SecretTree` instance's memory.
|
||||
|
||||
That would be harmless if the group instance outlived the messages. It does not:
|
||||
`NostrDao` rebuilds it from stored state for every inbound event and saves it
|
||||
back afterwards. So the sequence is
|
||||
|
||||
1. generation 1 arrives, ratchet advances 0 → 2, generation 0's key goes into
|
||||
`skippedKeys`
|
||||
2. `saveState()` — `skippedKeys` is dropped on the floor
|
||||
3. generation 0 arrives, a fresh tree is restored with
|
||||
`applicationGeneration = 2`, the cache is empty, `require` fails
|
||||
4. the exception is swallowed, the event yields no `ApplicationMessage`
|
||||
|
||||
Step 3 is terminal. The key is derived from a ratchet that has moved past it and
|
||||
cannot be recovered, and nothing asks the sender to resend.
|
||||
|
||||
Verified against the published artifact rather than a checkout:
|
||||
`quartz-1.14.0-sources.jar`, `commonMain/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt`.
|
||||
|
||||
## Why it is not an edge case here
|
||||
|
||||
Nostr relays make no ordering guarantee at all, and negentropy reconciliation
|
||||
hands back a room's backlog in whatever order it likes. Any two messages close
|
||||
enough together can swap.
|
||||
|
||||
Several flows publish in bursts, and each of them is a reliable trigger:
|
||||
|
||||
| flow | messages in one pass |
|
||||
|---|---|
|
||||
| `FrostSigningManager.proposeSigning` | proposal, then the proposer's nonce |
|
||||
| `ChillDkgRitualManager.proposeRitual` | proposal, then the host key |
|
||||
| `MantraDao.addArtifact` | the artifact, then its first version |
|
||||
| `MantraDao.addChapter` | the chapter, then one per paragraph chunk |
|
||||
|
||||
`addChapter` is the worst of these: a chapter with twenty paragraphs publishes
|
||||
twenty-one events at once, and only the ones that happen to arrive in ascending
|
||||
generation order survive.
|
||||
|
||||
## The fix, in quartz
|
||||
|
||||
Carry the skipped keys through `saveState`/`restore` alongside the ratchet
|
||||
positions.
|
||||
|
||||
**1. Export and import them.** In `SecretTree`:
|
||||
|
||||
```kotlin
|
||||
fun exportSkippedKeys(): Map<Pair<Int, Int>, KeyNonceGeneration> = skippedKeys.toMap()
|
||||
|
||||
fun importSkippedKeys(keys: Map<Pair<Int, Int>, KeyNonceGeneration>) {
|
||||
skippedKeys.putAll(keys)
|
||||
}
|
||||
```
|
||||
|
||||
`MAX_SKIPPED_KEYS` already bounds the map, so the serialised size is bounded by
|
||||
the same constant and needs no separate cap.
|
||||
|
||||
**2. Put them in the group state.** `MlsGroup.saveState()` already writes
|
||||
`senderRatchetStates = secretTree.exportSenderStates()`; add a sibling field, and
|
||||
have `restore()` call `importSkippedKeys` next to its existing
|
||||
`importSenderStates`.
|
||||
|
||||
**3. Keep old state readable.** The persisted state is a TLS-encoded struct that
|
||||
existing installs already hold, so the new field has to be optional: absent means
|
||||
an empty map, which is exactly the behaviour today. Without that, every device
|
||||
with a stored group is broken by the upgrade.
|
||||
|
||||
**4. Consumed-generation replay protection.** `consumedGenerations` guards
|
||||
against a replayed message re-using a cached key. It is in-memory too, so it
|
||||
should travel with the skipped keys or the guard weakens across restarts. Worth
|
||||
deciding deliberately rather than by omission.
|
||||
|
||||
A test worth having with it: save and restore a group between the two messages
|
||||
of an out-of-order pair, and assert the older one still decrypts. That is the
|
||||
property, and it is invisible to any test that keeps one instance alive.
|
||||
|
||||
### Getting the change into this build
|
||||
|
||||
Quartz is **not** a local fork. It is `com.vitorpamplona.quartz:quartz`, pinned
|
||||
in `gradle/libs.versions.toml` and resolved from mavenCentral;
|
||||
`settings.gradle.kts` only `includeBuild`s `lightning-kmp-app`. Nothing in this
|
||||
repository can change it.
|
||||
|
||||
There is a full amethyst clone at `~/Documents/development/nostr/amethyst` whose
|
||||
`SecretTree.kt` was byte-identical to published 1.14.0 when this was written, so
|
||||
the patch itself is a small delta against a known-good base. Landing it means one
|
||||
of:
|
||||
|
||||
- **Upstream it.** It is a genuine RFC 9420 conformance gap and affects any
|
||||
client that reloads group state per message, which is the ordinary shape for a
|
||||
mobile app. Slowest, and the only option that leaves this repo's build
|
||||
reproducible.
|
||||
- **Patch the clone and publish to mavenLocal**, then add `mavenLocal()` here and
|
||||
pin the patched version. Fast, but the build then depends on a patched crypto
|
||||
library built from one machine's filesystem.
|
||||
- **Wire quartz as a composite build**, the way `lightning-kmp-app` is. Same
|
||||
coupling to a path outside the repo, but the source is at least visible.
|
||||
|
||||
## What this app does in the meantime
|
||||
|
||||
`MlsGroupCache` keeps a room's `MlsGroup` instance alive between messages instead
|
||||
of rebuilding it from stored state each time, so `skippedKeys` survives for as
|
||||
long as the process does. The inbound path in `NostrDao` goes through it.
|
||||
|
||||
This covers the case that actually bites — a burst arriving in one sync, decrypted
|
||||
one after another against the same tree — and it is what makes the flows in the
|
||||
table above work.
|
||||
|
||||
It is not the fix, and it is worth being precise about what it leaves broken:
|
||||
|
||||
- **A restart loses the cache.** Messages skipped before the app closed cannot be
|
||||
read after it reopens.
|
||||
- **Another writer invalidates it.** Sending a message advances the sender ratchet
|
||||
and saves the room's state; adding a member does too. The cache reuses its
|
||||
instance only while the stored state is still exactly what it last wrote, and
|
||||
rebuilds otherwise — dropping the skipped keys at that point, exactly as before.
|
||||
- **Nothing helps a long reorder.** A message the relay holds back until after a
|
||||
restart or an outbound send is gone.
|
||||
|
||||
The staleness check is what keeps the cache from being *worse* than no cache: a
|
||||
group that has been overtaken by another writer is never carried on with, so the
|
||||
fallback is always the old behaviour rather than a diverged ratchet.
|
||||
Reference in new issue
Block a user