Merge branch 'feat/off-the-record' into 'main'

Off-the-record conversations: the mechanism

See merge request paulhitt/flamenet-messenger-android!129
This commit was merged in pull request #156.
This commit is contained in:
Paul Hitt
2026-09-12 01:40:25 +00:00
6 changed files with 243 additions and 6 deletions
@@ -90,8 +90,14 @@ class Session(
val id: String,
)
/**
* Off-the-record messages, held here and nowhere else. They go when the
* process does, which is the feature rather than a defect.
*/
private val ephemeral = mutableMapOf<String, org.json.JSONArray>()
fun history(threadId: String): List<Line> {
val stored = vault.history(threadId)
val stored = ephemeral[threadId] ?: vault.history(threadId)
return (0 until stored.length()).map {
val m = stored.getJSONObject(it)
Line(
@@ -153,8 +159,7 @@ class Session(
*/
forwarded: Boolean = false,
) {
vault.append(
threadId,
val row =
JSONObject()
.put("author", author)
.also { if (messageId != null) it.put("mid", messageId) }
@@ -172,8 +177,16 @@ class Session(
.also { if (link != null) it.put("lnk", link.toJson()) }
// Written only when true, for the same reason `secs` is.
.also { if (forwarded) it.put("fwd", true) }
.also { if (about != null) it.put("re", about.toJson()) },
)
.also { if (about != null) it.put("re", about.toJson()) }
// Off the record: the row is held in memory and nothing reaches disk.
// This is the one place a message is ever written, which is what makes
// a single gate enough.
if (vault.offTheRecord(threadId)) {
ephemeral.getOrPut(threadId) { org.json.JSONArray() }.put(row)
} else {
vault.append(threadId, row)
}
// A reply arriving cancels the reminder, and it is done HERE because
// this is the one place every inbound message passes through.
@@ -181,7 +194,19 @@ class Session(
}
/** Clear a thread. Keeps the session, so the conversation still works. */
fun clearHistory(threadId: String) = vault.forgetHistory(threadId)
fun clearHistory(threadId: String) {
ephemeral.remove(threadId)
vault.forgetHistory(threadId)
}
/** Whether this conversation is off the record. */
fun offTheRecord(peerUserId: Long): Boolean = conversations.offTheRecord(peerUserId)
/** Propose it to the other side; it takes effect when they echo. */
fun proposeOffTheRecord(
peerUserId: Long,
on: Boolean,
) = conversations.proposeOffTheRecord(peerUserId, on)
// -- Scheduled send
// -------------------------------------------------------.
@@ -0,0 +1,88 @@
package io.flamenet.messenger.app
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* A conversation held in memory and written to disk by neither side.
*
* `Session` needs a vault and a relay, so this reads the source the way
* `SecondDeviceCallTest` does.
*/
class OffTheRecordTest {
private fun read(vararg paths: String): String = paths.map(::File).first { it.exists() }.readText()
private val session: String by lazy {
read(
"src/main/kotlin/io/flamenet/messenger/app/Session.kt",
"app/src/main/kotlin/io/flamenet/messenger/app/Session.kt",
)
}
/**
* The whole design rests on there being one way onto disk. If a second
* appears, this fails and whoever added it has to gate it too.
*/
@Test
fun `a message reaches disk through exactly one call`() {
assertEquals(
1,
Regex("vault\\.append\\(").findAll(session).count(),
"more than one write path means the off-the-record gate can be walked around",
)
}
@Test
fun `an off-the-record message is held in memory instead of written`() {
val start = session.indexOf("if (vault.offTheRecord(threadId))")
assertTrue(start >= 0, "record() does not check the setting")
val branch = session.substring(start, minOf(session.length, start + 400))
assertTrue(branch.contains("ephemeral.getOrPut(threadId)"))
assertTrue(branch.contains("vault.append(threadId, row)"), "the ordinary path still writes")
}
@Test
fun `the conversation still reads back while the app is open`() {
assertTrue(session.contains("ephemeral[threadId] ?: vault.history(threadId)"))
}
@Test
fun `clearing a thread drops what was held in memory too`() {
val start = session.indexOf("fun clearHistory(")
assertTrue(start >= 0)
assertTrue(session.substring(start, start + 200).contains("ephemeral.remove(threadId)"))
}
@Test
fun `proposing it saves nothing until the other side answers`() {
val conv =
read(
"../client/src/main/kotlin/io/flamenet/client/Conversations.kt",
"client/src/main/kotlin/io/flamenet/client/Conversations.kt",
)
val start = conv.indexOf("fun proposeOffTheRecord(")
assertTrue(start >= 0, "no proposal path")
val body = conv.substring(start, conv.indexOf("\n /**", start))
assertTrue(body.contains("sendPlaintext(toUserId, Content.offTheRecord(on))"))
assertTrue(
!body.contains("saveOffTheRecord"),
"the proposer must not turn it on before the peer has echoed",
)
}
@Test
fun `an arriving proposal is applied and echoed once`() {
val conv =
read(
"../client/src/main/kotlin/io/flamenet/client/Conversations.kt",
"client/src/main/kotlin/io/flamenet/client/Conversations.kt",
)
val start = conv.indexOf("Content.Kind.OFF_RECORD ->")
assertTrue(start >= 0, "nothing handles it on the way in")
val branch = conv.substring(start, minOf(conv.length, start + 600))
assertTrue(branch.contains("vault.saveOffTheRecord"))
assertTrue(branch.contains("!Content.offTheRecordIsEcho(content)"), "an echo must not be echoed")
}
}