Polls where nobody can tell who voted for what

The secure-aggregation core has been in the engine since it was written and
nothing used it: there was no way to ask a poll, vote in one, or see a
result. This is the rest.

`PollBook` is the sequencing, deliberately the same shape as the iOS one. 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. No sum is published until every member has voted: a member who has not
voted has handed nobody a piece, so nobody holds a complete set, and a sum
published early is noise rather than a partial answer.

Asking a poll sends the same envelope to the group. Voting does not: each
piece is addressed to the member it belongs to and sent to them alone. That
is the one group envelope in the app which is not the same bytes to
everybody, and a test pins it, because sending one share to the group would
let any member subtract it from the tally and read the vote, with the counts
still coming out right and nothing at runtime to notice.

Kept in the vault, or a poll could never finish: it waits for everybody, and
the pieces gathered would be gone the first time the app closed.

The card says what the maths actually gives. Everyone else would have to work
together to know how one person voted, never that nobody can know. A poll of
two is not anonymous at all, because the other person holds a piece of the
vote and sees the tally, so the word is not offered for a pair and the card
says why.

22 new tests. 647 in the engine and 430 in the client, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-12 10:10:01 -04:00
co-authored by Claude Opus 5
parent 13f55076b9
commit 315e6d328f
10 changed files with 1085 additions and 3 deletions
@@ -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<Long>()) }
/** 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<Session.Line?>(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 ->
@@ -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
@@ -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<io.flamenet.e2e.PollBook.Entry> = 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<io.flamenet.e2e.PollBook.Entry> =
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<String>,
) = 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
@@ -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<Long>,
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))
}
@@ -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>,
): 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)
@@ -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()"))
}
}
@@ -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<Long>) : Standing
/** Everybody voted; not everybody has published their sum yet. */
data class WaitingForSums(val from: List<Long>) : 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<String>,
/**
* 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<Long>,
/** 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<Long, IntArray> = mutableMapOf(),
/** One published sum per member. */
val sumsPublished: MutableMap<Long, IntArray> = 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<String, Entry>()
val polls: List<Entry> get() = entries.values.toList()
fun poll(id: String): Entry? = entries[id]
fun pollsIn(groupId: String): List<Entry> = 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<String>,
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<Pair<Long, IntArray>> {
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<Long, IntArray>): 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<Long, IntArray>,
) {
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<String> =
if (a == null) emptyList() else (0 until a.length()).map { a.optString(it) }
private fun longs(a: JSONArray?): List<Long> =
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<Long>): String = ids.toSortedSet().joinToString(",")
fun members(set: String): List<Long> = set.split(",").mapNotNull { it.trim().toLongOrNull() }
}
}
@@ -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
/**
@@ -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"))
}
}
@@ -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)
}
}