diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectsFromCatalogueScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectsFromCatalogueScreen.kt index 0cf848c6..66a63737 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectsFromCatalogueScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectsFromCatalogueScreen.kt @@ -9,8 +9,10 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Translate +import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Card import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api @@ -41,6 +43,8 @@ import mantra.composeapp.generated.resources.already_in_this_group import mantra.composeapp.generated.resources.clear_the_selection import mantra.composeapp.generated.resources.nothing_matched_that_search import mantra.composeapp.generated.resources.pick_the_languages_this_group_will_translate_into +import mantra.composeapp.generated.resources.propose_dialect +import mantra.composeapp.generated.resources.propose_s_dialects import mantra.composeapp.generated.resources.s_of_s_selected import mantra.composeapp.generated.resources.search_by_language_country_or_code import mantra.composeapp.generated.resources.select_everything_missing @@ -55,6 +59,7 @@ import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository 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.Route import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.EmptyState @@ -63,6 +68,7 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.theme.readableContent @@ -92,6 +98,7 @@ fun AddDialectsFromCatalogueScreen( chatRepository: ChatRepository, mantraRepository: MantraRepository, frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, onNavigateToRoute: (Route) -> Unit, onNavigateBack: () -> Unit, ) { @@ -130,6 +137,47 @@ fun AddDialectsFromCatalogueScreen( title = { Text(stringResource(Res.string.add_dialects_from_a_list)) }, navigationIcon = { NavigateBackButton(onNavigateBack) }, ) + }, + bottomBar = { + // Only where there is something to propose. A room that + // holds the whole catalogue gets the empty state and its + // way to the form, and "Propose 0 dialects" underneath it + // would be a button that can never do anything. + if (state.missing.isNotEmpty()) { + BottomAppBar( + actions = {}, + floatingActionButton = { + ProposeWaveFab( + count = viewModel.selected.size, + enabled = state.canSign && + viewModel.selected.isNotEmpty(), + onClick = { + viewModel.proposeSelectedDialects( + localChatRoom = state.localChatRoom, + onSuccess = { sessionId -> + // Onto the session rather than + // back to the group. Nothing + // has been created yet -- the + // dialects appear when enough + // members sign -- so landing on + // a list they are not in would + // read as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = + activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + onFailure = {}, + ) + } + ) + } + ) + } } ) { innerPadding -> Column( @@ -222,6 +270,32 @@ fun AddDialectsFromCatalogueScreen( } } +/** + * Asks the group to sign what is ticked. + * + * Counted in the label because a wave is the thing being proposed and its size + * is the whole of what distinguishes one from another -- a member who has + * scrolled away from the summary still has to be told how many they are about to + * put in front of the group. + */ +@Composable +private fun ProposeWaveFab( + count: Int, + enabled: Boolean, + onClick: () -> Unit, +) { + ExtendedFab( + label = if (count == 1) { + stringResource(Res.string.propose_dialect) + } else { + stringResource(Res.string.propose_s_dialects, count) + }, + icon = { Icon(Icons.Default.Add, contentDescription = Decorative) }, + enabled = enabled, + onClick = onClick, + ) +} + /** * How much is ticked, and the two ways to change all of it at once. * @@ -398,6 +472,7 @@ private fun AddDialectsFromCataloguePreview() { chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {}, onNavigateToRoute = {}, onNavigateBack = {}, ) @@ -425,6 +500,7 @@ private fun AddDialectsFromCatalogueNothingLeftPreview() { chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {}, onNavigateToRoute = {}, onNavigateBack = {}, ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 449c5826..117aabe7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -1252,6 +1252,15 @@ fun MantraNavHost( chatRepository = databaseChatRepository, mantraRepository = databaseMantraRepository, frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace this picker so back returns to the group rather + // than to a selection whose proposal has already gone out. + navController.navigate(route = signingRoute) { + popUpTo { + inclusive = true + } + } + }, onNavigateToRoute = { actionRoute -> navController.navigate(route = actionRoute) }, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectsFromCatalogueViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectsFromCatalogueViewModel.kt index 3fc835f3..3877b3c4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectsFromCatalogueViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectsFromCatalogueViewModel.kt @@ -10,6 +10,7 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow @@ -17,8 +18,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.language.AfricanLanguage import press.mantra.compose.managers.DialectScaffold +import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository @@ -181,6 +184,83 @@ class AddDialectsFromCatalogueViewModel( return loaded.missing.filter { DialectScaffold.keyOf(it) in selected } } + /** + * Asks the group to sign the ticked dialects into existence. + * + * None of them are created here and none of them exist yet. What goes out is + * a proposal, and the dialects appear -- on every member's device at once, + * authored by the room's own key rather than by whoever ticked them -- when + * enough members have signed. + * + * One session per [FrostSigningManager.MAX_BATCH_SIZE] entries, the same way + * `TranslationArtifactVersionDetailViewModel` catches up a translation's + * missing chapters. The catalogue is smaller than one batch today, so the + * chunking never fires; it is a line, it is the established idiom, and it is + * what stops the sixty-fifth catalogue entry from being a bug rather than a + * second session. `DialectCatalogueSizeTest` is the tripwire. + * + * One timestamp for the lot, so a wave reads as the one act it is rather than + * as entries that happen to share a minute. + * + * A batch is all-or-nothing: if one item cannot be aggregated the session + * fails and none of them are applied, and a retry has to be a fresh proposal + * because the items' nonce seeds have already been published. Both are + * answered by the rule this screen is built on -- it only ever proposes what + * is missing -- so asking again after a failed wave lands exactly the + * entries the failed one did not, and the signing screen is right to tell a + * member it is safe to ask. + */ + fun proposeSelectedDialects( + localChatRoom: LocalChatRoom, + onSuccess: (sessionId: String) -> Unit, + onFailure: () -> Unit, + ) { + val languages = selectedLanguages() + + if (languages.isEmpty()) { + onFailure.invoke() + return + } + + // Guard against double submits from repeated taps. + if (isActionPending.value) return + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + val createdAt = TimeUtils.now() + + val sessions = runCatching { + languages.chunked(FrostSigningManager.MAX_BATCH_SIZE).mapNotNull { wave -> + frostSigningRepository.proposeSigningBatch( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + events = DialectScaffold.dialectsOf( + languages = wave, + createdAt = createdAt, + ), + ) + } + }.onFailure { error -> + logger.e("Failed to propose a wave of dialects for signing", error) + }.getOrNull().orEmpty() + + // The first, because it is the one that opened; any others are in the + // room's proposal list beside it. + val session = sessions.firstOrNull() + + if (session != null) selected.clear() + + // On the main thread because the callback navigates, and navigating + // off Dispatchers.IO crashes. The handoff belongs here rather than at + // the call site. + viewModelScope.launch(Dispatchers.Main) { + if (session != null) onSuccess.invoke(session.id) else onFailure.invoke() + } + + isActionPending.value = false + } + } + companion object { private const val TAG = "AddDialectsFromCatalogueViewModel" diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/DialectWaveProposalJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/DialectWaveProposalJvmTest.kt new file mode 100644 index 00000000..c30c9b8c --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/DialectWaveProposalJvmTest.kt @@ -0,0 +1,289 @@ +package press.mantra.compose.managers + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.Event +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgParticipantMessage +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.extensions.toHex +import press.mantra.compose.language.AfricanLanguage +import press.mantra.compose.language.AfricanLanguages +import press.mantra.compose.nostr.dkg.DkgRitualEvents +import press.mantra.compose.nostr.nip30303.DialectEvent + +/** + * What a wave of dialects actually puts in front of the group. + * + * `DialectScaffoldTest` builds the templates and asserts their shape, which is + * the easy half. This runs the real proposal against a real database and a real + * key, because the half with the sharp edge is the one the caller cannot see: + * every event of a wave is re-authored under the group's key at the room's + * derivation path, inside `proposeSigningBatch`, and the ids the group will sign + * are not the ids `dialectsOf` produced. + * + * The wave is also the first batch in this app made of many *peers* -- fifty + * dialects, no lead and no dependents -- rather than one thing and its parts. + */ +class DialectWaveProposalJvmTest { + private val participants = 3 + private val threshold = 2 + + /** Stands in for a completed ceremony; the test is about the proposal, not the DKG. */ + private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + private val thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex() + + private val roomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey) + + private val ceremonyId = "ceremony".padEnd(64, '0') + private val members = listOf("a", "b", "c").map { it.repeat(64) } + private val hostKeys = listOf("2a", "2b", "2c").map { it.padEnd(66, '0') } + + private val proposer = members.first() + + private lateinit var db: MantraDatabase + private lateinit var room: LocalChatRoom + + @AfterTest + fun closeDatabase() { + if (::db.isInitialized) db.close() + } + + /** The proposer's device: a room it can sign in, and the share to sign with. */ + private suspend fun openDevice() { + db = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + + val nostrEventId = "e$proposer".take(64) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = proposer, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128) + ) + ) + db.profileDao().upsert(Profile(publicKey = proposer, nostrEventId = nostrEventId)) + + val chatRoom = ChatRoom( + id = roomId, + userPublicKey = proposer, + subject = "#Ekklesia", + description = SharedKeyDerivation.describe("Admins of the group."), + mlsGroupState = null + ) + db.chatRoomDao().upsert(chatRoom) + + db.dkgSessionDao().upsert( + DkgSession( + id = ceremonyId, + chatRoomId = roomId, + coordinatorPublicKey = proposer, + userPublicKey = proposer, + threshold = threshold, + participantCount = participants, + stage = DkgRitualStage.COMPLETE, + hostPublicKey = hostKeys[0], + round1Random = "1".repeat(64), + round2AuxRandom = "2".repeat(64), + thresholdPublicKey = thresholdPublicKey, + secretShare = keyMaterial.secretShares[0].value.toHex(), + publicShares = keyMaterial.publicShares.joinToString(",") { it.value.toHex() } + ) + ) + + members.forEachIndexed { index, member -> + db.dkgSessionDao().upsert( + DkgParticipantMessage( + sessionId = ceremonyId, + participantPublicKey = member, + kind = DkgRitualEvents.HOST_KEY, + payload = hostKeys[index] + ) + ) + } + + room = LocalChatRoom(chatRoom = chatRoom) + } + + /** Exactly what `AddDialectsFromCatalogueViewModel` proposes, for one wave. */ + private suspend fun proposeWave(languages: List, createdAt: Long = STAMP) = + FrostSigningManager.proposeSigningBatch( + database = db, + localChatRoom = room, + userPublicKey = proposer, + events = DialectScaffold.dialectsOf(languages = languages, createdAt = createdAt), + ) + + private suspend fun itemEvents(sessionId: String): List = + db.frostSigningSessionDao().getItems(sessionId).map { Event.fromJson(it.unsignedEventJson) } + + private fun dialectsOf(events: List) = events.map { + DialectEvent(it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig) + } + + @Test + fun `a wave is one session with one item per entry, in order`() = runBlocking { + openDevice() + + val wave = AfricanLanguages.ALL.take(5) + val events = itemEvents(proposeWave(wave).id) + + assertEquals(wave.size, events.size) + assertTrue(events.all { it.kind == DialectEvent.KIND }) + assertEquals(wave.map { it.name }, dialectsOf(events).map { it.name() }) + } + + @Test + fun `every item carries the entry it came from`() = runBlocking { + openDevice() + + val wave = AfricanLanguages.ALL.take(8) + val dialects = dialectsOf(itemEvents(proposeWave(wave).id)) + + assertEquals( + wave, + dialects.map { + AfricanLanguage( + name = it.name().orEmpty(), + code = it.language().orEmpty(), + country = it.country().orEmpty(), + ) + } + ) + } + + @Test + fun `the whole wave is authored by the room, not by whoever ticked it`() = runBlocking { + openDevice() + + val events = itemEvents(proposeWave(AfricanLanguages.ALL.take(4)).id) + + // Signing runs at the path the room was derived at, so a dialect says + // which group made it simply by being signed. + assertTrue(events.all { it.pubKey == roomId }) + assertTrue(events.none { it.pubKey == proposer }) + } + + @Test + fun `one wave is one timestamp`() = runBlocking { + openDevice() + + val events = itemEvents(proposeWave(AfricanLanguages.ALL.take(6)).id) + + assertEquals(setOf(STAMP), events.map { it.createdAt }.toSet()) + } + + /** + * The ids the group will sign are not the ids the templates had, because the + * author is resolved inside the proposal. Distinct all the same: the entries + * share an author and a timestamp by construction, so their tags are the only + * thing separating them. + */ + @Test + fun `items of one wave get distinct ids`() = runBlocking { + openDevice() + + val events = itemEvents(proposeWave(AfricanLanguages.ALL.take(20)).id) + + assertEquals(events.size, events.map { it.id }.distinct().size) + } + + @Test + fun `the whole catalogue proposes in a single session`() = runBlocking { + openDevice() + + val events = itemEvents(proposeWave(AfricanLanguages.ALL).id) + + assertEquals(AfricanLanguages.ALL.size, events.size) + } + + /** + * A selection bigger than a batch is chunked rather than refused, which is + * what the view model does with `chunked(MAX_BATCH_SIZE)`. Asserted here + * because the catalogue is smaller than one batch today, so nothing else + * exercises it. + */ + @Test + fun `a selection larger than a batch becomes several sessions`() = runBlocking { + openDevice() + + val oversized = (0 until FrostSigningManager.MAX_BATCH_SIZE + 1).map { index -> + AfricanLanguage(name = "Language $index", code = "x$index", country = "Nowhere") + } + + val sessions = oversized.chunked(FrostSigningManager.MAX_BATCH_SIZE).map { + proposeWave(it) + } + + assertEquals(2, sessions.size) + assertEquals( + listOf(FrostSigningManager.MAX_BATCH_SIZE, 1), + sessions.map { itemEvents(it.id).size } + ) + } + + /** + * The round trip the picker depends on: the rows a signed wave becomes say + * what was ticked. + * + * Against the *proposed* events rather than the templates, since those are + * the ones the group signs -- and `fromDialectEvent` is the arm + * `ChatMessage.applyInnerEvent` will take when it does. + */ + @Test + fun `a proposed wave parses back into the dialects that were ticked`() = runBlocking { + openDevice() + + val wave = AfricanLanguages.ALL.take(12) + val events = itemEvents(proposeWave(wave).id) + + val rows = events.mapNotNull { event -> + MantraDialect.fromDialectEvent( + dialectEvent = DialectEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ), + chatRoomId = roomId, + ) + } + + assertEquals(wave.size, rows.size) + assertEquals( + wave, + rows.map { AfricanLanguage(name = it.name, code = it.language, country = it.country) } + ) + + // And so the picker would now show every one of them as held rather than + // offering them again. + assertTrue( + DialectScaffold.missingFrom(existing = rows, catalogue = wave).isEmpty() + ) + } + + private companion object { + const val STAMP = 1_700_000_000L + } +}