Group authors, avatars everywhere, QR scan/share, and iOS-parity Messages bar

- Session.Line carries `author` (record always persisted it; the read
  side never surfaced it) — group bubbles now wear the WRITER's name and
  picture instead of the group's initial for every message.
- Story cards use the author's real picture (your own from myAvatar).
- avatarShared marker stamps the peer's device set, not just the blob
  id: a bare marker outlived their reinstall and they saw initials for
  ever. Old markers mismatch once and self-heal with one resend.
- QR: AddressCard embeds ?u=<handle> so a same-relay scan resolves
  through the ordinary username lookup, and the Home + Messages bars
  gain a scanner — WebRTC's own Camera2 capture rendered to a
  SurfaceViewRenderer with each frame's Y plane offered to zxing:core
  (the one new dependency; decode only, no second camera framework).
  parseScannedAddress mirrors iOS ContactCard.decode's refusals.
- Messages screen matches iOS: bare title with scan + add-contact
  actions, New group beside the Groups heading (GroupsSection's own
  label, always drawn now, carries it), New chat beside CHATS with a
  contact picker, and the duplicated GROUPS label is gone.
- Story composer gains a per-story audience: Everyone / Only… /
  All except… chips with a member checklist, seeded from the account
  setting, never written back to it. postStory takes the override.
- AddContactForm accepts a prefill so a scan lands in the field —
  looked up, then added, never auto-added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-28 16:14:08 -04:00
