diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 36eedda..1aff8db 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -1592,6 +1592,9 @@ fun ColumnScope.ChatScreen( // Recorded by ID at the moment of picking, never re-derived from the text // when sending. var pendingMentions by remember { mutableStateOf(emptySet()) } + + /** Whether the new-poll dialog is up (§6.29). */ + var askingPoll by remember(chat.threadId) { mutableStateOf(false) } val listState = rememberLazyListState() // Open AT the newest message, a jump, not a tour of the history. @@ -1654,6 +1657,12 @@ fun ColumnScope.ChatScreen( } if (chat.peerId != null) ConversationMenu(model, chat, draft) { setDraft("") } chat.group?.let { group -> + IconAction( + icon = KitIcon.Poll, + label = "New poll", + tag = "new-poll", + onClick = { askingPoll = true }, + ) IconAction( icon = KitIcon.LeaveGroup, label = "Leave group", @@ -1691,6 +1700,21 @@ fun ColumnScope.ChatScreen( /** The message the next send answers (android#16). */ var replying by remember(chat.threadId) { mutableStateOf(null) } + // §6.29. Above the messages rather than in them: a poll is not a bubble, + // it changes as pieces arrive, and it has to stay reachable while people + // are still voting. + chat.polls.forEach { poll -> + PollCard(model, poll) { id -> + buddies.firstOrNull { it.userId == id }?.displayName ?: "user $id" + } + } + + if (askingPoll) { + chat.group?.let { group -> + NewPollDialog(model, group, myUserId) { askingPoll = false } + } + } + // The pinned banner, above the messages: a pin nobody can see is a pin // nobody uses. chat.pinnedMessageId?.let { pinnedId -> diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt index 5245339..d13ca9d 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt @@ -6,6 +6,7 @@ import androidx.compose.material.icons.automirrored.outlined.Logout import androidx.compose.material.icons.automirrored.outlined.Send import androidx.compose.material.icons.filled.ChatBubble import androidx.compose.material.icons.filled.Circle +import androidx.compose.material.icons.outlined.BarChart import androidx.compose.material.icons.outlined.AccountBalanceWallet import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.AddCircleOutline @@ -99,6 +100,9 @@ object KitIcon { val Verified: ImageVector = Icons.Outlined.Verified val LeaveGroup: ImageVector = Icons.AutoMirrored.Outlined.Logout + /** Asking a group something (§6.29). */ + val Poll: ImageVector = Icons.Outlined.BarChart + /** Presence and unread dots, where a filled circle IS the icon. */ val Dot: ImageVector = Icons.Filled.Circle diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 1bde7f7..4361492 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -201,6 +201,12 @@ class MessengerViewModel( * The message pinned in this conversation (§6.19), or null. */ val pinnedMessageId: String? = null, + /** + * Polls asked in this group (§6.29). Carried on the chat because a + * poll is not a message: it changes as pieces and sums arrive, + * which touches no thread at all. + */ + val polls: List = emptyList(), /** * What we believe about the other side's identity keys (§19). */ @@ -1147,6 +1153,53 @@ class MessengerViewModel( /** * Leaving also clears the local thread. */ + // ---- Polls (§6.29) ----------------------------------------------------. + + /** Polls asked in this group, for the cards above the messages. */ + fun pollsIn(groupId: String): List = + session?.conversations?.polls?.pollsIn(groupId).orEmpty().sortedBy { it.id } + + /** Ask a poll of a group. Nothing is voted here. */ + fun askPoll( + group: io.flamenet.client.RelayClient.Group, + question: String, + options: List, + ) = viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { + runCatching { current.conversations.askPoll(group, current.userId, question, options) } + } + refreshPolls() + } + + /** + * Vote. Each piece goes to the member it belongs to and to nobody else: + * the whole point is that no individual vote is ever sent anywhere. + */ + fun votePoll( + pollId: String, + choice: Int, + ) = viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { + runCatching { current.conversations.vote(pollId, current.userId, choice) } + } + refreshPolls() + } + + private fun refreshPolls() { + val current = session ?: return + update { state -> + val gid = state.openChat?.group?.id ?: return@update state + state.copy( + openChat = + state.openChat.copy( + polls = current.conversations.polls.pollsIn(gid).sortedBy { it.id }, + ), + ) + } + } + fun leaveGroup(groupId: String) = viewModelScope.launch { val current = session ?: return@launch @@ -3764,10 +3817,27 @@ class MessengerViewModel( state.copy( openChat = open?.let { - if (it.threadId in threads) { - it.copy(lines = current.history(it.threadId)) + val withLines = + if (it.threadId in threads) { + it.copy(lines = current.history(it.threadId)) + } else { + it + } + // Poll traffic is control traffic: + // it touches no thread, so it never + // appears in `threads` and a card + // waiting on somebody else's vote + // would never have moved. + val gid = withLines.group?.id + if (gid == null) { + withLines } else { - it + withLines.copy( + polls = + current.conversations.polls + .pollsIn(gid) + .sortedBy { p -> p.id }, + ) } }, // A thread that is already open is being diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/PollUi.kt b/app/src/main/kotlin/io/flamenet/messenger/app/PollUi.kt new file mode 100644 index 0000000..0f5086a --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/PollUi.kt @@ -0,0 +1,264 @@ +package io.flamenet.messenger.app + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.flamenet.e2e.Poll +import io.flamenet.e2e.PollBook + +/** + * A poll in a group, and what it is waiting for (§6.29). + * + * The screen carries two claims the maths actually supports and no more: that + * nobody is sent an individual vote, and that everybody other than the voter + * would have to work together to recover one. It never says "nobody can know", + * and it never shows a count before every member has published, because a + * missing sum leaves noise in the total rather than a smaller number. + */ +@Composable +fun ColumnScope.PollCard( + model: MessengerViewModel, + poll: PollBook.Entry, + name: (Long) -> String, +) { + val standing = poll.standing + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Radius.medium)) + .background(Palette.Accent.copy(alpha = 0.08f)) + .padding(14.dp) + .testTag("poll-${poll.id}"), + verticalArrangement = Arrangement.spacedBy(Space.sm), + ) { + // A poll of two is not anonymous: the other member holds a piece of + // your vote and sees the tally, and subtracting gives it, with nobody + // to collude with. The word is not offered for a pair. + Text( + if (poll.members.size > 2) "Anonymous poll" else "Poll", + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = Palette.SecondaryText, + ) + Text(poll.question, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + + when (standing) { + is PollBook.Standing.Counted -> Results(poll, standing.counts) + else -> { + if (poll.myChoice == null) { + poll.options.forEachIndexed { index, option -> + Text( + option, + fontSize = 15.sp, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Radius.small)) + .background(Palette.SecondaryText.copy(alpha = 0.12f)) + .clickable { model.votePoll(poll.id, index) } + .padding(horizontal = 12.dp, vertical = 9.dp) + .testTag("poll-option-$index"), + ) + } + } else { + Text( + "You chose ${poll.options[poll.myChoice ?: 0]}.", + fontSize = 14.sp, + color = Palette.SecondaryText, + ) + } + Text( + waitingLine(poll, standing, name), + fontSize = 13.sp, + color = Palette.SecondaryText, + ) + } + } + } + Spacer(Modifier.height(Space.sm)) +} + +private fun waitingLine( + poll: PollBook.Entry, + standing: PollBook.Standing, + name: (Long) -> String, +): String = + when (standing) { + is PollBook.Standing.WaitingForVotes -> + if (poll.myChoice == null) { + "Nobody sees a count until everyone has voted." + } else { + "Waiting for ${list(standing.from, name)} to vote." + } + + is PollBook.Standing.WaitingForSums -> + "Everyone voted. Counting, waiting for ${list(standing.from, name)}." + + is PollBook.Standing.Counted -> "" + } + +private fun list( + ids: List, + name: (Long) -> String, +): String { + val names = ids.map(name) + return when (names.size) { + 0 -> "nobody" + 1 -> names[0] + 2 -> "${names[0]} and ${names[1]}" + else -> names.dropLast(1).joinToString(", ") + " and " + names.last() + } +} + +@Composable +private fun ColumnScope.Results( + poll: PollBook.Entry, + counts: IntArray, +) { + val total = maxOf(counts.sum(), 1) + poll.options.forEachIndexed { index, option -> + val count = counts.getOrElse(index) { 0 } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(option, fontSize = 14.sp, modifier = Modifier.weight(1f)) + Text("$count", fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + } + Box( + modifier = + Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(Radius.small)) + .background(Palette.SecondaryText.copy(alpha = 0.15f)), + ) { + Box( + modifier = + Modifier + .fillMaxWidth(maxOf(count, 0).toFloat() / total.toFloat()) + .height(6.dp) + .clip(RoundedCornerShape(Radius.small)) + .background(Palette.Accent), + ) + } + Spacer(Modifier.height(Space.xs)) + } + // The guarantee, stated as it actually is. + Text( + if (poll.members.size > 2) { + "Nobody was sent anybody's vote. Everyone else here would have to " + + "work together to know how you voted." + } else { + "Nobody was sent your vote, but in a poll of two the other person " + + "can work it out from the result." + }, + fontSize = 12.sp, + color = Palette.SecondaryText, + ) +} + +/** Asking one. */ +@Composable +fun NewPollDialog( + model: MessengerViewModel, + group: io.flamenet.client.RelayClient.Group, + myUserId: Long, + onDismiss: () -> Unit, +) { + var question by remember { mutableStateOf("") } + var options by remember { mutableStateOf(listOf("", "")) } + val members = (group.memberIds + myUserId).distinct().size + val filled = options.map { it.trim() }.filter { it.isNotEmpty() } + val ready = question.isNotBlank() && filled.size >= 2 && members >= 2 + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("New poll") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) { + OutlinedTextField( + value = question, + onValueChange = { question = it }, + label = { Text("Ask something") }, + singleLine = false, + modifier = Modifier.fillMaxWidth().testTag("poll-question"), + ) + options.forEachIndexed { i, value -> + OutlinedTextField( + value = value, + onValueChange = { text -> + options = options.toMutableList().also { it[i] = text } + }, + label = { Text("Option ${i + 1}") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("poll-new-option-$i"), + ) + } + if (options.size < Poll.MAX_OPTIONS) { + TextButton(onClick = { options = options + "" }) { + Text("Add an option") + } + } + // Said before the poll is asked, not after, because it is what + // somebody is deciding on. + Text( + when { + members < 2 -> "A poll needs somebody else in the group." + members == 2 -> + "In a group of two this is not anonymous: the other " + + "person can work out your vote from the result. " + + "It is still a poll." + + else -> + "Nobody is sent anybody's vote, not even you as the " + + "person asking. Everyone other than the voter would " + + "have to work together to know how one person voted. " + + "Everyone has to vote before there is a result." + }, + fontSize = 12.sp, + color = Palette.SecondaryText, + ) + } + }, + confirmButton = { + TextButton( + enabled = ready, + onClick = { + model.askPoll(group, question.trim(), filled) + onDismiss() + }, + modifier = Modifier.testTag("poll-ask"), + ) { Text("Ask") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) + Spacer(Modifier.width(0.dp)) +} diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 2fd319e..f5a7147 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -7,6 +7,7 @@ import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.E2ESession import io.flamenet.e2e.Envelope import io.flamenet.e2e.PeerBundle +import io.flamenet.e2e.PollBook import io.flamenet.e2e.SealedSender import io.flamenet.e2e.Vault @@ -137,6 +138,78 @@ class Conversations( */ var requireSealed: () -> Boolean = { false } + // ---- Polls (§6.29) ----------------------------------------------------. + + /** Polls in flight, restored from the vault the first time they are asked for. */ + val polls: PollBook by lazy { + PollBook().apply { vault.loadPolls()?.let { restore(it) } } + } + + private fun savePolls() = vault.savePolls(polls.archive()) + + /** + * Ask a poll of a group. Nothing is voted here: the poll goes to the group + * and every member, this one included, votes separately. + */ + fun askPoll( + group: RelayClient.Group, + myUserId: Long, + question: String, + options: List, + ): String { + val members = (group.memberIds + myUserId).distinct().sorted() + require(members.size >= 2) { "a poll needs somebody else in the group" } + val id = java.util.UUID.randomUUID().toString() + val set = PollBook.memberSet(members) + val body = Content.poll(id, question, options, group.id, set) + polls.remember(id, group.id, question, options, set) + savePolls() + for (member in members) { + if (member == myUserId) continue + runCatching { sendPlaintext(member, body) } + } + return id + } + + /** + * Vote, which means handing every member one piece of the vote and keeping + * one. No member is ever sent the vote itself. + */ + fun vote( + pollId: String, + myUserId: Long, + choice: Int, + ) { + val entry = polls.poll(pollId) ?: return + val outgoing = polls.vote(pollId, myUserId, choice) + savePolls() + for ((member, piece) in outgoing) { + // One envelope per member, each different. This is the one place a + // group message is not the same message to everybody: sending the + // same piece to all of them would let any member subtract it from + // the tally and read the vote. + runCatching { sendPlaintext(member, Content.pollShare(pollId, piece, entry.groupId)) } + } + publishPollSum(pollId) + } + + /** + * Publish this device's sum, once every member has voted and never before: + * a sum published early leaves noise in the total rather than a smaller + * count. + */ + private fun publishPollSum(pollId: String) { + val me = myUserId ?: return + val entry = polls.poll(pollId) ?: return + val sum = polls.sumToPublish(pollId, me) ?: return + savePolls() + val body = Content.pollSum(pollId, sum, entry.groupId) + for (member in entry.members) { + if (member == me) continue + runCatching { sendPlaintext(member, body) } + } + } + fun sendToGroup( group: RelayClient.Group, text: String, @@ -1469,6 +1542,37 @@ class Conversations( vault.saveDisappearSeconds(Vault.threadForUser(fromUser), seconds) } + Content.Kind.POLL -> { + val raw = content.raw ?: return + val opts = raw.optJSONArray("opts") ?: return + val options = (0 until opts.length()).map { opts.optString(it) } + if (polls.remember( + raw.optString("pid"), + raw.optString("gid"), + raw.optString("q"), + options, + raw.optString("mset"), + ) + ) { + savePolls() + } + } + + Content.Kind.POLL_SHARE -> { + val raw = content.raw ?: return + val piece = Content.pollVector(content) ?: return + if (polls.receivePiece(raw.optString("pid"), fromUser, piece)) { + savePolls() + publishPollSum(raw.optString("pid")) + } + } + + Content.Kind.POLL_SUM -> { + val raw = content.raw ?: return + val sum = Content.pollVector(content) ?: return + if (polls.receiveSum(raw.optString("pid"), fromUser, sum)) savePolls() + } + Content.Kind.OFF_RECORD -> { val on = Content.offTheRecord(content) ?: return vault.saveOffTheRecord(Vault.threadForUser(fromUser), on) diff --git a/client/src/test/kotlin/io/flamenet/client/PollSharesGoToOneMemberTest.kt b/client/src/test/kotlin/io/flamenet/client/PollSharesGoToOneMemberTest.kt new file mode 100644 index 0000000..94bb9b9 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/PollSharesGoToOneMemberTest.kt @@ -0,0 +1,85 @@ +package io.flamenet.client + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * A share goes to one member. Sending the same one to the group reveals the + * vote (§6.29). + * + * Every other group envelope in the app is the same bytes to everybody, so the + * group send is the obvious thing to reach for, and it is the one call that + * must not appear in the vote path: handing every member the same piece means + * any of them can subtract it from the tally, and the poll is anonymous in name + * only. The mistake leaves no trace at runtime. The counts still come out + * right. + * + * `Conversations` needs a relay, so this reads the source, bounded to the + * function each check is about. + */ +class PollSharesGoToOneMemberTest { + private val source = + File("src/main/kotlin/io/flamenet/client/Conversations.kt").readText() + + /** From a declaration to the next one at the same indent. */ + private fun function(marker: String): String { + val start = source.indexOf(marker) + assertTrue(start >= 0, "missing $marker") + val rest = source.substring(start + marker.length) + val end = + listOf("\n fun ", "\n private fun ", "\n // ----") + .mapNotNull { rest.indexOf(it).takeIf { i -> i >= 0 } } + .minOrNull() ?: rest.length + return rest.substring(0, end) + } + + @Test + fun `voting sends each piece to the member it belongs to`() { + val vote = function("fun vote(") + assertTrue( + vote.contains("for ((member, piece) in outgoing)"), + "each piece must be addressed to its own member", + ) + assertTrue(vote.contains("sendPlaintext(member, Content.pollShare(")) + } + + @Test + fun `and never sends one to the whole group`() { + val vote = function("fun vote(") + assertFalse( + vote.contains("sendToGroup"), + "sending a share to the group hands every member the same piece, and any one " + + "of them can then subtract it from the tally to read the vote", + ) + } + + /** + * The published sum is the same to everybody, which is the other half of + * the rule: if it ever became a per-member calculation it would mean + * somebody had copied the share path without knowing why it differed. + */ + @Test + fun `the sum is the same envelope to every member`() { + val publish = function("private fun publishPollSum(") + assertTrue(publish.contains("val body = Content.pollSum(")) + assertTrue(publish.contains("for (member in entry.members)")) + } + + /** + * A sum published before the last vote arrives is noise in the total, not + * a smaller count, so the decision stays with the book. + */ + @Test + fun `publishing waits for the book to say so`() { + assertTrue(function("private fun publishPollSum(").contains("polls.sumToPublish(")) + } + + /** And a poll that is not written down could never finish. */ + @Test + fun `every change to a poll is written down`() { + assertTrue(function("fun askPoll(").contains("savePolls()")) + assertTrue(function("fun vote(").contains("savePolls()")) + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/PollBook.kt b/engine/src/main/kotlin/io/flamenet/e2e/PollBook.kt new file mode 100644 index 0000000..d5db1f0 --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/PollBook.kt @@ -0,0 +1,254 @@ +package io.flamenet.e2e + +import org.json.JSONArray +import org.json.JSONObject +import java.security.SecureRandom +import java.util.Random + +/** + * Every poll this device knows about, and what it owes each one (§6.29). + * + * Held apart from the session because the sequencing is the hard part and it is + * worth being able to test it without a relay: a vote is split into pieces, one + * per member, each member adds up the pieces they were handed, and only those + * sums are published. Nothing here sends anything. It says what should be sent + * and the session does it. + * + * The same shape as the iOS `PollBook`, deliberately. A poll crosses between + * the two engines, so a rule enforced on one side and not the other is a poll + * that tallies differently depending on who is looking at it. + */ +class PollBook { + /** What a poll is waiting for, which is what the screen has to say. */ + sealed interface Standing { + /** + * Not everybody has voted yet. Until they have, nobody holds a complete + * set of pieces and no sum can be published. + */ + data class WaitingForVotes(val from: List) : Standing + + /** Everybody voted; not everybody has published their sum yet. */ + data class WaitingForSums(val from: List) : Standing + + /** Every member published. These are the counts. */ + data class Counted(val counts: IntArray) : Standing { + override fun equals(other: Any?): Boolean = + other is Counted && counts.contentEquals(other.counts) + + override fun hashCode(): Int = counts.contentHashCode() + } + } + + data class Entry( + val id: String, + val groupId: String, + val question: String, + val options: List, + /** + * The member set the arithmetic is agreed over, in the order the pieces + * are indexed. Every client must use this same order. + */ + val members: List, + /** What this device chose, once it has. */ + var myChoice: Int? = null, + /** One piece per voter, the piece that voter handed to me. */ + val piecesHeld: MutableMap = mutableMapOf(), + /** One published sum per member. */ + val sumsPublished: MutableMap = mutableMapOf(), + /** + * Whether this device already published its own sum, so it is published + * exactly once however many times the last piece arrives. + */ + var didPublish: Boolean = false, + ) { + val standing: Standing + get() { + val missingVotes = members.filter { piecesHeld[it] == null } + if (missingVotes.isNotEmpty()) return Standing.WaitingForVotes(missingVotes) + val missingSums = members.filter { sumsPublished[it] == null } + if (missingSums.isNotEmpty()) return Standing.WaitingForSums(missingSums) + // Every member published, so this is the tally rather than a + // partial one. A missing sum would leave noise here, not a + // smaller count, which is why it is never shown before now. + return Standing.Counted( + Poll.tally(members.mapNotNull { sumsPublished[it] }, options.size), + ) + } + } + + private val entries = LinkedHashMap() + + val polls: List get() = entries.values.toList() + + fun poll(id: String): Entry? = entries[id] + + fun pollsIn(groupId: String): List = entries.values.filter { it.groupId == groupId } + + /** + * A poll arrived, or this device is asking one. Returns false for one + * already known, so a duplicate delivery changes nothing. + */ + fun remember( + id: String, + groupId: String, + question: String, + options: List, + memberSet: String, + ): Boolean { + if (entries.containsKey(id)) return false + val members = members(memberSet) + if (members.size < 2 || options.size !in 2..Poll.MAX_OPTIONS) return false + entries[id] = Entry(id, groupId, question, options, members) + return true + } + + /** + * A piece of somebody's vote reached this device. Returns whether it was + * new, so a redelivery cannot be counted twice. + */ + fun receivePiece( + id: String, + from: Long, + piece: IntArray, + ): Boolean { + val entry = entries[id] ?: return false + if (from !in entry.members) return false + if (piece.size != entry.options.size) return false + if (entry.piecesHeld.containsKey(from)) return false + entry.piecesHeld[from] = piece + return true + } + + /** A member published their sum. */ + fun receiveSum( + id: String, + from: Long, + sum: IntArray, + ): Boolean { + val entry = entries[id] ?: return false + if (from !in entry.members) return false + if (sum.size != entry.options.size) return false + if (entry.sumsPublished.containsKey(from)) return false + entry.sumsPublished[from] = sum + return true + } + + /** + * This device's vote, split into one piece per member. The piece for this + * device is kept rather than sent, and the rest are addressed to the member + * they belong to: a piece sent to the whole group would let any member + * subtract it from the tally and read the vote. + */ + fun vote( + id: String, + me: Long, + choice: Int, + random: Random = SecureRandom(), + ): List> { + val entry = entries[id] ?: return emptyList() + if (entry.myChoice != null) return emptyList() + val pieces = Poll.shares(choice, entry.options.size, entry.members.size, random) + entry.myChoice = choice + val mine = entry.members.indexOf(me) + if (mine >= 0) entry.piecesHeld[me] = pieces[mine] + return entry.members.zip(pieces).filter { it.first != me } + } + + /** + * The sum to publish, once every member has voted and not before, and only + * once. Null when there is nothing to publish yet. + */ + fun sumToPublish( + id: String, + me: Long, + ): IntArray? { + val entry = entries[id] ?: return null + if (entry.didPublish) return null + if (me !in entry.members) return null + if (entry.members.any { entry.piecesHeld[it] == null }) return null + val sum = Poll.partial(entry.members.mapNotNull { entry.piecesHeld[it] }, entry.options.size) + entry.didPublish = true + // Publishing is also receiving: this device's own sum counts toward the + // tally like anybody else's. + entry.sumsPublished[me] = sum + return sum + } + + // ---- At rest ----------------------------------------------------------. + + fun archive(): JSONObject { + val root = JSONObject() + for ((id, e) in entries) { + val o = JSONObject() + o.put("gid", e.groupId) + o.put("q", e.question) + o.put("opts", JSONArray(e.options)) + o.put("members", JSONArray(e.members)) + e.myChoice?.let { o.put("choice", it) } + o.put("didPublish", e.didPublish) + o.put("held", vectors(e.piecesHeld)) + o.put("sums", vectors(e.sumsPublished)) + root.put(id, o) + } + return root + } + + fun restore(root: JSONObject) { + entries.clear() + for (id in root.keys()) { + val o = root.optJSONObject(id) ?: continue + val options = strings(o.optJSONArray("opts")) + val members = longs(o.optJSONArray("members")) + if (options.size < 2 || members.size < 2) continue + val e = + Entry( + id = id, + groupId = o.optString("gid"), + question = o.optString("q"), + options = options, + members = members, + myChoice = if (o.has("choice")) o.optInt("choice") else null, + didPublish = o.optBoolean("didPublish"), + ) + readVectors(o.optJSONObject("held"), e.piecesHeld) + readVectors(o.optJSONObject("sums"), e.sumsPublished) + entries[id] = e + } + } + + private fun vectors(from: Map): JSONObject { + val o = JSONObject() + for ((who, v) in from) o.put(who.toString(), JSONArray(v.toList())) + return o + } + + private fun readVectors( + from: JSONObject?, + into: MutableMap, + ) { + val o = from ?: return + for (key in o.keys()) { + val who = key.toLongOrNull() ?: continue + val a = o.optJSONArray(key) ?: continue + into[who] = IntArray(a.length()) { a.optInt(it) } + } + } + + private fun strings(a: JSONArray?): List = + if (a == null) emptyList() else (0 until a.length()).map { a.optString(it) } + + private fun longs(a: JSONArray?): List = + if (a == null) emptyList() else (0 until a.length()).map { a.optLong(it) } + + companion object { + /** + * The member set as it travels: ids, ascending, comma separated. Both + * engines must agree on this exactly, because it fixes which index each + * member's piece sits at. Sorting is what makes two clients that learned + * the membership in a different order still agree. + */ + fun memberSet(ids: List): String = ids.toSortedSet().joinToString(",") + + fun members(set: String): List = set.split(",").mapNotNull { it.trim().toLongOrNull() } + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 4481c8b..af43a0a 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -75,6 +75,7 @@ class Vault( private const val STORY_ARCHIVE = "story-archive" private const val DISAPPEAR = "disappear" private const val OFF_RECORD = "offrecord" + private const val POLLS = "polls" /** Bridge lines contacts have offered, pending review. See [bridgeOffers]. */ private const val BRIDGE_OFFERS = "bridge-offers" @@ -1017,6 +1018,17 @@ class Vault( fun disappearSeconds(threadId: String): Int = readJson(DISAPPEAR)?.optInt(threadId, 0) ?: 0 /** Whether this conversation is off the record (§6.28). */ + /** + * Polls in flight (§6.29). + * + * Kept at rest, or a poll could never finish: it waits for every member to + * vote and then to publish, and the pieces already gathered would be gone + * the first time the app was closed. + */ + fun loadPolls(): JSONObject? = readJson(POLLS) + + fun savePolls(polls: JSONObject) = writeJson(POLLS, polls) + fun offTheRecord(threadId: String): Boolean = readJson(OFF_RECORD)?.optBoolean(threadId, false) ?: false /** diff --git a/engine/src/test/kotlin/io/flamenet/e2e/PollBookTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/PollBookTest.kt new file mode 100644 index 0000000..b95cde1 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/PollBookTest.kt @@ -0,0 +1,216 @@ +package io.flamenet.e2e + +import org.json.JSONObject +import java.util.Random +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Running a whole poll between three people, without a relay (§6.29). + * + * The sequencing is the part that goes wrong: a sum published before everybody + * has voted is not a partial answer, it is noise, and a piece counted twice is + * a wrong count that nothing else would catch. + */ +class PollBookTest { + private val members = listOf(4L, 7L, 9L) + + private fun book(): PollBook = + PollBook().apply { + remember("p1", "g1", "Lunch?", listOf("Thai", "Pizza"), PollBook.memberSet(members)) + } + + private fun rng(seed: Long) = Random(seed) + + /** Everybody votes, the pieces travel, everybody publishes. */ + @Test + fun `a whole poll between three people`() { + val books = members.associateWith { book() } + val choices = mapOf(4L to 1, 7L to 0, 9L to 1) + + for (voter in members) { + val outgoing = books[voter]!!.vote("p1", voter, choices[voter]!!, rng(voter)) + assertEquals(members.size - 1, outgoing.size, "my own piece never leaves") + for ((member, piece) in outgoing) { + assertTrue(books[member]!!.receivePiece("p1", voter, piece)) + } + } + + val sums = members.associateWith { assertNotNull(books[it]!!.sumToPublish("p1", it)) } + for ((publisher, sum) in sums) { + for (member in members) { + if (member != publisher) { + assertTrue(books[member]!!.receiveSum("p1", publisher, sum)) + } + } + } + + for (member in members) { + val standing = books[member]!!.poll("p1")!!.standing + assertTrue(standing is PollBook.Standing.Counted, "$member has no tally") + assertContentEquals( + intArrayOf(1, 2), + (standing as PollBook.Standing.Counted).counts, + "one Thai, two Pizza", + ) + } + } + + /** The rule the interface depends on. */ + @Test + fun `no sum is published before everybody has voted`() { + val mine = book() + mine.vote("p1", 4L, 0, rng(1)) + assertNull(mine.sumToPublish("p1", 4L), "only my own vote is in") + + val other = book() + val from7 = other.vote("p1", 7L, 1, rng(2)) + mine.receivePiece("p1", 7L, from7.first { it.first == 4L }.second) + assertNull(mine.sumToPublish("p1", 4L), "still nothing from 9") + + val third = book() + val from9 = third.vote("p1", 9L, 1, rng(3)) + mine.receivePiece("p1", 9L, from9.first { it.first == 4L }.second) + assertNotNull(mine.sumToPublish("p1", 4L), "now every member has voted") + } + + /** And it says who it is waiting on. */ + @Test + fun `it says who it is waiting for`() { + val mine = book() + mine.vote("p1", 4L, 0, rng(1)) + val standing = mine.poll("p1")!!.standing + assertTrue(standing is PollBook.Standing.WaitingForVotes) + assertEquals(listOf(7L, 9L), (standing as PollBook.Standing.WaitingForVotes).from) + } + + @Test + fun `the sum is published only once`() { + val mine = book() + mine.vote("p1", 4L, 0, rng(1)) + for (voter in listOf(7L, 9L)) { + val out = book().vote("p1", voter, 1, rng(voter)) + mine.receivePiece("p1", voter, out.first { it.first == 4L }.second) + } + assertNotNull(mine.sumToPublish("p1", 4L)) + assertNull(mine.sumToPublish("p1", 4L), "a second publish would double the count") + } + + /** Redelivery is the failure that shows up as a tally quietly being wrong. */ + @Test + fun `a redelivered piece is ignored`() { + val out = book().vote("p1", 7L, 1, rng(2)) + val piece = out.first { it.first == 4L }.second + val mine = book() + assertTrue(mine.receivePiece("p1", 7L, piece)) + assertFalse(mine.receivePiece("p1", 7L, piece), "counted twice") + } + + @Test + fun `a redelivered sum is ignored`() { + val mine = book() + assertTrue(mine.receiveSum("p1", 7L, intArrayOf(3, -3))) + assertFalse(mine.receiveSum("p1", 7L, intArrayOf(3, -3))) + } + + @Test + fun `a stranger cannot join the arithmetic`() { + val mine = book() + assertFalse(mine.receivePiece("p1", 999L, intArrayOf(1, 0))) + assertFalse(mine.receiveSum("p1", 999L, intArrayOf(1, 0))) + } + + @Test + fun `a vector of the wrong width is refused`() { + val mine = book() + assertFalse(mine.receivePiece("p1", 7L, intArrayOf(1, 0, 0))) + assertFalse(mine.receiveSum("p1", 7L, intArrayOf(1))) + } + + /** Both engines index the pieces the same way, whatever order was learned. */ + @Test + fun `the member set is order independent`() { + assertEquals("4,7,9", PollBook.memberSet(listOf(9L, 4L, 7L))) + assertEquals("4,7,9", PollBook.memberSet(listOf(9L, 9L, 4L, 7L))) + assertEquals(listOf(4L, 7L, 9L), PollBook.members("4,7,9")) + } + + @Test + fun `a redelivered poll does not reset it`() { + val mine = book() + mine.vote("p1", 4L, 0, rng(1)) + assertFalse( + mine.remember( + "p1", + "g1", + "Lunch?", + listOf("Thai", "Pizza"), + PollBook.memberSet(members), + ), + ) + assertEquals(0, mine.poll("p1")!!.myChoice, "the vote survived") + } + + @Test + fun `voting twice changes nothing`() { + val mine = book() + val first = mine.vote("p1", 4L, 0, rng(1)) + val second = mine.vote("p1", 4L, 1, rng(1)) + assertTrue(first.isNotEmpty()) + assertTrue(second.isEmpty(), "a second vote must send nothing") + assertEquals(0, mine.poll("p1")!!.myChoice) + } + + /** + * A poll has to survive being closed, or it could never finish: it waits + * for every member, and the pieces already gathered would be gone. + */ + @Test + fun `it survives a round trip through storage`() { + val mine = book() + mine.vote("p1", 4L, 1, rng(1)) + val out = book().vote("p1", 7L, 0, rng(2)) + mine.receivePiece("p1", 7L, out.first { it.first == 4L }.second) + + val back = PollBook() + back.restore(JSONObject(mine.archive().toString())) + + val e = assertNotNull(back.poll("p1")) + assertEquals("Lunch?", e.question) + assertEquals(listOf("Thai", "Pizza"), e.options) + assertEquals(members, e.members) + assertEquals(1, e.myChoice) + assertContentEquals( + mine.poll("p1")!!.piecesHeld[7L], + e.piecesHeld[7L], + "the piece somebody handed us has to come back exactly", + ) + assertContentEquals(mine.poll("p1")!!.piecesHeld[4L], e.piecesHeld[4L]) + } + + /** And the negative values in a share have to survive it too. */ + @Test + fun `storage keeps the sign and the extremes`() { + val mine = book() + mine.receiveSum("p1", 7L, intArrayOf(Int.MIN_VALUE, Int.MAX_VALUE)) + val back = PollBook() + back.restore(JSONObject(mine.archive().toString())) + assertContentEquals( + intArrayOf(Int.MIN_VALUE, Int.MAX_VALUE), + back.poll("p1")!!.sumsPublished[7L], + ) + } + + /** One member is not a poll. */ + @Test + fun `a poll needs at least two members`() { + val b = PollBook() + assertTrue(b.remember("p2", "g1", "?", listOf("a", "b"), "4,7")) + assertFalse(b.remember("p3", "g1", "?", listOf("a", "b"), "4")) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/PollParseProbeTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/PollParseProbeTest.kt new file mode 100644 index 0000000..07ced86 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/PollParseProbeTest.kt @@ -0,0 +1,49 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * The poll envelopes have to come back out of the parser with their fields + * intact, and must never reach a thread as a bubble (§6.29). + */ +class PollParseProbeTest { + @Test + fun `a poll parses with its raw fields`() { + val wire = Content.poll("p1", "Lunch?", listOf("Thai", "Pizza"), "g1", "4,7,9") + val c = Content.parse(wire) + assertEquals(Content.Kind.POLL, c.kind) + val raw = assertNotNull(c.raw, "a poll must keep its fields") + assertEquals("p1", raw.optString("pid")) + assertEquals("Lunch?", raw.optString("q")) + assertEquals("4,7,9", raw.optString("mset")) + assertEquals("g1", raw.optString("gid")) + assertTrue(c.isControl, "a poll is consumed, never shown") + assertFalse(c.isRenderable, "a poll must never become a bubble") + } + + @Test + fun `a share and a sum parse with their vectors`() { + val share = Content.parse(Content.pollShare("p1", intArrayOf(7, -7), "g1")) + assertEquals(Content.Kind.POLL_SHARE, share.kind) + assertNotNull(share.raw) + assertEquals(listOf(7, -7), Content.pollVector(share)?.toList()) + assertFalse(share.isRenderable) + + val sum = Content.parse(Content.pollSum("p1", intArrayOf(Int.MIN_VALUE, Int.MAX_VALUE), "g1")) + assertEquals(listOf(Int.MIN_VALUE, Int.MAX_VALUE), Content.pollVector(sum)?.toList()) + assertFalse(sum.isRenderable) + } + + /** An older build must ignore these rather than showing anything. */ + @Test + fun `a poll is known to this build`() { + assertTrue(Content.Kind.POLL in Content.Kind.KNOWN) + assertTrue(Content.Kind.POLL_SHARE in Content.Kind.KNOWN) + assertTrue(Content.Kind.POLL_SUM in Content.Kind.KNOWN) + assertFalse(Content.parse(Content.poll("p1", "?", listOf("a", "b"), "g1", "1,2")).needsNewerClient) + } +}