Profile pictures, end to end, and the chat bar's second line

🔴 **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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-27 15:58:58 -04:00
co-authored by Claude Opus 5
parent 7295f43a13
commit c9de1f26b8
8 changed files with 419 additions and 20 deletions
@@ -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) {
@@ -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.
@@ -87,6 +87,17 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val groups: List<io.flamenet.client.RelayClient.Group> = emptyList(),
val blocked: Set<Long> = emptySet(),
val notifications: List<MessengerApi.Notification> = 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<Long, io.flamenet.e2e.Attachment.Ref> = emptyMap(),
val myAvatar: io.flamenet.e2e.Attachment.Ref? = null,
/** Live stories, newest first (§6.6). */
val stories: List<Vault.Story> = 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
@@ -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<Long>) =
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<Long>): Map<Long, io.flamenet.e2e.Attachment.Ref> =
userIds.mapNotNull { id -> conversations.avatarFor(id)?.let { id to it } }.toMap()
fun shareAvatarIfNeeded(toUserId: Long) = conversations.shareAvatarIfNeeded(toUserId)
fun postStory(
bytes: ByteArray,
mime: String,