diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 7620c9d..085dc93 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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() + fun history(threadId: String): List { - 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 // -------------------------------------------------------. diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/OffTheRecordTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/OffTheRecordTest.kt new file mode 100644 index 0000000..e41861f --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/OffTheRecordTest.kt @@ -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") + } +} diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 9ac4c3a..d555d23 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -939,6 +939,23 @@ class Conversations( 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). */ @@ -1455,6 +1472,16 @@ class Conversations( 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 -> { Unit } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index 06cd045..024c3ae 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -261,6 +261,11 @@ data class Content( /** A screenshot notice (§6.27). */ 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). */ @@ -283,6 +288,7 @@ data class Content( DISAPPEAR, SCREENSHOT, BRIDGE, + OFF_RECORD, ) /** @@ -587,6 +593,20 @@ data class Content( // `1`, not `true`. "{\"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). */ @@ -642,6 +662,13 @@ data class Content( /** 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 + /** 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. */ private fun quote(value: String): String = JSONObject.quote(value) diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index f4ca4e2..4481c8b 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -74,6 +74,7 @@ class Vault( private const val STORY_MUTES = "story-mutes" private const val STORY_ARCHIVE = "story-archive" private const val DISAPPEAR = "disappear" + private const val OFF_RECORD = "offrecord" /** Bridge lines contacts have offered, pending review. See [bridgeOffers]. */ private const val BRIDGE_OFFERS = "bridge-offers" @@ -1015,6 +1016,22 @@ class Vault( */ 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). */ diff --git a/engine/src/test/kotlin/io/flamenet/e2e/OffTheRecordEnvelopeTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/OffTheRecordEnvelopeTest.kt new file mode 100644 index 0000000..b93af5b --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/OffTheRecordEnvelopeTest.kt @@ -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)))) + } +}