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,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user