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:
co-authored by
Claude Fable 5
parent
91ab857107
commit
3b6bdb43a3
@@ -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) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user