From c9de1f26b846763aa09f0d98cb75cf27f1fa2c31 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Thu, 27 Aug 2026 15:58:58 -0400 Subject: [PATCH] Profile pictures, end to end, and the chat bar's second line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔴 **An avatar is an attachment, not a field on the account.** The obvious design — upload a JPEG and let the relay serve one per user — hands the server everybody's face, on a product whose whole claim is that it cannot see what you send. This rides the same encrypted blob path as a photo message: the relay stores ciphertext, and the key travels to each contact inside the ratchet exactly as the delivery key already does. ⚠️ **The cost, stated on the screen rather than discovered.** It is per contact. Somebody who has never messaged you cannot see your picture, and changing it means telling every contact again. Sealed sender already makes this bargain; a different answer for avatars would mean two answers to the same question. The pieces: a new `avatar` control kind, which is parsed WITH its attachment ref rather than falling through the CONTROL early-return — otherwise a profile picture arrives in the conversation as though somebody had sent you their face as a photo. Encrypted at rest in the vault like everything else, because a face on disk in the clear is the one thing a stolen phone should not hand over about your contacts. Sharing is keyed on the blob id, not a boolean: a flag would say "told" for ever and a new picture would never reach anyone who had the old one. ⚠️ Contacts are told on OPENING a conversation, not when the picture is chosen. Somebody added afterwards would otherwise never be told, would see initials for ever, and nothing on any screen would explain why. ⚠️ Partial delivery is reported. "Sent, but 2 of 5 could not be told" is the truth when two contacts have a changed key, and silence would let somebody believe it had gone out. Also: the chat bar gained the kit's second line — a presence dot and a 12sp fact, or a member count for a group, which shows no dot because a group has many presences and one dot cannot speak for them. And the reassuring identity states dropped to 12sp grey; they were as loud as the title bar, which is the wrong weight for "nothing has happened". The CHANGED state is untouched and stays loud. Co-Authored-By: Claude Opus 5 --- .../io/flamenet/messenger/app/ChatScreens.kt | 136 ++++++++++++++++-- .../flamenet/messenger/app/ContactDetail.kt | 21 ++- .../messenger/app/MessengerViewModel.kt | 73 ++++++++++ .../io/flamenet/messenger/app/Session.kt | 14 ++ .../flamenet/messenger/app/ScreenshotTest.kt | 27 ++++ .../io/flamenet/client/Conversations.kt | 76 ++++++++++ .../main/kotlin/io/flamenet/e2e/Content.kt | 34 ++++- .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 58 ++++++++ 8 files changed, 419 insertions(+), 20 deletions(-) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 8e80e57..b501717 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -225,7 +226,12 @@ fun ColumnScope.BuddyList( // the design. Initials rather than a photo, because the relay // stores no avatars and inventing a placeholder face would be // worse than the letters. - Avatar(name = name, online = buddy.presence.equals("online", ignoreCase = true)) + Avatar( + name = name, + online = buddy.presence.equals("online", ignoreCase = true), + picture = phase.avatars[buddy.userId], + model = model, + ) Column(modifier = Modifier.weight(1f)) { Text( name, @@ -618,6 +624,9 @@ fun ColumnScope.AccountScreen( Spacer(Modifier.height(Space.md)) + SettingsSection { ProfilePicture(model, phase) } + + SectionBand() SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay) } SectionBand() @@ -746,6 +755,78 @@ fun ColumnScope.AccountScreen( } +/** + * Your profile picture: show it, change it, remove it. + * + * 🔴 **It is sent to contacts, not published.** The relay stores an encrypted + * blob it cannot open, and the key reaches each contact inside the ratchet — + * so somebody who has never messaged you cannot see your face, and changing it + * means telling every contact again. That trade is stated on the screen rather + * than left for somebody to discover when a new contact sees initials. + */ +@Composable +private fun ColumnScope.ProfilePicture( + model: MessengerViewModel, + phase: MessengerViewModel.Phase.SignedIn, +) { + val context = LocalContext.current + val pick = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + uri ?: return@rememberLauncherForActivityResult + val bytes = runCatching { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + }.getOrNull() ?: return@rememberLauncherForActivityResult + val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true } + android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + model.setAvatar( + bytes, + context.contentResolver.getType(uri) ?: "image/jpeg", + bounds.outWidth, + bounds.outHeight, + ) + } + + Text("Profile picture", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(Space.md)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Kit.gutter), + ) { + Avatar( + name = phase.handle, + online = false, + picture = phase.myAvatar, + model = model, + size = 80.dp, + ) + Column(modifier = Modifier.weight(1f)) { + KitChip( + text = if (phase.myAvatar == null) "Choose a picture" else "Change", + selected = false, + onClick = { pick.launch("image/*") }, + tag = "set-avatar", + ) + if (phase.myAvatar != null) { + Spacer(Modifier.height(Space.sm)) + KitChip( + text = "Remove", + selected = false, + onClick = model::clearAvatar, + tag = "clear-avatar", + ) + } + } + } + Spacer(Modifier.height(Space.md)) + Text( + "Sent to your contacts encrypted, the same way a photo is — this server " + + "stores it and cannot open it. People who have never messaged you see " + + "your initials instead.", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) +} + @Composable private fun SettingsSection(content: @Composable ColumnScope.() -> Unit) { Surface( @@ -822,7 +903,14 @@ private fun SettingsRow( * rather than complete. */ @Composable -private fun Avatar(name: String, online: Boolean) { +internal fun Avatar( + name: String, + online: Boolean, + /** Their picture, if they have sent us one. Initials stand in when not. */ + picture: io.flamenet.e2e.Attachment.Ref? = null, + model: MessengerViewModel? = null, + size: androidx.compose.ui.unit.Dp = Kit.avatar, +) { val initials = remember(name) { name.trim().split(" ").filter { it.isNotBlank() } .take(2).mapNotNull { it.firstOrNull() } @@ -831,17 +919,25 @@ private fun Avatar(name: String, online: Boolean) { Box(contentAlignment = Alignment.Center) { Box( modifier = Modifier - .size(Kit.avatar) + .size(size) .clip(CircleShape) .background(Palette.AvatarFill), contentAlignment = Alignment.Center, ) { + // ⚠️ Initials are not a placeholder to be replaced when the bytes + // arrive — they are what most people will have, because a picture + // only exists once somebody has sent one and only reaches contacts + // they have messaged. They are the normal case, not the fallback. + if (picture != null && model != null) { + AttachedImage(picture, model) + } else { Text( initials, - fontSize = 16.sp, + fontSize = (size.value * 0.33f).sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary, ) + } } if (online) { Box( @@ -980,14 +1076,22 @@ fun ColumnScope.ChatScreen( fontSize = 16.sp, fontWeight = FontWeight.Medium, ) - // ⚠️ One subtitle slot, whichever fact applies. A group says how - // many people are in it; a one-to-one has nothing to say here yet. - chat.group?.let { - Text( - "${it.memberIds.size} members", - fontSize = 12.sp, - color = Palette.SecondaryText, - ) + // The kit's second line: a presence dot, then a 12sp fact. + // ⚠️ A group says how many people are in it and shows no dot — a + // group has many presences and one dot cannot speak for them. + val presence = chat.peerId?.let { id -> + buddies.firstOrNull { it.userId == id }?.presence + } + val subtitle = chat.group?.let { "${it.memberIds.size} members" } + ?: presence?.takeIf { it.isNotBlank() } + subtitle?.let { line -> + Row(verticalAlignment = Alignment.CenterVertically) { + if (chat.group == null && presence.equals("online", true)) { + Box(Modifier.size(8.dp).clip(CircleShape).background(Palette.Teal)) + Spacer(Modifier.width(6.dp)) + } + Text(line, fontSize = 12.sp, color = Palette.SecondaryText) + } } } // Calling is one-to-one. A group call is a different feature with @@ -1308,13 +1412,19 @@ private fun ColumnScope.IdentityPanel( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { + // ⚠️ **Quiet, but never absent.** The reassuring states are 12sp + // grey: they were as loud as the conversation's own title bar, + // which is the wrong weight for "nothing has happened". The + // CHANGED state above is untouched and stays loud — it is the one + // signal this whole mechanism exists to produce. Text( when (chat.trust) { IdentityTrust.VERIFIED -> "Verified" IdentityTrust.PINNED -> "Keys unchanged since you met" else -> "No keys seen yet" }, - style = MaterialTheme.typography.bodySmall, + fontSize = 12.sp, + color = Palette.SecondaryText, modifier = Modifier.weight(1f).testTag("trust-${chat.trust.name.lowercase()}"), ) if (chat.safetyNumber != null) { diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt index 4db523a..23f4262 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt @@ -93,12 +93,21 @@ fun ColumnScope.ContactDetail( .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), contentAlignment = Alignment.Center, ) { - Text( - initials, - fontSize = 64.sp, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) + // Their picture if they have sent us one; their initials if + // not. ⚠️ Initials are the normal case, not a placeholder — a + // picture only exists once somebody has set one AND messaged + // you, so most covers will be letters and that is fine. + val picture = phase.avatars[buddy.userId] + if (picture != null) { + AttachedImage(picture, model) + } else { + Text( + initials, + fontSize = 64.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } } // Name plate: 60 tall, radius 16, on the kit's neutral fill. diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index b909a02..3ea4cf8 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -87,6 +87,17 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val groups: List = emptyList(), val blocked: Set = emptySet(), val notifications: List = emptyList(), + + /** + * Profile pictures we hold, by user id, plus our own. + * + * 🔴 **References, not bytes.** An avatar is an encrypted blob on + * the relay; these say where it is and how to open it. The bytes + * go through the same cache every other attachment uses, so a + * contact list does not hold a decrypted face per row. + */ + val avatars: Map = emptyMap(), + val myAvatar: io.flamenet.e2e.Attachment.Ref? = null, /** Live stories, newest first (§6.6). */ val stories: List = emptyList(), /** The story being viewed full-screen, if any. */ @@ -275,6 +286,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { status = "Signed in on this device.", buddies = fetchedBuddies.getOrDefault(emptyList()), buddiesLoaded = fetchedBuddies.isSuccess, + avatars = session?.avatars( + fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId } + ) ?: emptyMap(), + myAvatar = session?.myAvatar(), groups = fetchedGroups.getOrDefault(emptyList()), groupsLoaded = fetchedGroups.isSuccess, ) @@ -422,6 +437,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { status = "Registered with ${preKeys.size} one-time prekeys published.", buddies = fetchedBuddies.getOrDefault(emptyList()), buddiesLoaded = fetchedBuddies.isSuccess, + avatars = session?.avatars( + fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId } + ) ?: emptyMap(), + myAvatar = session?.myAvatar(), groups = fetchedGroups.getOrDefault(emptyList()), groupsLoaded = fetchedGroups.isSuccess, ) @@ -472,6 +491,21 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { fun openChat(peerId: Long, peerName: String) { val current = session ?: return val thread = Vault.threadForUser(peerId) + + // 🔴 **Hand them our picture here, not when it was chosen.** An avatar + // reaches a contact inside the ratchet, so somebody added AFTER it was + // set would never have been told — they would see initials for ever + // and there would be nothing on any screen to explain why. Opening a + // conversation is the first moment there is certainly a session to + // send down. + // + // ⚠️ Off the main thread and failure-tolerant: this is a courtesy, and + // it must never be the reason a chat does not open. + viewModelScope.launch { + withContext(Dispatchers.IO) { + runCatching { current.shareAvatarIfNeeded(peerId) } + } + } update { it.copy( openChat = Phase.Chat( @@ -783,6 +817,45 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { // -- Attachments --------------------------------------------------------- + /** + * Set a profile picture and tell every contact. + * + * ⚠️ Reports partial delivery rather than swallowing it. "Sent to 3 of 5" + * is the truth when two contacts have a changed identity key, and silence + * would let somebody believe a picture had gone out when it had not. + */ + fun setAvatar(bytes: ByteArray, mime: String, width: Int, height: Int) = + viewModelScope.launch { + val current = session ?: return@launch + val ids = signedIn()?.buddies?.map { it.userId } ?: emptyList() + update { it.copy(busy = true, error = null) } + val done = withContext(Dispatchers.IO) { + runCatching { current.setAvatar(bytes, mime, width, height, ids) } + } + done.fold( + onSuccess = { posted -> + update { + it.copy( + busy = false, + myAvatar = posted.ref, + error = if (posted.missed > 0) { + "Picture saved, but ${posted.missed} of ${ids.size} contacts " + + "could not be told — check their keys." + } else null, + ) + } + }, + onFailure = { failure -> update { it.copy(busy = false, error = describe(failure)) } }, + ) + } + + /** Drop our picture. Contacts keep the last one they were told about. */ + fun clearAvatar() = viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { runCatching { current.vault.saveMyAvatar(null) } } + update { it.copy(myAvatar = null) } + } + fun sendImage(bytes: ByteArray, mime: String, width: Int, height: Int) = viewModelScope.launch { val current = session ?: return@launch 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 2a75bf8..7cae7ca 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -248,6 +248,20 @@ class Session( * Recorded locally from the reference we just built, so the author's own * strip shows it without fetching their own image back from the relay. */ + /** Publish a profile picture and tell every contact where it is. */ + fun setAvatar(bytes: ByteArray, mime: String, width: Int, height: Int, toUserIds: List) = + conversations.setAvatar(bytes, mime, width, height, toUserIds) + + fun myAvatar() = conversations.myAvatar() + + fun avatarFor(userId: Long) = conversations.avatarFor(userId) + + /** Every picture we hold, for the UI to render without asking one by one. */ + fun avatars(userIds: List): Map = + userIds.mapNotNull { id -> conversations.avatarFor(id)?.let { id to it } }.toMap() + + fun shareAvatarIfNeeded(toUserId: Long) = conversations.shareAvatarIfNeeded(toUserId) + fun postStory( bytes: ByteArray, mime: String, diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt index 23b42cf..57a32d9 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt @@ -104,6 +104,33 @@ class ScreenshotTest { androidx.compose.foundation.layout.Column { NotificationsScreen(model, phase) } } + /** + * A conversation. The one screen with no render until now — it was checked + * on the emulator before the palette changed, and not since. + */ + @Test fun chatScreen() = shootFullScreen("screen-chat") { model, phase -> + androidx.compose.foundation.layout.Column { + ChatScreen(model, sampleChat(), error = null, buddies = phase.buddies) + } + } + + private fun sampleChat() = MessengerViewModel.Phase.Chat( + threadId = "t1", + title = "Hoang Nguyen", + peerId = 33, + trust = io.flamenet.client.IdentityTrust.PINNED, + safetyNumber = "12345 67890 12345 67890 12345 67890", + lines = listOf( + Session.Line("sup", mine = true, at = 1_756_300_200_000), + Session.Line("Notta", mine = false, at = 1_756_300_260_000), + Session.Line( + "A longer one, so the corner that points at its author is " + + "visible on a bubble that actually wraps.", + mine = false, at = 1_756_300_320_000), + Session.Line("And the same on this side.", mine = true, at = 1_756_300_380_000), + ), + ) + /** One contact, the kit's way: cover, name plate, floating action rail. */ @Test fun contactDetail() = shootFullScreen("screen-contact") { model, phase -> androidx.compose.foundation.layout.Column { diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 78b1709..361e7bb 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -236,6 +236,75 @@ class Conversations( data class StoryPosted(val ref: Attachment.Ref, val reached: Int, val missed: Int) + /** + * Publish a profile picture: upload it encrypted, then tell each contact + * where it is and how to open it. + * + * 🔴 **The relay never sees the face.** It stores the same opaque blob it + * stores for a photo message; the key travels inside the ratchet, exactly + * as the delivery key already does. The obvious alternative — a JPEG the + * server serves per user — would hand it everybody's face on a product + * whose claim is that it cannot see what you send. + * + * ⚠️ **Per contact, and that is the trade.** Somebody who has never + * messaged you cannot see your picture, and changing it means telling + * every contact again. Sealed sender already makes this bargain; making a + * different one for avatars would mean two answers to the same question. + * + * ⚠️ Saved locally BEFORE the fan-out, so a picture that reached nobody is + * still yours and is still re-sent to the next contact you talk to. + */ + fun setAvatar( + data: ByteArray, + mime: String, + width: Int, + height: Int, + toUserIds: List, + ): AvatarPosted { + val sealed = Attachment.seal(data) + val id = relay.uploadAttachment(sealed.blob) + val ref = Attachment.Ref( + kind = Attachment.IMAGE, id = id, key = sealed.key, digest = sealed.digest, + bytes = sealed.blob.size, mime = mime, width = width, height = height, + ) + vault.saveMyAvatar(ref) + + val body = Content.avatar(ref) + var reached = 0 + var missed = 0 + for (userId in toUserIds) { + // ⚠️ One failing contact must not stop the rest. A key that + // changed under one person is a real refusal and it is theirs, not + // a reason nobody else gets the picture. + val ok = runCatching { sendPlaintext(userId, body).reachedSomeone }.getOrDefault(false) + if (ok) reached++ else missed++ + } + return AvatarPosted(ref, reached, missed) + } + + data class AvatarPosted(val ref: Attachment.Ref, val reached: Int, val missed: Int) + + /** Our own picture, or null. */ + fun myAvatar(): Attachment.Ref? = vault.myAvatar() + + /** A contact's picture, or null if they have not sent one. */ + fun avatarFor(userId: Long): Attachment.Ref? = vault.avatarFor(userId) + + /** + * Send our picture to one contact, if we have one and they have not been + * told about this version. + * + * ⚠️ Called on first contact rather than on every message: the reference is + * a few hundred bytes, but sending it with every line would put a control + * envelope in front of every word anybody says. + */ + fun shareAvatarIfNeeded(toUserId: Long) { + val ref = vault.myAvatar() ?: return + if (vault.avatarSharedWith(toUserId) == ref.id) return + runCatching { sendPlaintext(toUserId, Content.avatar(ref)) } + .onSuccess { vault.markAvatarShared(toUserId, ref.id) } + } + /** * Send one call signal (§8.10). * @@ -512,6 +581,13 @@ class Conversations( vault.saveDelivery( delivery.copy(peers = delivery.peers + (fromUser to Wire.unb64(key)))) } + Content.Kind.AVATAR -> { + // ⚠️ Consumed, never rendered. Without this branch an avatar + // would fall through as an ordinary image and appear in the + // conversation as though they had sent you their face as a + // photo message. + vault.saveAvatar(fromUser, content.attachment) + } Content.Kind.KEY_LOG_HEAD -> { val raw = content.raw ?: return trust.gossip(raw.optInt("size"), raw.optString("root")) diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index 39cd9cd..fb0eefa 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -116,12 +116,30 @@ data class Content( /** A copy of a message another of OUR devices sent (§17.2). */ const val SENT_COPY = "sent" + /** + * The sender's profile picture, as an encrypted blob reference. + * + * 🔴 **An avatar is an attachment, not a field on the account.** The + * obvious design — upload a JPEG to the relay and let it serve one per + * user — hands the server everybody's face, on a product whose whole + * claim is that it cannot see what you send. This rides the same + * encrypted blob path as a photo message: the relay stores ciphertext + * and the key travels to your contacts inside the ratchet, exactly as + * the delivery key already does. + * + * ⚠️ The cost, stated: it is per-contact. Somebody who has never + * messaged you cannot see your picture, and changing it means telling + * each contact again. That is the same trade sealed sender already + * makes, and it is the honest one for this product. + */ + const val AVATAR = "avatar" + /** * ⚠️ Consumed, never shown. Each of these is a real message the web * client sends today, and rendering one puts raw JSON in somebody's * chat. */ - val CONTROL = setOf(DELIVERY_KEY, KEY_LOG_HEAD, SENT_COPY) + val CONTROL = setOf(DELIVERY_KEY, KEY_LOG_HEAD, SENT_COPY, AVATAR) } companion object { @@ -199,6 +217,10 @@ data class Content( return parts.joinToString(",", prefix = "{", postfix = "}") } + /** A profile-picture reference, as control traffic. */ + fun avatar(ref: Attachment.Ref): String = + assemble(Kind.AVATAR, attachment = ref) + /** JSON string escaping, via the library, so control characters survive. */ private fun quote(value: String): String = JSONObject.quote(value) @@ -232,6 +254,16 @@ data class Content( // Control traffic. Returned with its envelope so the caller can act // on it, and never renderable. + // + // ⚠️ An avatar is control traffic that CARRIES an attachment, so + // its ref is parsed here rather than falling through to the + // renderable path below — which would put a profile picture in the + // conversation as though somebody had sent it as a photo. + if (kind == Kind.AVATAR) { + val avatar = json.optJSONObject("att") + ?.let { Attachment.Ref.fromJson(Attachment.IMAGE, it, 0.0) } + return Content("", kind = kind, raw = json, attachment = avatar) + } if (kind in Kind.CONTROL) return Content("", kind = kind, raw = json) if (kind in CallSignal.ACTIONS) { diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 2f7bab0..826ff7b 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -56,6 +56,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) { private const val KEYLOG_PIN = "keylog-pin" private const val DELIVERY_KEY = "delivery-key" private const val STORIES = "stories" + private const val AVATARS = "avatars" + private const val MY_AVATAR = "my-avatar" /** The two kinds of thread, named once so they cannot drift apart. */ fun threadForUser(userId: Long) = "user:$userId" @@ -352,6 +354,62 @@ class Vault(private val storage: Storage, private val key: SecretKey) { fun isActive(nowSeconds: Double): Boolean = ref.expires <= 0 || ref.expires > nowSeconds } + // -- Avatars ------------------------------------------------------------- + // + // 🔴 **Encrypted at rest like everything else here.** An avatar is a face, + // and a face on disk in the clear is the one thing a stolen phone should + // not hand over about somebody's contacts. These are references, not + // bytes — the bytes live in the ordinary attachment cache and are fetched + // from the relay as ciphertext. + + /** The profile picture we last accepted from this contact, if any. */ + fun avatarFor(userId: Long): Attachment.Ref? { + val json = readJson(AVATARS)?.optJSONObject(userId.toString()) ?: return null + return runCatching { Attachment.Ref.fromJson(Attachment.IMAGE, json, 0.0) }.getOrNull() + } + + /** + * Record a contact's picture. + * + * ⚠️ Last one wins, deliberately. A person who changes their picture sends + * a new reference and the old blob simply stops being asked for; there is + * no history of what somebody used to look like, and there should not be. + */ + fun saveAvatar(userId: Long, ref: Attachment.Ref?) { + val json = readJson(AVATARS) ?: JSONObject() + if (ref == null) json.remove(userId.toString()) + else json.put(userId.toString(), ref.toJson()) + writeJson(AVATARS, json) + } + + /** + * Which version of our picture a contact has already been told about. + * + * ⚠️ The blob id, not a boolean. A flag would say "told" for ever and a + * new picture would never reach anybody who already had the old one. + */ + fun avatarSharedWith(userId: Long): String? = + readJson(AVATARS)?.optString("shared:$userId")?.takeIf { it.isNotEmpty() } + + fun markAvatarShared(userId: Long, blobId: String) { + val json = readJson(AVATARS) ?: JSONObject() + json.put("shared:$userId", blobId) + writeJson(AVATARS, json) + } + + /** Our own, so it survives a relaunch and can be re-sent to new contacts. */ + fun myAvatar(): Attachment.Ref? { + val json = readJson(MY_AVATAR) ?: return null + return runCatching { Attachment.Ref.fromJson(Attachment.IMAGE, json, 0.0) }.getOrNull() + } + + fun saveMyAvatar(ref: Attachment.Ref?) { + // ⚠️ `storage.delete`, not `forget` — that one is session-scoped and + // would delete a record called `session/my-avatar` that does not exist, + // leaving the picture in place while reporting success. + if (ref == null) storage.delete(MY_AVATAR) else writeJson(MY_AVATAR, ref.toJson()) + } + fun loadStories(): List { val array = readJson(STORIES)?.optJSONArray("items") ?: return emptyList() return (0 until array.length()).mapNotNull { i ->