diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3ac0972..a9d8686 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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. diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/AddressCard.kt b/app/src/main/kotlin/io/flamenet/messenger/app/AddressCard.kt index 89447f1..8ca4080 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/AddressCard.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/AddressCard.kt @@ -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( 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 91c1db0..632a4bd 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -359,9 +359,24 @@ fun ColumnScope.GroupsSection( var name by remember { mutableStateOf("") } var chosen by remember { mutableStateOf(setOf()) } - // ⚠️ 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, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt index 6e72830..eed6957 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt @@ -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 diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index f220382..74e51d9 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -621,6 +621,15 @@ internal fun ColumnScope.SignedIn( ) { var addingContact by remember { mutableStateOf(false) } var openProfile by remember { mutableStateOf(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()) { diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt index e5dba4c..f66d369 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt @@ -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 { 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 6d1fd13..52e9fdb 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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 " diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ScanAddress.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ScanAddress.kt new file mode 100644 index 0000000..cbe2b26 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ScanAddress.kt @@ -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:@[?u=&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() } + } +} 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 3f98ec0..2c27a59 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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) }, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt index 426dada..ab58d07 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt @@ -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") } }, diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index f13b5e8..40d215e 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -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) } } /**