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, 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> { fun history(threadId: String): List<Line> {
val stored = vault.history(threadId) val stored = ephemeral[threadId] ?: vault.history(threadId)
return (0 until stored.length()).map { return (0 until stored.length()).map {
val m = stored.getJSONObject(it) val m = stored.getJSONObject(it)
Line( Line(
@@ -153,8 +159,7 @@ class Session(
*/ */
forwarded: Boolean = false, forwarded: Boolean = false,
) { ) {
vault.append( val row =
threadId,
JSONObject() JSONObject()
.put("author", author) .put("author", author)
.also { if (messageId != null) it.put("mid", messageId) } .also { if (messageId != null) it.put("mid", messageId) }
@@ -172,8 +177,16 @@ class Session(
.also { if (link != null) it.put("lnk", link.toJson()) } .also { if (link != null) it.put("lnk", link.toJson()) }
// Written only when true, for the same reason `secs` is. // Written only when true, for the same reason `secs` is.
.also { if (forwarded) it.put("fwd", true) } .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 // A reply arriving cancels the reminder, and it is done HERE because
// this is the one place every inbound message passes through. // 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. */ /** 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 // -- 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")
}
}
@@ -939,6 +939,23 @@ class Conversations(
sendPlaintext(toUserId, Content.disappearing(seconds)) sendPlaintext(toUserId, Content.disappearing(seconds))
} }
/**
* Propose that this conversation goes off the record, or comes back on.
*
* Nothing is saved here: the proposer waits for the peer to echo it. A
* build that does not understand the kind never echoes, so the thread stays
* on the record on both sides rather than on one of them.
*/
fun proposeOffTheRecord(
toUserId: Long,
on: Boolean,
) {
sendPlaintext(toUserId, Content.offTheRecord(on))
}
/** Whether this conversation is off the record on this device. */
fun offTheRecord(toUserId: Long): Boolean = vault.offTheRecord(Vault.threadForUser(toUserId))
/** /**
* Offer a contact the bridge this phone uses (§6.19). * Offer a contact the bridge this phone uses (§6.19).
*/ */
@@ -1455,6 +1472,16 @@ class Conversations(
vault.saveDisappearSeconds(Vault.threadForUser(fromUser), seconds) vault.saveDisappearSeconds(Vault.threadForUser(fromUser), seconds)
} }
Content.Kind.OFF_RECORD -> {
val on = Content.offTheRecord(content) ?: return
vault.saveOffTheRecord(Vault.threadForUser(fromUser), on)
// Answer a proposal so the other side learns this build kept
// its side of it. An echo is not echoed back.
if (!Content.offTheRecordIsEcho(content)) {
runCatching { sendPlaintext(fromUser, Content.offTheRecord(on, echo = true)) }
}
}
else -> { else -> {
Unit Unit
} }
@@ -267,6 +267,11 @@ data class Content(
/** A screenshot notice (§6.27). */ /** A screenshot notice (§6.27). */
const val SCREENSHOT = "shot" const val SCREENSHOT = "shot"
/**
* A conversation held in memory and written to disk by neither side.
*/
const val OFF_RECORD = "otr"
/** /**
* A replacement for the text of an earlier message (§6.15). * A replacement for the text of an earlier message (§6.15).
*/ */
@@ -289,6 +294,7 @@ data class Content(
DISAPPEAR, DISAPPEAR,
SCREENSHOT, SCREENSHOT,
BRIDGE, BRIDGE,
OFF_RECORD,
) )
/** /**
@@ -594,6 +600,20 @@ data class Content(
// `1`, not `true`. // `1`, not `true`.
"{\"fnc\":1,\"kind\":${quote(Kind.DISAPPEAR)},\"ctl\":1,\"secs\":$seconds}" "{\"fnc\":1,\"kind\":${quote(Kind.DISAPPEAR)},\"ctl\":1,\"secs\":$seconds}"
/**
* Turn a conversation off the record, or back on.
*
* Sent twice: the proposal, and the peer's [echo] of it. A build that
* does not know this kind never echoes, which is how the proposer finds
* out that the other side would have kept writing to disk.
*/
fun offTheRecord(
on: Boolean,
echo: Boolean = false,
): String =
"{\"fnc\":1,\"kind\":${quote(Kind.OFF_RECORD)},\"ctl\":1,\"otr\":${if (on) 1 else 0}" +
(if (echo) ",\"echo\":1" else "") + "}"
/** /**
* Tell somebody their disappearing message was screenshotted (§6.27). * Tell somebody their disappearing message was screenshotted (§6.27).
*/ */
@@ -649,6 +669,13 @@ data class Content(
/** The timer a [Kind.DISAPPEAR] envelope carries, or null if it is not one. */ /** The timer a [Kind.DISAPPEAR] envelope carries, or null if it is not one. */
fun disappearSeconds(content: Content): Int? = if (content.kind == Kind.DISAPPEAR) content.raw?.optInt("secs", 0) else null fun disappearSeconds(content: Content): Int? = if (content.kind == Kind.DISAPPEAR) content.raw?.optInt("secs", 0) else null
/** Whether an [Kind.OFF_RECORD] envelope turns it on, or null if it is not one. */
fun offTheRecord(content: Content): Boolean? =
if (content.kind == Kind.OFF_RECORD) (content.raw?.optInt("otr", 0) ?: 0) != 0 else null
/** True when this is the peer's echo rather than a fresh proposal. */
fun offTheRecordIsEcho(content: Content): Boolean = (content.raw?.optInt("echo", 0) ?: 0) != 0
/** JSON string escaping, via the library, so control characters survive. */ /** JSON string escaping, via the library, so control characters survive. */
private fun quote(value: String): String = JSONObject.quote(value) private fun quote(value: String): String = JSONObject.quote(value)
@@ -74,6 +74,7 @@ class Vault(
private const val STORY_MUTES = "story-mutes" private const val STORY_MUTES = "story-mutes"
private const val STORY_ARCHIVE = "story-archive" private const val STORY_ARCHIVE = "story-archive"
private const val DISAPPEAR = "disappear" private const val DISAPPEAR = "disappear"
private const val OFF_RECORD = "offrecord"
/** Bridge lines contacts have offered, pending review. See [bridgeOffers]. */ /** Bridge lines contacts have offered, pending review. See [bridgeOffers]. */
private const val BRIDGE_OFFERS = "bridge-offers" private const val BRIDGE_OFFERS = "bridge-offers"
@@ -1015,6 +1016,22 @@ class Vault(
*/ */
fun disappearSeconds(threadId: String): Int = readJson(DISAPPEAR)?.optInt(threadId, 0) ?: 0 fun disappearSeconds(threadId: String): Int = readJson(DISAPPEAR)?.optInt(threadId, 0) ?: 0
/** Whether this conversation is off the record (§6.28). */
fun offTheRecord(threadId: String): Boolean = readJson(OFF_RECORD)?.optBoolean(threadId, false) ?: false
/**
* Remember that a conversation is off the record. The setting is kept, and
* only the setting: the messages themselves never reach disk at all.
*/
fun saveOffTheRecord(
threadId: String,
on: Boolean,
) {
val all = readJson(OFF_RECORD) ?: JSONObject()
if (on) all.put(threadId, true) else all.remove(threadId)
writeJson(OFF_RECORD, all)
}
/** /**
* A bridge line a contact offered, and who offered it (§6.19). * A bridge line a contact offered, and who offered it (§6.19).
*/ */
@@ -0,0 +1,53 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Turning a conversation off the record.
*
* The envelope is a proposal, not an instruction: it takes effect when the
* other side echoes it, because a build that does not know the kind would go on
* writing to disk while this one believed it had stopped.
*/
class OffTheRecordEnvelopeTest {
@Test
fun `a proposal says so, and is not itself an echo`() {
val wire = Content.offTheRecord(true)
assertTrue(wire.contains("\"otr\":1"))
assertTrue(wire.contains("\"ctl\":1"))
assertFalse(wire.contains("echo"))
val read = Content.parse(wire)
assertEquals(true, Content.offTheRecord(read))
assertFalse(Content.offTheRecordIsEcho(read))
}
@Test
fun `an echo is marked, so it is not answered with another`() {
val read = Content.parse(Content.offTheRecord(true, echo = true))
assertEquals(true, Content.offTheRecord(read))
assertTrue(Content.offTheRecordIsEcho(read))
}
@Test
fun `turning it back on is the same envelope carrying a zero`() {
val read = Content.parse(Content.offTheRecord(false))
assertEquals(false, Content.offTheRecord(read))
}
/** Control traffic: consumed, and never drawn as a message. */
@Test
fun `it is control traffic and never renderable`() {
assertTrue(Content.Kind.OFF_RECORD in Content.Kind.CONTROL)
assertTrue(Content.Kind.OFF_RECORD in Content.Kind.KNOWN)
assertFalse(Content.parse(Content.offTheRecord(true)).isRenderable)
}
@Test
fun `another kind is not mistaken for one`() {
assertNull(Content.offTheRecord(Content.parse(Content.disappearing(60))))
}
}