Merge branch 'feat/anonymous-polls' into 'main'

Anonymous polls: the envelopes

See merge request paulhitt/flamenet-messenger-android!132
This commit was merged in pull request #159.
This commit is contained in:
Paul Hitt
2026-09-12 02:23:07 +00:00
2 changed files with 208 additions and 0 deletions
@@ -66,6 +66,18 @@ data class Content(
val pins: String? = null,
/** The sequence this story item belongs to (§6.23), if any. */
val storySet: StorySet? = null,
/** Which poll this envelope belongs to (§6.29), for all three of its kinds. */
val pollId: String? = null,
/** The question, on the poll itself. */
val pollQuestion: String? = null,
/** What may be chosen, in the order the counts come back in. */
val pollOptions: List<String> = emptyList(),
/**
* The member set the arithmetic is agreed over, hashed. Shares split across
* a different set do not add up, and that should be visible rather than
* arriving as a wrong tally.
*/
val pollMemberSet: String? = null,
) {
/**
* A sticker: an id, never an image.
@@ -282,6 +294,24 @@ data class Content(
*/
const val PIN = "pin"
/**
* A poll asked of a group (§6.29): the question, its options, and the
* member set the arithmetic is agreed over.
*/
const val POLL = "poll"
/**
* One voter's piece of their own vote, for one member. Sent to that
* member and to nobody else, which is what makes a vote unattributable.
*/
const val POLL_SHARE = "pshare"
/**
* What one member publishes: the sum of every piece handed to them.
* Adding these gives the counts, and no vote was ever sent to anybody.
*/
const val POLL_SUM = "psum"
/**
* Consumed, never shown.
*/
@@ -315,6 +345,10 @@ data class Content(
CallSignal.OFFER,
CallSignal.ANSWER,
CallSignal.END,
CallSignal.REACTION,
POLL,
POLL_SHARE,
POLL_SUM,
)
}
@@ -527,6 +561,52 @@ data class Content(
about = Reference(messageId, author = 0, preview = ""),
)
/**
* A poll put to a group. [members] is the agreed member list, hashed,
* so both sides can tell they are counting over the same set: shares
* split across a different set would not add up, and the difference
* should be visible rather than arriving as a wrong tally.
*/
fun poll(
pollId: String,
question: String,
options: List<String>,
groupId: String,
members: String,
): String {
require(options.size in 2..Poll.MAX_OPTIONS) {
"a poll offers 2..${Poll.MAX_OPTIONS} options, not ${options.size}"
}
val opts = options.joinToString(",", "[", "]") { quote(it) }
return "{\"fnc\":1,\"kind\":${quote(Kind.POLL)},\"ctl\":1,\"pid\":${quote(pollId)}," +
"\"q\":${quote(question)},\"opts\":$opts,\"mset\":${quote(members)}," +
"\"gid\":${quote(groupId)}}"
}
/** One piece of one vote, addressed to a single member. */
fun pollShare(
pollId: String,
piece: IntArray,
groupId: String,
): String =
"{\"fnc\":1,\"kind\":${quote(Kind.POLL_SHARE)},\"ctl\":1,\"pid\":${quote(pollId)}," +
"\"v\":${piece.joinToString(",", "[", "]")},\"gid\":${quote(groupId)}}"
/** The sum one member publishes, once every piece has reached them. */
fun pollSum(
pollId: String,
sum: IntArray,
groupId: String,
): String =
"{\"fnc\":1,\"kind\":${quote(Kind.POLL_SUM)},\"ctl\":1,\"pid\":${quote(pollId)}," +
"\"v\":${sum.joinToString(",", "[", "]")},\"gid\":${quote(groupId)}}"
/** The integer vector a share or a sum carries, or null if malformed. */
fun pollVector(content: Content): IntArray? {
val array = content.raw?.optJSONArray("v") ?: return null
return IntArray(array.length()) { array.optInt(it) }
}
fun callSignal(signal: CallSignal): String {
val call = JSONObject().put("id", signal.callId)
signal.sdp?.let { call.put("sdp", it) }
@@ -774,6 +854,33 @@ data class Content(
)
}
// §6.29. A poll and its pieces are never bubbles, and they are not
// in CONTROL: that check returns a bare envelope before this point,
// and these carry structure worth reading.
if (kind == Kind.POLL) {
val opts = json.optJSONArray("opts")
return Content(
"",
kind = kind,
raw = json,
groupId = groupId,
pollId = json.optString("pid").takeIf { it.isNotEmpty() },
pollQuestion = json.optString("q"),
pollOptions =
(0 until (opts?.length() ?: 0)).map { opts?.optString(it).orEmpty() },
pollMemberSet = json.optString("mset").takeIf { it.isNotEmpty() },
)
}
if (kind == Kind.POLL_SHARE || kind == Kind.POLL_SUM) {
return Content(
"",
kind = kind,
raw = json,
groupId = groupId,
pollId = json.optString("pid").takeIf { it.isNotEmpty() },
)
}
// Control traffic.
if (kind == Kind.AVATAR) {
val avatar =
@@ -0,0 +1,101 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* The three envelopes a poll rides on (§6.29): the poll itself, one piece of
* one vote, and the sum a member publishes.
*/
class PollEnvelopeTest {
@Test
fun `a poll carries its question, options and member set`() {
val wire =
Content.poll(
pollId = "p-1",
question = "Where are we eating?",
options = listOf("Pizza", "Noodles", "Anywhere warm"),
groupId = "g-1",
members = "abc123",
)
val read = Content.parse(wire)
assertEquals(Content.Kind.POLL, read.kind)
assertEquals("p-1", read.pollId)
assertEquals("Where are we eating?", read.pollQuestion)
assertContentEquals(listOf("Pizza", "Noodles", "Anywhere warm"), read.pollOptions)
assertEquals("abc123", read.pollMemberSet)
assertEquals("g-1", read.groupId)
}
@Test
fun `a piece and a sum carry their vector back intact`() {
val piece = intArrayOf(7, -1_900_000_000, 0, 2_100_000_000)
val share = Content.parse(Content.pollShare("p-1", piece, "g-1"))
assertEquals(Content.Kind.POLL_SHARE, share.kind)
assertEquals("p-1", share.pollId)
assertContentEquals(piece, Content.pollVector(share))
val sum = Content.parse(Content.pollSum("p-1", piece, "g-1"))
assertEquals(Content.Kind.POLL_SUM, sum.kind)
assertContentEquals(piece, Content.pollVector(sum))
}
/** Quoting is the usual way a hand-built envelope breaks. */
@Test
fun `a question with quotes and a newline survives`() {
val awkward = "Say \"yes\"?\nOr not"
val read = Content.parse(Content.poll("p-2", awkward, listOf("a", "b"), "g-1", "m"))
assertEquals(awkward, read.pollQuestion)
}
@Test
fun `none of the three is ever a bubble`() {
assertFalse(Content.parse(Content.poll("p-1", "q", listOf("a", "b"), "g", "m")).isRenderable)
assertFalse(Content.parse(Content.pollShare("p-1", intArrayOf(1), "g")).isRenderable)
assertFalse(Content.parse(Content.pollSum("p-1", intArrayOf(1), "g")).isRenderable)
}
/**
* Known, so an envelope this build understands is not filed as traffic from
* a future one. `CallSignal.REACTION` was in ACTIONS but never here.
*/
@Test
fun `the kinds are ones this build knows`() {
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)
// Went into ACTIONS when call reactions were built and never here, so a
// call reaction was filed as traffic from a future build.
assertTrue(Content.CallSignal.REACTION in Content.Kind.KNOWN)
}
/**
* Marked as control traffic, which is what keeps all three out of the
* thread: an older build consumes them instead of drawing empty bubbles.
*/
@Test
fun `all three say they are control traffic`() {
assertTrue(Content.parse(Content.poll("p", "q", listOf("a", "b"), "g", "m")).isControl)
assertTrue(Content.parse(Content.pollShare("p", intArrayOf(1), "g")).isControl)
assertTrue(Content.parse(Content.pollSum("p", intArrayOf(1), "g")).isControl)
}
@Test
fun `a poll refuses a shape it cannot count`() {
assertFailsWith<IllegalArgumentException> {
Content.poll("p", "q", listOf("only one"), "g", "m")
}
assertFailsWith<IllegalArgumentException> {
Content.poll("p", "q", List(Poll.MAX_OPTIONS + 1) { "opt $it" }, "g", "m")
}
}
@Test
fun `a malformed vector reads as absent rather than as zeroes`() {
assertEquals(null, Content.pollVector(Content.parse("""{"fnc":1,"kind":"psum","pid":"p"}""")))
}
}