Keep a half-written message across a relaunch

Sealed in the vault, not in SharedPreferences — and that is the whole
point. A draft is plaintext the person has not decided to send, very
often the most sensitive text in the app because it is the thing they
are still weighing. SharedPreferences writes readable XML that survives
into backups; the vault seals it under the conversation's own key with
the record name bound into the AAD, and forgetEverything takes it on
sign-out. There is a test that asserts the bytes on disk do not contain
the text, because the wrong implementation would pass every other test
here.

Keyed by the same threadFor* ids the transcript uses, so a group called
"7", the contact who is user 7, and the address "7" are three
different drafts.

Every write to the composer state goes through one setDraft: send clears
it by assigning directly in three places, and any one of those that
forgot to clear the stored copy would bring the message back next time
as a draft of something already sent.

Closes #47 on iOS side; Android half of the same feature

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 15:50:01 -04:00
co-authored by Claude Opus 4.8
parent 1fc1a561b3
commit f95cb1e6aa
5 changed files with 285 additions and 5 deletions
@@ -88,6 +88,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
private const val REPLY_REMINDER = "reply-reminder"
private const val ROSTER = "roster"
private const val GROUP_NAMES = "group-names"
private const val DRAFTS = "drafts"
private const val GROUP_SECRETS = "group-secrets"
private const val DEVICE_OWNER_PREFIX = "devowner/"
private const val KT_ISSUER_PIN = "kt-issuer-pin"
@@ -551,6 +552,40 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
writeJson(GROUP_NAMES, json)
}
/**
* A half-written message, kept across a relaunch.
*
* 🔴 **In the vault rather than in SharedPreferences, and that is the whole
* point.** A draft is plaintext the person has not decided to send — very
* often the most sensitive text in the app, because it is the thing they
* are still weighing. `SharedPreferences` writes it as readable XML that
* survives into backups; here it is sealed under the same key as the
* conversation, with the record's own name bound into the AAD, and
* [forgetEverything] takes it on sign-out.
*
* ⚠️ Keyed by the same `threadFor*` ids the transcript uses, so a group, a
* local contact and somebody on another relay cannot collide — see the note
* on [threadForAddress] for why that third case is not theoretical.
*/
fun draft(threadId: String): String? =
readJson(DRAFTS)?.optString(threadId)?.takeIf { it.isNotEmpty() }
/**
* ⚠️ Blank is not a draft. Storing " " would restore a composer that
* looks empty and reports itself as having content, which on this screen
* means an enabled send button that sends nothing.
*
* ⚠️ Stored AS TYPED though, not trimmed: somebody who left a trailing
* space was mid-word, and handing back a subtly different string than the
* one they left is the kind of small wrongness that makes an app feel
* untrustworthy.
*/
fun saveDraft(threadId: String, text: String) {
val json = readJson(DRAFTS) ?: JSONObject()
if (text.isBlank()) json.remove(threadId) else json.put(threadId, text)
writeJson(DRAFTS, json)
}
/**
* Record a contact's picture.
*
@@ -0,0 +1,202 @@
package io.flamenet.e2e
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Half-written messages, kept across a relaunch.
*
* 🔴 **The reason this is not a two-line SharedPreferences change** is where a
* draft has to live. It is plaintext the person has not decided to send — very
* often the most sensitive text in the app, because it is the thing they are
* still weighing. `SharedPreferences` writes readable XML that survives into
* backups. In the vault it is sealed under the same key as the conversation,
* with the record's own name bound into the AAD, and `forgetEverything` takes
* it on sign-out.
*/
class DraftsThatSurviveTest {
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun names() = records.keys.toList()
}
private fun key(): SecretKey =
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
// ---------------------------------------------------------------- surviving
@Test
fun `a draft survives being stored and read back`() {
val storage = MapStorage()
val secret = key()
val thread = Vault.threadForUser(7)
Vault(storage, secret).saveDraft(thread, "meet at 6")
assertEquals("meet at 6", Vault(storage, secret).draft(thread))
}
/**
* 🔴 **On disk it must be ciphertext.** This is the assertion the whole
* ticket is about: the same feature built on SharedPreferences would put
* the text in readable XML, and no other test here would notice.
*/
@Test
fun `a draft is not readable on disk`() {
val storage = MapStorage()
Vault(storage, key()).saveDraft(Vault.threadForUser(7), "the thing I have not sent")
val onDisk = storage.records.values.joinToString("") { bytes ->
bytes.joinToString("") { byte -> (byte.toInt() and 0xFF).toChar().toString() }
}
assertFalse(onDisk.contains("the thing I have not sent"),
"the draft was written in the clear")
}
/** ⚠️ Multi-line text and awkward characters survive the round trip. */
@Test
fun `awkward text survives`() {
val storage = MapStorage()
val secret = key()
val awkward = "line one\n\"quoted\"\ttabbed \\ backslash 🙂"
Vault(storage, secret).saveDraft(Vault.threadForUser(1), awkward)
assertEquals(awkward, Vault(storage, secret).draft(Vault.threadForUser(1)))
}
// ---------------------------------------------------------------- forgetting
/**
* 🔴 Clearing the composer has to clear what was stored. A draft that
* outlives the message it became comes back as a draft of something already
* sent, which is worse than not having drafts at all.
*/
@Test
fun `clearing the composer forgets the draft`() {
val storage = MapStorage()
val secret = key()
val thread = Vault.threadForUser(7)
Vault(storage, secret).saveDraft(thread, "meet at 6")
Vault(storage, secret).saveDraft(thread, "")
assertNull(Vault(storage, secret).draft(thread))
}
/**
* ⚠️ Blank is not a draft. Storing " " would restore a composer that
* looks empty and reports itself as having content, which on this screen
* means an enabled send button that sends nothing.
*/
@Test
fun `whitespace is not a draft`() {
val storage = MapStorage()
val secret = key()
val thread = Vault.threadForUser(7)
for (blank in listOf("", " ", " ", "\n", "\t\n ")) {
Vault(storage, secret).saveDraft(thread, "something")
Vault(storage, secret).saveDraft(thread, blank)
assertNull(Vault(storage, secret).draft(thread),
"kept \"$blank\", which would enable send on an empty composer")
}
}
/**
* ⚠️ But stored as typed, not trimmed. Somebody who left a trailing space
* was mid-word, and handing back a subtly different string than the one
* they left is the kind of small wrongness that makes an app feel
* untrustworthy.
*/
@Test
fun `text is kept exactly as it was typed`() {
val storage = MapStorage()
val secret = key()
Vault(storage, secret).saveDraft(Vault.threadForUser(1), "meet at ")
assertEquals("meet at ", Vault(storage, secret).draft(Vault.threadForUser(1)))
}
// ---------------------------------------------------------------- which thread
/**
* 🔴 Three kinds of conversation share one record, so the ids must not
* collide. A group called "7", the person who is user 7, and an address
* "7" are three different threads — and the transcript already names them
* apart for exactly this reason.
*/
@Test
fun `three kinds of conversation cannot share a draft`() {
val storage = MapStorage()
val secret = key()
Vault(storage, secret).saveDraft(Vault.threadForUser(7), "to the contact")
Vault(storage, secret).saveDraft(Vault.threadForGroup("7"), "to the group")
Vault(storage, secret).saveDraft(Vault.threadForAddress("7"), "to the address")
val vault = Vault(storage, secret)
assertEquals("to the contact", vault.draft(Vault.threadForUser(7)))
assertEquals("to the group", vault.draft(Vault.threadForGroup("7")))
assertEquals("to the address", vault.draft(Vault.threadForAddress("7")))
}
@Test
fun `a conversation with no draft has none`() {
assertNull(Vault(MapStorage(), key()).draft(Vault.threadForUser(99)))
}
/** Writing one draft must not disturb another. */
@Test
fun `drafts do not overwrite each other`() {
val storage = MapStorage()
val secret = key()
Vault(storage, secret).saveDraft(Vault.threadForUser(1), "first")
Vault(storage, secret).saveDraft(Vault.threadForUser(2), "second")
Vault(storage, secret).saveDraft(Vault.threadForUser(1), "")
assertNull(Vault(storage, secret).draft(Vault.threadForUser(1)))
assertEquals("second", Vault(storage, secret).draft(Vault.threadForUser(2)))
}
// ---------------------------------------------------------------- sign-out
/**
* 🔴 Signing out must take drafts with it. Leaving the one piece of
* plaintext the person never chose to send behind, after the credential
* that justified it is gone, is exactly the residue the vault exists to
* avoid.
*/
@Test
fun `signing out forgets drafts`() {
val storage = MapStorage()
val secret = key()
Vault(storage, secret).saveDraft(Vault.threadForUser(7), "meet at 6")
Vault(storage, secret).forgetEverything()
assertTrue(storage.records.isEmpty(), "something survived sign-out")
}
/**
* ⚠️ A vault opened with a different key cannot read the draft — the same
* guarantee as everything else in here, asserted because a draft is the
* newest thing in it and the easiest to get wrong.
*/
@Test
fun `another key cannot read a draft`() {
val storage = MapStorage()
Vault(storage, key()).saveDraft(Vault.threadForUser(7), "meet at 6")
val other = Vault(storage, key())
val read = runCatching { other.draft(Vault.threadForUser(7)) }
assertNotEquals("meet at 6", read.getOrNull())
}
}