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:
@@ -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))))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user