Android: contacts, conversations, and a message that made the round trip
The app now holds a conversation. A message typed into it reaches a different
program through the real relay, that program answers, and the answer appears in
the thread -- verified three times, end to end, encrypted the whole way.
96 JVM tests, 37 cross-engine, 11 on-device, plus live round trips against the
throwaway fleet.
What landed: a fan-out send that reaches every device a person has, inbox
polling, message history encrypted at rest, contacts, presence, and a session
that survives a restart. Plus `:interop:peer` -- a Flamenet account you can
drive from a shell, so the correspondent on the other end is not this app
talking to itself.
Three wire shapes were wrong, and only a real relay could say so:
- `prekeys`, not `opks`. The relay answered 200 and stored nothing, so the
device was published and could not be started with.
- The send body names the recipient once at the top, not per envelope.
- The buddy list is `id`/`presence`/`screen_name`, not `user_id`/`status`.
The second and third at least failed loudly. The first did not, which is why
the live tests exist.
Decisions worth stating:
A send returns partial success rather than throwing. One of someone's three
devices being unreachable must not stop the message reaching the other two, and
the UI says "delivered to 2 of 3" rather than "sent".
The inbox watermark advances per message, after it is decrypted AND stored --
never once for the batch, which would lose everything after a crash. An
undecryptable message advances it too: it is never going to decrypt, and
leaving it would re-fail on every poll for ever.
A sent line is recorded only once a device took it. Writing it first produces a
thread showing messages the recipient never got, which is worse than an error --
the sender believes they said something.
Opening a thread marks it read locally and tells the relay nothing. A read
receipt is a disclosure, and this product does not make one by default.
History is keyed by person, not device. Someone's phone and tablet are one
conversation, and filing by device would split it into threads each holding
half the story.
Also improves how instrumented failures report: the assertion now prints what
the screen was showing. That turned a bare 90-second timeout into "No value for
user_id" and then "Failed to connect to relay1" -- one a bug, one the emulator's
network, and previously indistinguishable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5f983c1455
commit
d30a77dd1e
@@ -44,7 +44,9 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
|
||||
private const val IDENTITY = "identity"
|
||||
private const val WATERMARK = "watermark"
|
||||
private const val CREDENTIAL = "credential"
|
||||
private const val SESSION_PREFIX = "session/"
|
||||
private const val HISTORY_PREFIX = "history/"
|
||||
|
||||
private val random = SecureRandom()
|
||||
}
|
||||
@@ -97,6 +99,44 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
|
||||
fun hasIdentity(): Boolean = storage.read(IDENTITY) != null
|
||||
|
||||
// -- The account credential ----------------------------------------------
|
||||
|
||||
/**
|
||||
* Who we are signed in as, so a restart does not mean signing in again.
|
||||
*
|
||||
* ⚠️ **This is the long-lived credential, not the password.** The relay
|
||||
* mints it, returns it once and keeps only its hash, so this is the only
|
||||
* copy — losing it means signing in again, and leaking it means someone
|
||||
* else is signed in as this account until it is revoked. It lives here
|
||||
* rather than in shared preferences for exactly that reason.
|
||||
*
|
||||
* ⚠️ The relay address is stored with it and must be. The same credential
|
||||
* against a different relay is meaningless, and a client that remembered
|
||||
* the credential but not where it came from would present it to whatever
|
||||
* server happened to be configured next.
|
||||
*/
|
||||
data class Credential(
|
||||
val relayBaseUrl: String,
|
||||
val username: String,
|
||||
val appPassword: String,
|
||||
val userId: Long,
|
||||
)
|
||||
|
||||
fun loadCredential(): Credential? = readJson(CREDENTIAL)?.let {
|
||||
Credential(
|
||||
relayBaseUrl = it.getString("relay"),
|
||||
username = it.getString("username"),
|
||||
appPassword = it.getString("appPassword"),
|
||||
userId = it.getLong("userId"),
|
||||
)
|
||||
}
|
||||
|
||||
fun saveCredential(credential: Credential) = writeJson(CREDENTIAL, JSONObject()
|
||||
.put("relay", credential.relayBaseUrl)
|
||||
.put("username", credential.username)
|
||||
.put("appPassword", credential.appPassword)
|
||||
.put("userId", credential.userId))
|
||||
|
||||
// -- Sessions ------------------------------------------------------------
|
||||
|
||||
private fun sessionName(peerDeviceId: String) = SESSION_PREFIX + peerDeviceId
|
||||
@@ -123,11 +163,52 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
get() = readJson(WATERMARK)?.optLong("id", 0) ?: 0
|
||||
set(value) = writeJson(WATERMARK, JSONObject().put("id", value))
|
||||
|
||||
// -- Message history -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Messages, encrypted at rest, keyed by the person rather than the device.
|
||||
*
|
||||
* ⚠️ **Per person, not per device.** A conversation is with someone, and
|
||||
* their phone and tablet are the same conversation — filing history by
|
||||
* device would show the same person as several threads that each hold half
|
||||
* the story.
|
||||
*
|
||||
* ⚠️ The whole thread is rewritten on every append. That is fine for a
|
||||
* chat and would not be for a log; if a thread ever grows large enough for
|
||||
* this to matter, the fix is an append-only record format, not a bigger
|
||||
* buffer.
|
||||
*/
|
||||
fun history(peerUserId: Long): org.json.JSONArray =
|
||||
readJson(historyName(peerUserId))?.optJSONArray("messages") ?: org.json.JSONArray()
|
||||
|
||||
fun append(peerUserId: Long, message: JSONObject) {
|
||||
val existing = history(peerUserId)
|
||||
existing.put(message)
|
||||
writeJson(historyName(peerUserId), JSONObject().put("messages", existing))
|
||||
}
|
||||
|
||||
/** Everyone this device has a thread with, whether or not a session survives. */
|
||||
fun conversationPartners(): List<Long> =
|
||||
storage.names().filter { it.startsWith(HISTORY_PREFIX) }
|
||||
.mapNotNull { it.removePrefix(HISTORY_PREFIX).toLongOrNull() }
|
||||
|
||||
private fun historyName(peerUserId: Long) = HISTORY_PREFIX + peerUserId
|
||||
|
||||
// -- Forgetting ----------------------------------------------------------
|
||||
|
||||
/** Forget one conversation. The identity and every other session survive. */
|
||||
/** Forget one device's session. The identity and every other session survive. */
|
||||
fun forget(peerDeviceId: String) = storage.delete(sessionName(peerDeviceId))
|
||||
|
||||
/**
|
||||
* Delete a thread's history.
|
||||
*
|
||||
* ⚠️ Deliberately does NOT delete the session. Clearing what was said is
|
||||
* not the same as ending the conversation, and dropping the ratchet would
|
||||
* make the next message from that person undecryptable — a "clear chat"
|
||||
* that quietly breaks the chat.
|
||||
*/
|
||||
fun forgetHistory(peerUserId: Long) = storage.delete(historyName(peerUserId))
|
||||
|
||||
/**
|
||||
* Forget everything, identity included.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user