co-authored by Claude Fable 5
parent 91ab857107
commit 3b6bdb43a3
11 changed files with 537 additions and 53 deletions
+6
View File
@@ -163,6 +163,12 @@ dependencies {
// auditable.
implementation("io.github.webrtc-sdk:android:125.6422.07")
// Reads the QR on somebody's Address screen. Decode only, pure Java, no
// services — the camera frames come from the WebRTC capture stack above,
// so this is the algorithm (finder patterns + Reed-Solomon), not a second
// camera framework. The encoder half stays our own (`engine`'s QrCode).
implementation("com.google.zxing:core:3.5.3")
// On-device tests. The JVM suites cannot see a provider difference, and a
// provider difference is what broke X25519 the first time this engine's
// sibling ran on a phone.
@@ -45,7 +45,7 @@ import io.flamenet.e2e.QrCode
* off a screen is how the wrong person gets messaged.
*/
@Composable
fun AddressCard(fnid: String?, relay: String) {
fun AddressCard(fnid: String?, relay: String, handle: String? = null) {
val context = LocalContext.current
Column(Modifier.fillMaxWidth()) {
@@ -66,7 +66,12 @@ fun AddressCard(fnid: String?, relay: String) {
}
val address = "$fnid@$relay"
val encoded = "flamenet:$address"
// ⚠️ The handle rides in the CODE only, never in the copyable string —
// it lets a same-relay scanner resolve the account through the
// ordinary username lookup. Foreign scanners ignore the parameter
// (iOS's decoder reads only `k=`); handles are slugs, so no escaping.
val encoded = "flamenet:$address" +
(handle?.takeIf { it.isNotBlank() }?.let { "?u=$it" } ?: "")
val bitmap = remember(encoded) { qrBitmap(encoded) }
Column(
@@ -359,9 +359,24 @@ fun ColumnScope.GroupsSection(
var name by remember { mutableStateOf("") }
var chosen by remember { mutableStateOf(setOf<Long>()) }
// ⚠️ No label for a section with nothing in it. Two words of furniture
// above zero rows is worse than the gap it fills.
if (shownGroupsExist(phase, query) || creating) SectionLabel("Groups")
// 🔴 The heading always renders now, because it CARRIES the way in — the
// New-group toggle sits beside the label, as it does on iOS, instead of
// in the screen's top bar two lists away from the list it feeds.
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.weight(1f)) { SectionLabel("Groups") }
androidx.compose.material3.Icon(
if (creating) KitIcon.Close else KitIcon.NewGroup,
contentDescription = "New group",
tint = Palette.Accent,
modifier = Modifier
.size(22.dp)
.clickable { onCreatingChange(!creating) }
.testTag("new-group"),
)
}
if (creating) {
OutlinedTextField(
@@ -643,7 +658,7 @@ fun ColumnScope.AccountScreen(
SettingsSection { AppLockSection() }
SectionBand()
SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay) }
SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay, handle = phase.handle) }
SectionBand()
SettingsSection {
@@ -1336,14 +1351,29 @@ fun ColumnScope.ChatScreen(
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(chat.lines) { line ->
// 🔴 THE AUTHOR, not the thread. In a group, `chat.peerId` is null
// and `chat.title` is the group's name — so every received bubble
// wore the group's initial as its avatar and the group's name as
// its author. The line has carried the writer's id all along.
val authorId = when {
line.mine -> null
chat.group == null -> chat.peerId
else -> line.author.takeIf { it != 0L }
}
Bubble(
line = line,
model = model,
authorName = chat.title,
authorName = when {
line.mine || chat.group == null -> chat.title
authorId == null -> chat.title
else -> buddies.firstOrNull { it.userId == authorId }
?.let { it.displayName.ifBlank { it.handle } }
?: "user $authorId"
},
// 🔴 Only for a message you did not send. Your own bubble has
// no avatar in the kit — you know who you are, and a picture on
// both sides halves the width a thread has to say anything in.
picture = chat.peerId?.takeIf { !line.mine }?.let { avatars[it] },
picture = authorId?.let { avatars[it] },
)
}
}
@@ -2277,9 +2307,11 @@ fun BuddyCirclesRow(
fun ColumnScope.AddContactForm(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
/** Prefill — a scanned address lands here. The person still looks it up. */
initial: String = "",
onDone: () -> Unit,
) {
var username by remember { mutableStateOf("") }
var username by remember(initial) { mutableStateOf(initial) }
OutlinedTextField(
value = username,
@@ -7,6 +7,7 @@ import androidx.compose.material.icons.automirrored.outlined.Send
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.AddComment
import androidx.compose.material.icons.outlined.AddCircleOutline
import androidx.compose.material.icons.outlined.Block
import androidx.compose.material.icons.outlined.Call
@@ -28,6 +29,7 @@ import androidx.compose.material.icons.outlined.StickyNote2
import androidx.compose.material.icons.outlined.NotificationsNone
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material.icons.outlined.PersonOutline
import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material.icons.outlined.PersonRemove
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material.icons.outlined.PhotoCamera
@@ -97,6 +99,8 @@ object KitIcon {
// Contact actions
val NewGroup: ImageVector = Icons.Outlined.GroupAdd
val NewChat: ImageVector = Icons.Outlined.AddComment
val Scan: ImageVector = Icons.Outlined.QrCodeScanner
val Block: ImageVector = Icons.Outlined.Block
/** Unblocking: the same row, the opposite verb. */
val PersonAdd: ImageVector = Icons.Outlined.PersonAdd
@@ -621,6 +621,15 @@ internal fun ColumnScope.SignedIn(
) {
var addingContact by remember { mutableStateOf(false) }
var openProfile by remember { mutableStateOf<Long?>(null) }
var scanning by remember { mutableStateOf(false) }
var scannedPrefill by remember { mutableStateOf("") }
val scanContext = androidx.compose.ui.platform.LocalContext.current
val askCamera = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
) { _ ->
// Open either way — the dialog explains itself when access is refused.
scanning = true
}
// ⚠️ The contact screen REPLACES the list rather than sitting under it,
// the same way an open chat does. A detail view stacked below a list is
@@ -647,6 +656,20 @@ internal fun ColumnScope.SignedIn(
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
// Scan their Address screen — iOS's home has the same button. A scan
// fills the Add-contact field and stops; nothing is added unlooked-at.
BarAction(
icon = KitIcon.Scan,
label = "Scan an address",
tag = "scan-address",
onClick = {
val has = androidx.core.content.ContextCompat.checkSelfPermission(
scanContext, android.Manifest.permission.CAMERA
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (has) scanning = true
else askCamera.launch(android.Manifest.permission.CAMERA)
},
)
BarAction(
icon = if (addingContact) KitIcon.Close else KitIcon.Add,
label = "Add contact",
@@ -655,6 +678,24 @@ internal fun ColumnScope.SignedIn(
)
}
if (scanning) {
ScanAddressDialog(
onFound = { raw ->
scanning = false
parseScannedAddress(raw)?.let { card ->
// A same-relay code that carries the handle can go through
// the ordinary username lookup; anything else fills the
// field with the address it named.
scannedPrefill = if (card.handle != null &&
card.relay.equals(phase.relay, ignoreCase = true)
) card.handle else "${card.fnid}@${card.relay}"
addingContact = true
}
},
onDismiss = { scanning = false },
)
}
phase.error?.let {
Text(
it,
@@ -665,7 +706,10 @@ internal fun ColumnScope.SignedIn(
}
if (addingContact) {
AddContactForm(model, phase) { addingContact = false }
AddContactForm(model, phase, initial = scannedPrefill) {
addingContact = false
scannedPrefill = ""
}
}
if (phase.buddies.isEmpty()) {
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
@@ -18,7 +19,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -45,7 +48,20 @@ fun ColumnScope.MessagesScreen(
) {
val query = remember { mutableStateOf("") }
val creatingGroup = remember { mutableStateOf(false) }
var addingContact by remember { mutableStateOf(false) }
var scanning by remember { mutableStateOf(false) }
var scannedPrefill by remember { mutableStateOf("") }
val scanContext = androidx.compose.ui.platform.LocalContext.current
val askCamera = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
) { _ ->
// Open either way — the dialog explains itself when access is refused.
scanning = true
}
// Starting a group sits on the Groups heading and a chat on the Chats
// heading; the bar keeps the two ways a NEW person gets in — scan their
// code, or add them by name — the same pair the Home bar carries.
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -56,16 +72,53 @@ fun ColumnScope.MessagesScreen(
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
// Groups live on this tab now — so does starting one.
androidx.compose.material3.Icon(
if (creatingGroup.value) KitIcon.Close else KitIcon.NewGroup,
contentDescription = "New group",
KitIcon.Scan,
contentDescription = "Scan an address",
tint = Palette.Accent,
modifier = Modifier
.size(28.dp)
.clickable { creatingGroup.value = !creatingGroup.value }
.testTag("new-group"),
.size(24.dp)
.clickable {
val has = androidx.core.content.ContextCompat.checkSelfPermission(
scanContext, android.Manifest.permission.CAMERA
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (has) scanning = true
else askCamera.launch(android.Manifest.permission.CAMERA)
}
.testTag("messages-scan"),
)
Spacer(Modifier.width(Space.md))
androidx.compose.material3.Icon(
if (addingContact) KitIcon.Close else KitIcon.Add,
contentDescription = "Add contact",
tint = Palette.Accent,
modifier = Modifier
.size(26.dp)
.clickable { addingContact = !addingContact }
.testTag("messages-add-contact"),
)
}
if (scanning) {
ScanAddressDialog(
onFound = { raw ->
scanning = false
parseScannedAddress(raw)?.let { card ->
scannedPrefill = if (card.handle != null &&
card.relay.equals(phase.relay, ignoreCase = true)
) card.handle else "${card.fnid}@${card.relay}"
addingContact = true
}
},
onDismiss = { scanning = false },
)
}
if (addingContact) {
AddContactForm(model, phase, initial = scannedPrefill) {
addingContact = false
scannedPrefill = ""
}
}
Spacer(Modifier.height(Space.sm))
@@ -83,26 +136,86 @@ fun ColumnScope.MessagesScreen(
Divider()
Spacer(Modifier.height(Space.sm))
// The kit's listing holds every conversation — groups included. Two kinds
// of thing share the list, so each gets the kit's muted label.
Text(
"GROUPS",
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = Palette.SecondaryText,
letterSpacing = androidx.compose.ui.unit.TextUnit(
0.8f, androidx.compose.ui.unit.TextUnitType.Sp),
)
// The kit's listing holds every conversation — groups included.
// ⚠️ No label of our own above the groups: GroupsSection draws its own
// (and only when there is something to head). A second one here printed
// "GROUPS" twice.
GroupsSection(model, phase, query.value, creatingGroup.value) { creatingGroup.value = it }
Spacer(Modifier.height(Space.sm))
Text(
"CHATS",
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = Palette.SecondaryText,
letterSpacing = androidx.compose.ui.unit.TextUnit(
0.8f, androidx.compose.ui.unit.TextUnitType.Sp),
)
val showNewChat = remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"CHATS",
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = Palette.SecondaryText,
letterSpacing = androidx.compose.ui.unit.TextUnit(
0.8f, androidx.compose.ui.unit.TextUnitType.Sp),
modifier = Modifier.weight(1f),
)
// The way in, beside the label — exactly as New group sits beside its
// heading in the bar above.
androidx.compose.material3.Icon(
KitIcon.NewChat,
contentDescription = "New chat",
tint = Palette.Accent,
modifier = Modifier
.size(22.dp)
.clickable { showNewChat.value = true }
.testTag("new-chat"),
)
}
if (showNewChat.value) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showNewChat.value = false },
title = { Text("New chat") },
text = {
if (phase.buddies.isEmpty()) {
Text("Add a contact first — on Home, with their username or address.")
} else {
Column {
phase.buddies.forEach { buddy ->
val rowName = buddy.displayName.ifBlank { buddy.handle }
.ifBlank { "user ${buddy.userId}" }
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
showNewChat.value = false
model.openChat(buddy.userId, rowName)
}
.padding(vertical = 10.dp)
.testTag("new-chat-${buddy.userId}"),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
name = rowName,
online = buddy.presence == "online",
picture = phase.avatars[buddy.userId],
model = model,
size = 36.dp,
)
Spacer(Modifier.width(Space.sm))
Text(
rowName,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
},
confirmButton = {
androidx.compose.material3.TextButton(
onClick = { showNewChat.value = false }) { Text("Cancel") }
},
)
}
val shown = phase.buddies
.filter {
@@ -1270,14 +1270,16 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
fun discardStagedStory() = update { it.copy(storyDraft = null) }
/** The deliberate Post from the composer sheet. */
fun postStagedStory(caption: String?) {
fun postStagedStory(caption: String?, audience: Session.StoryAudience? = null) {
val draft = signedIn()?.storyDraft ?: return
update { it.copy(storyDraft = null) }
postStory(draft.bytes, draft.mime, draft.width, draft.height,
caption?.takeIf { it.isNotBlank() })
caption?.takeIf { it.isNotBlank() }, audience)
}
fun postStory(bytes: ByteArray, mime: String, width: Int, height: Int, caption: String?) =
fun postStory(bytes: ByteArray, mime: String, width: Int, height: Int, caption: String?,
/** THIS story's audience from the composer; null = the account setting. */
audienceOverride: Session.StoryAudience? = null) =
viewModelScope.launch {
val current = session ?: return@launch
val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty()
@@ -1287,7 +1289,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}
// 🔴 The audience decides who is SENT the bytes. There is no copy
// on the relay that somebody could be denied — see Session.StoryAudience.
val recipients = current.storyAudience().recipients(contacts)
val recipients = (audienceOverride ?: current.storyAudience()).recipients(contacts)
if (recipients.isEmpty()) {
update {
it.copy(error = "Nobody is in your story audience, so this "
@@ -0,0 +1,202 @@
package io.flamenet.messenger.app
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
/**
* Point the camera at somebody's address — the other half of [AddressCard].
*
* 🔴 **Nothing here trusts what it read.** A QR code is a string an arbitrary
* stranger controls, so every scan goes through [parseScannedAddress] — the
* same refusals as iOS's `ContactCard.decode`: a relay is a hostname, never a
* URL, and a code that is not an address is ignored rather than shown as one.
*
* ⚠️ The camera pipeline is WebRTC's own — [org.webrtc.Camera2Capturer] into a
* [org.webrtc.SurfaceViewRenderer], exactly the capture stack video calls
* already ship. The only new code is handing each frame's Y plane to the QR
* decoder; pulling in a second camera framework to look for a square would be
* a dependency for decoration.
*/
/** What a valid code carries. `handle` only when the QR's author included it. */
internal data class ScannedAddress(val fnid: String, val relay: String, val handle: String?)
/**
* `flamenet:<fnid>@<relay>[?u=<handle>&k=…]`, or the bare `fnid@relay`
* somebody retypes. Refuses anything else rather than guessing — an address
* that is nearly right addresses somebody else.
*/
internal fun parseScannedAddress(text: String): ScannedAddress? {
var s = text.trim()
if (s.lowercase().startsWith("flamenet:")) s = s.substring("flamenet:".length)
var handle: String? = null
val q = s.indexOf('?')
if (q >= 0) {
s.substring(q + 1).split('&').forEach { pair ->
if (pair.startsWith("u=")) handle = pair.substring(2)
}
s = s.substring(0, q)
}
val at = s.indexOf('@')
if (at <= 0 || at == s.length - 1) return null
val fnid = s.substring(0, at).trim()
val relay = s.substring(at + 1).trim().lowercase()
if (fnid.isEmpty() || relay.isEmpty() || !relay.contains('.')) return null
// A relay is a hostname. Accepting "https://x/y" would let a printed code
// aim this client at a path on a host of the attacker's choosing.
if (relay.contains('/') || relay.contains(':')) return null
return ScannedAddress(fnid, relay, handle?.takeIf { it.isNotBlank() })
}
/** Full-screen camera dialog. Calls [onFound] once with the raw decoded text. */
@Composable
fun ScanAddressDialog(onFound: (String) -> Unit, onDismiss: () -> Unit) {
val context = LocalContext.current
val granted = androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.CAMERA
) == android.content.pm.PackageManager.PERMISSION_GRANTED
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
if (!granted) {
// ⚠️ Says what to do, not just that it failed. Once refused,
// only Android settings can change the answer.
Column(modifier = Modifier.align(Alignment.Center).padding(24.dp)) {
Text(
"Flamenet cannot use the camera. Allow it in Android "
+ "settings, or paste their address into Add contact instead.",
color = Color.White,
fontSize = 14.sp,
modifier = Modifier.testTag("scan-denied"),
)
}
} else {
androidx.compose.ui.viewinterop.AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
val renderer = org.webrtc.SurfaceViewRenderer(ctx)
renderer.tag = QrScan(ctx, renderer, onFound)
renderer
},
onRelease = { view -> (view.tag as? QrScan)?.stop() },
)
Text(
"Point at the code on their Address screen.",
color = Color.White,
fontSize = 13.sp,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 48.dp),
)
}
TextButton(
onClick = onDismiss,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 24.dp, end = 8.dp)
.testTag("scan-cancel"),
) { Text("Cancel", color = Color.White) }
}
}
}
/**
* The capture-and-decode loop. Frames render to the given view AND, four times
* a second, have their luminance plane offered to the QR decoder. First hit
* wins; everything after it is dropped.
*/
private class QrScan(
context: Context,
private val renderer: org.webrtc.SurfaceViewRenderer,
private val onText: (String) -> Unit,
) {
private val egl = org.webrtc.EglBase.create()
private val helper = org.webrtc.SurfaceTextureHelper.create("qr-scan", egl.eglBaseContext)
private var capturer: org.webrtc.CameraVideoCapturer? = null
private val done = java.util.concurrent.atomic.AtomicBoolean(false)
@Volatile private var lastDecode = 0L
private val reader = com.google.zxing.qrcode.QRCodeReader()
private val main = android.os.Handler(android.os.Looper.getMainLooper())
init {
renderer.init(egl.eglBaseContext, null)
// The BACK camera: a code is on somebody else's screen, not your face.
val enumerator = org.webrtc.Camera2Enumerator(context)
val name = enumerator.deviceNames.firstOrNull { enumerator.isBackFacing(it) }
?: enumerator.deviceNames.firstOrNull()
if (name != null) {
runCatching {
val cap = enumerator.createCapturer(name, null)
cap.initialize(helper, context, object : org.webrtc.CapturerObserver {
override fun onCapturerStarted(success: Boolean) {}
override fun onCapturerStopped() {}
override fun onFrameCaptured(frame: org.webrtc.VideoFrame) {
renderer.onFrame(frame)
maybeDecode(frame)
}
})
cap.startCapture(1280, 720, 30)
capturer = cap
}
}
}
/** Runs on the capture thread; ~250ms apart so decoding never backs it up. */
private fun maybeDecode(frame: org.webrtc.VideoFrame) {
val now = android.os.SystemClock.elapsedRealtime()
if (done.get() || now - lastDecode < 250) return
lastDecode = now
val i420 = frame.buffer.toI420() ?: return
try {
val w = i420.width
val h = i420.height
// Row by row because the stride can exceed the width; the decoder
// wants the packed plane. A QR carries its own orientation in the
// finder patterns, so the frame's rotation does not matter.
val y = ByteArray(w * h)
val data = i420.dataY
for (row in 0 until h) {
data.position(row * i420.strideY)
data.get(y, row * w, w)
}
val source = com.google.zxing.PlanarYUVLuminanceSource(y, w, h, 0, 0, w, h, false)
val bitmap = com.google.zxing.BinaryBitmap(
com.google.zxing.common.HybridBinarizer(source))
val text = runCatching { reader.decode(bitmap).text }.getOrNull()
reader.reset()
if (text != null && done.compareAndSet(false, true)) {
main.post { onText(text) }
}
} finally {
i420.release()
}
}
fun stop() {
done.set(true)
runCatching { capturer?.stopCapture() }
runCatching { capturer?.dispose() }
runCatching { helper.dispose() }
runCatching { renderer.release() }
runCatching { egl.release() }
}
}
@@ -38,6 +38,14 @@ class Session(
val text: String,
val mine: Boolean,
val at: Long,
/**
* Who wrote it — a user id, 0 when unrecorded. Groups need it: every
* received bubble in a group was captioned and pictured as the GROUP
* (its title's initial in the avatar slot), because the transcript
* only knew the thread, not the author. `record` has always persisted
* this; the read side just never surfaced it.
*/
val author: Long = 0,
val attachment: Attachment.Ref? = null,
/** Set iff this line is a sticker rather than words. */
val sticker: Sticker? = null,
@@ -82,6 +90,7 @@ class Session(
text = m.optString("text"),
mine = m.optBoolean("mine"),
at = m.optLong("at"),
author = m.optLong("author"),
attachment = m.optJSONObject("att")?.let { att ->
Attachment.Ref.fromJson(m.optString("kind", Attachment.IMAGE), att, 0.0)
},
@@ -137,17 +137,16 @@ private fun StoryCard(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
) {
Box(
modifier = Modifier.size(40.dp).clip(CircleShape).background(Palette.AvatarFill),
contentAlignment = Alignment.Center,
) {
Text(
author.take(1).uppercase(),
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
// The author's actual picture when one is held — your own comes
// from myAvatar, a contact's from the avatar map. Initials remain
// the normal case for anyone who never set one.
Avatar(
name = author,
online = false,
picture = if (story.mine) phase.myAvatar else phase.avatars[story.authorId],
model = model,
size = 40.dp,
)
Column(modifier = Modifier.weight(1f)) {
Text(author, fontSize = 14.sp, fontWeight = FontWeight.Medium)
// ⚠️ The deadline, in the slot the kit gives a description.
@@ -204,6 +203,13 @@ fun StoryComposerDialog(
) {
val draft = phase.storyDraft ?: return
val caption = remember(draft) { androidx.compose.runtime.mutableStateOf("") }
// Per-story audience, starting where the account setting points.
val mode = remember(draft) {
androidx.compose.runtime.mutableStateOf(phase.storyAudience.mode)
}
val chosen = remember(draft) {
androidx.compose.runtime.mutableStateOf(phase.storyAudience.userIds)
}
val image = remember(draft) {
runCatching {
android.graphics.BitmapFactory.decodeByteArray(draft.bytes, 0, draft.bytes.size)
@@ -230,8 +236,53 @@ fun StoryComposerDialog(
placeholder = { Text("Add a caption…") },
modifier = Modifier.fillMaxWidth().testTag("story-caption"),
)
// Who THIS story goes to. The audience decides who is SENT
// the bytes — there is no relay copy to deny anyone later —
// so the choice belongs here, before posting. Seeded from the
// account setting; never written back to it.
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs)) {
listOf(
Session.AudienceMode.EVERYONE to "Everyone",
Session.AudienceMode.ONLY to "Only…",
Session.AudienceMode.EXCEPT to "All except…",
).forEach { (m, label) ->
androidx.compose.material3.FilterChip(
selected = mode.value == m,
onClick = { mode.value = m },
label = { Text(label, fontSize = 12.sp) },
modifier = Modifier.testTag("story-aud-${m.name.lowercase()}"),
)
}
}
if (mode.value != Session.AudienceMode.EVERYONE) {
phase.buddies.forEach { buddy ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
chosen.value =
if (buddy.userId in chosen.value) chosen.value - buddy.userId
else chosen.value + buddy.userId
}
.padding(vertical = 4.dp)
.testTag("story-aud-${buddy.userId}"),
horizontalArrangement = Arrangement.spacedBy(Space.sm),
verticalAlignment = Alignment.CenterVertically,
) {
Text(if (buddy.userId in chosen.value) "☑" else "☐")
Text(buddy.displayName.ifBlank { buddy.handle }, fontSize = 14.sp)
}
}
}
Text(
"Goes to your contacts and disappears after a day.",
when (mode.value) {
Session.AudienceMode.EVERYONE ->
"Goes to all your contacts and disappears after a day."
Session.AudienceMode.ONLY ->
"Goes only to the ${chosen.value.size} chosen, and disappears after a day."
Session.AudienceMode.EXCEPT ->
"Goes to everyone but the ${chosen.value.size} chosen, and disappears after a day."
},
fontSize = 12.sp,
color = Palette.SecondaryText,
)
@@ -239,7 +290,12 @@ fun StoryComposerDialog(
},
confirmButton = {
androidx.compose.material3.TextButton(
onClick = { model.postStagedStory(caption.value) },
onClick = {
model.postStagedStory(
caption.value,
Session.StoryAudience(mode.value, chosen.value),
)
},
modifier = Modifier.testTag("story-post"),
) { Text("Post") }
},
@@ -309,9 +309,20 @@ class Conversations(
*/
fun shareAvatarIfNeeded(toUserId: Long) {
val ref = vault.myAvatar() ?: return
if (vault.avatarSharedWith(toUserId) == ref.id) return
// 🔴 The marker records WHICH DEVICES were told, not just which
// picture. A bare ref.id marker outlived the peer's reinstall: their
// new install held no avatar, this side said "already shared", and
// they saw initials for ever — the same staleness class as the
// delivery key. The directory endpoint is free (no prekey popped).
val devices = runCatching { relay.listDevices(toUserId) }.getOrNull() ?: return
val ids = (0 until devices.length())
.map { devices.getJSONObject(it).optString("device_id") }
.sorted().joinToString(",")
if (ids.isEmpty()) return
val stamp = "${ref.id}|$ids"
if (vault.avatarSharedWith(toUserId) == stamp) return
runCatching { sendPlaintext(toUserId, Content.avatar(ref)) }
.onSuccess { vault.markAvatarShared(toUserId, ref.id) }
.onSuccess { vault.markAvatarShared(toUserId, stamp) }
}
/**