Merge main and re-derive the formatting pass

Conflicting files take main's version and ktlint and the comment
pipeline are re-run on them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-08 20:22:03 -04:00
co-authored by Claude Fable 5.1
parent b938d161ba
commit 788be4259e
43 changed files with 585 additions and 669 deletions
+1 -1
View File
@@ -184,7 +184,7 @@ dependencies {
// module's classpath would make `:client` unable to change its own // module's classpath would make `:client` unable to change its own
// dependencies without breaking a screen that never mentioned it. // dependencies without breaking a screen that never mentioned it.
implementation(project(":obfs4")) implementation(project(":obfs4"))
// 🔴 BitmapFactory ignores EXIF orientation, so a photo from a phone // BitmapFactory ignores EXIF orientation, so a photo from a phone
// camera decodes on its side. This reads the tag so the rotation can be // camera decodes on its side. This reads the tag so the rotation can be
// applied before re-encoding drops it. See ImagePrep.orientedFrom. // applied before re-encoding drops it. See ImagePrep.orientedFrom.
implementation("androidx.exifinterface:exifinterface:1.3.7") implementation("androidx.exifinterface:exifinterface:1.3.7")
@@ -208,7 +208,8 @@ class AppLock(
) { ) {
return return
} }
// The platform's own lockout is not counted on top of ours. // The platform's own lockout is not counted on top of
// ours.
onFailure(message.toString()) onFailure(message.toString())
} }
}, },
@@ -189,13 +189,13 @@ class CallManager(
): PeerConnection? { ): PeerConnection? {
val config = val config =
PeerConnection.RTCConfiguration(iceServers(turn)).apply { PeerConnection.RTCConfiguration(iceServers(turn)).apply {
// ALL rather than RELAY: direct paths are tried, and TURN is the // ALL rather than RELAY: direct paths are tried, and TURN is
// fallback. // the fallback.
iceTransportsType = PeerConnection.IceTransportsType.ALL iceTransportsType = PeerConnection.IceTransportsType.ALL
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
// Trickle is off: candidates are gathered before the offer is sent, // Trickle is off: candidates are gathered before the offer is
// so signalling is ONE encrypted envelope each way rather than a // sent, so signalling is ONE encrypted envelope each way rather
// stream of them. // than a stream of them.
continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_ONCE continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_ONCE
} }
val observer = val observer =
@@ -235,9 +235,9 @@ class CallManager(
override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) { override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) {
android.util.Log.i("fnmsg-call", "ice gathering -> $newState") android.util.Log.i("fnmsg-call", "ice gathering -> $newState")
// Hop to the main thread rather than touching the connection // Hop to the main thread rather than touching the
// from here, same JNI rule as above: no WebRTC calls, and no // connection from here, same JNI rule as above: no WebRTC
// exceptions, from inside a WebRTC callback. // calls, and no exceptions, from inside a WebRTC callback.
if (newState == PeerConnection.IceGatheringState.COMPLETE) { if (newState == PeerConnection.IceGatheringState.COMPLETE) {
main.post { runCatching { deliverGatheredSdp() } } main.post { runCatching { deliverGatheredSdp() } }
} }
@@ -392,8 +392,9 @@ fun rememberVideoCall(model: MessengerViewModel): (Long) -> Unit {
) { granted -> ) { granted ->
val peer = pending.value val peer = pending.value
pending.value = null pending.value = null
// Denied still calls, as voice-grade video (their picture, no ours is // Denied still calls, as voice-grade video (their picture, no ours
// wrong; the manager simply has no camera to add, so it degrades). // is wrong; the manager simply has no camera to add, so it
// degrades).
if (peer != null && granted) model.requestCall(peer, video = true) if (peer != null && granted) model.requestCall(peer, video = true)
} }
return { peer -> return { peer ->
@@ -188,8 +188,8 @@ fun ColumnScope.BuddyList(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { model.openChat(buddy.userId, buddy.displayName) } .clickable { model.openChat(buddy.userId, buddy.displayName) }
// Half the kit's 24dp row gap on each side, which makes the // Half the kit's 24dp row gap on each side, which makes
// 72dp pitch it draws between 48dp rows. // the 72dp pitch it draws between 48dp rows.
.padding(vertical = Kit.rowGap / 2) .padding(vertical = Kit.rowGap / 2)
.testTag("buddy-${buddy.userId}"), .testTag("buddy-${buddy.userId}"),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -2312,9 +2312,9 @@ private fun Bubble(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
// The kit insets a received bubble by 40 from the leading edge, the // The kit insets a received bubble by 40 from the leading edge,
// 32dp avatar plus its gutter, and lets a sent one run to the // the 32dp avatar plus its gutter, and lets a sent one run to
// margin. // the margin.
.padding( .padding(
start = if (line.mine) Kit.receivedInset else 0.dp, start = if (line.mine) Kit.receivedInset else 0.dp,
end = if (line.mine) 0.dp else Kit.receivedInset, end = if (line.mine) 0.dp else Kit.receivedInset,
@@ -2389,8 +2389,9 @@ private fun Bubble(
Text("Not delivered", fontSize = 10.sp, color = Palette.Pink) Text("Not delivered", fontSize = 10.sp, color = Palette.Pink)
} }
// DELIVERED and null both draw nothing: a message that arrived // DELIVERED and null both draw nothing: a message that
// needs no decoration, and null is every received message. // arrived needs no decoration, and null is every received
// message.
else -> { else -> {
Unit Unit
} }
@@ -2441,8 +2442,8 @@ private fun Bubble(
VoiceBubble(ref, model) VoiceBubble(ref, model)
} }
// A document, not a picture: AttachedImage would download a // A document, not a picture: AttachedImage would
// PDF and draw nothing. // download a PDF and draw nothing.
io.flamenet.e2e.Attachment.FILE -> { io.flamenet.e2e.Attachment.FILE -> {
FileBubble( FileBubble(
ref, ref,
@@ -2464,9 +2465,10 @@ private fun Bubble(
if (line.text.isNotEmpty()) { if (line.text.isNotEmpty()) {
Text( Text(
line.text, line.text,
// 16sp regular, per the kit's Body Text 2, which is what // 16sp regular, per the kit's Body Text 2, which is
// `messageTextSize` returns with larger type off, so this // what `messageTextSize` returns with larger type off,
// renders exactly as it always did until somebody asks. // so this renders exactly as it always did until
// somebody asks.
fontSize = bodySize.sp, fontSize = bodySize.sp,
color = color =
if (line.mine) { if (line.mine) {
@@ -2732,8 +2734,8 @@ fun ColumnScope.StoryStrip(
.size(Kit.avatar) .size(Kit.avatar)
.clip(CircleShape) .clip(CircleShape)
// Unseen is marked rather than styled away: a strip // Unseen is marked rather than styled away: a strip
// where read and unread look alike is a strip nobody // where read and unread look alike is a strip
// comes back to. // nobody comes back to.
.background( .background(
if (story.seen) { if (story.seen) {
Palette.AvatarFill Palette.AvatarFill
@@ -3199,9 +3201,9 @@ fun ColumnScope.AddContactForm(
) { ) {
Text(found.displayName.ifBlank { found.handle }) Text(found.displayName.ifBlank { found.handle })
Text("@${found.handle}", style = MaterialTheme.typography.bodySmall) Text("@${found.handle}", style = MaterialTheme.typography.bodySmall)
// Named plainly: identity is checked by comparing safety // Named plainly: identity is checked by comparing safety numbers
// numbers once the conversation exists, and a lookup result is // once the conversation exists, and a lookup result is not a
// not a substitute for that. // substitute for that.
Text( Text(
"Check their safety number after you start talking, this only says the " + "Check their safety number after you start talking, this only says the " +
"handle exists on this server.", "handle exists on this server.",
@@ -104,49 +104,59 @@ object ImagePrep {
/** /**
* Turn the pixels the way the EXIF tag says, then forget the tag. * Turn the pixels the way the EXIF tag says, then forget the tag.
*
* 🔴 **`BitmapFactory` does not apply EXIF orientation, and re-encoding
* throws the tag away.** A phone camera almost never writes upright
* pixels: it writes landscape pixels plus "rotate this 90°". Decoding
* ignores that, the JPEG we write out carries no EXIF at all — deliberately,
* because that block holds the GPS coordinates the shot was taken at — and
* so the only thing that knew which way was up is gone. Every portrait
* photo arrived on its side.
*
* ⚠️ **iOS is not affected and that is why this survived**, so a fix
* confirmed on one app proves nothing about the other: `UIImage(data:)`
* applies the orientation as it decodes, so the same strip-and-re-encode is
* safe there. Same design, opposite platform default.
*
* ⚠️ Returns the bitmap unchanged when there is nothing to do — the common
* case, and it avoids copying a large bitmap for no reason.
*/ */
internal fun orientedFrom(source: ByteArray, bitmap: Bitmap): Bitmap { internal fun orientedFrom(
val tag = runCatching { source: ByteArray,
bitmap: Bitmap,
): Bitmap {
val tag =
runCatching {
ExifInterface(ByteArrayInputStream(source)) ExifInterface(ByteArrayInputStream(source))
.getAttributeInt(ExifInterface.TAG_ORIENTATION, .getAttributeInt(
ExifInterface.ORIENTATION_NORMAL) ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL) }.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
val matrix = Matrix() val matrix = Matrix()
when (tag) { when (tag) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) ExifInterface.ORIENTATION_ROTATE_90 -> {
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) }
// ⚠️ The mirrored tags are rare but real — a front camera writes
// them. Handled rather than ignored, because a selfie coming back ExifInterface.ORIENTATION_ROTATE_180 -> {
// flipped is the same class of bug as one coming back sideways. matrix.postRotate(180f)
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f) }
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
ExifInterface.ORIENTATION_ROTATE_270 -> {
matrix.postRotate(270f)
}
// The mirrored tags are rare but real, a front camera writes them.
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
matrix.postScale(1f, -1f)
}
ExifInterface.ORIENTATION_TRANSPOSE -> { ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f); matrix.postScale(-1f, 1f) matrix.postRotate(90f)
matrix.postScale(-1f, 1f)
} }
ExifInterface.ORIENTATION_TRANSVERSE -> { ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(270f); matrix.postScale(-1f, 1f) matrix.postRotate(270f)
matrix.postScale(-1f, 1f)
} }
else -> return bitmap
else -> {
return bitmap
} }
val turned = runCatching { }
val turned =
runCatching {
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}.getOrNull() ?: return bitmap }.getOrNull() ?: return bitmap
if (turned !== bitmap) bitmap.recycle() if (turned !== bitmap) bitmap.recycle()
@@ -47,8 +47,8 @@ object KeystoreVault {
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256) .setKeySize(256)
// The vault supplies its own nonce per record, and a // The vault supplies its own nonce per record, and a
// repeated nonce under one key is the catastrophic AES-GCM // repeated nonce under one key is the catastrophic AES-
// failure. // GCM failure.
.setRandomizedEncryptionRequired(false) .setRandomizedEncryptionRequired(false)
.build(), .build(),
) )
@@ -13,10 +13,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -26,7 +26,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import io.flamenet.messenger.app.MessengerViewModel.Phase.Screen
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -62,47 +61,28 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import io.flamenet.messenger.app.MessengerViewModel.Phase.Screen
/** /**
* ⚠️ `FragmentActivity`, not `ComponentActivity`, and that is not cosmetic: * `FragmentActivity`, not `ComponentActivity`, and that is not cosmetic.
* `BiometricPrompt` hosts a fragment, so on a plain `ComponentActivity` it * `BiometricPrompt` hosts a fragment, so on a plain `ComponentActivity` it
* silently cannot show — the callback never fires and the lock screen sits * silently cannot show, the callback never fires and the lock screen sits there
* there looking broken. `FragmentActivity` extends `ComponentActivity`, so * looking broken.
* `setContent` and everything else is unaffected.
*/ */
class MainActivity : androidx.fragment.app.FragmentActivity() { class MainActivity : androidx.fragment.app.FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// 🔴 **Screenshots off, and out of the recents thumbnail.** // Screenshots off, and out of the recents thumbnail.
//
// The second half is the one that matters and the one nobody thinks
// about: without FLAG_SECURE, Android captures whatever is on screen
// into the recent-apps preview and WRITES IT TO DISK. An open
// conversation therefore exists as a plaintext bitmap outside every
// protection this app has — the ratchet, the sealed history file, the
// ThisDeviceOnly key — none of which cover a screenshot the OS took of
// the rendered thread.
//
// ⚠️ iOS has had the equivalent since the app lock landed: it obscures
// the switcher snapshot on `scenePhase`. Android had nothing at all.
// Two platforms disagreeing about a security guarantee is how one of
// them ends up being the one that leaks.
//
// ⚠️ It also blocks screen recording and casting, which is the point,
// and is not something to make optional: a setting here is a setting
// people leave off and then get surprised by.
window.setFlags( window.setFlags(
android.view.WindowManager.LayoutParams.FLAG_SECURE, android.view.WindowManager.LayoutParams.FLAG_SECURE,
android.view.WindowManager.LayoutParams.FLAG_SECURE, android.view.WindowManager.LayoutParams.FLAG_SECURE,
) )
// ⚠️ Runtime permission on API 33+, which is this app's minSdk. Without // Runtime permission on API 33+, which is this app's minSdk.
// it the service runs and every alert is dropped silently — no error,
// no banner, and nothing on screen to say why. Ask once here rather
// than discovering it as "notifications do not work".
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
!= android.content.pm.PackageManager.PERMISSION_GRANTED) { != android.content.pm.PackageManager.PERMISSION_GRANTED
) {
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 1001) requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 1001)
} }
@@ -110,25 +90,10 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
setContent { setContent {
MessengerTheme { MessengerTheme {
// 🔴 **`android:windowSoftInputMode="adjustResize"` stopped doing // `android:windowSoftInputMode="adjustResize"` stopped doing
// anything at targetSdk 35.** Android 15 forces apps that target // anything at targetSdk 35.
// it edge-to-edge, and an edge-to-edge window does not resize
// for the keyboard — so the manifest line has been a no-op on
// every device this ships to (minSdk 33), and the keyboard sat
// over the chat and the box being typed into. Reported as
// "bringing the chat above the keyboard would help me see what
// I'm typing", which is exactly the symptom.
//
// ⚠️ At the root rather than per screen: every screen with a
// text field has this problem, and one that is added later
// would have it too. `imePadding` is inset consumption, so the
// content shrinks and a scrolled list keeps its position —
// adding padding under the keyboard, not scrolling over it.
Surface(modifier = Modifier.fillMaxSize().imePadding()) { Surface(modifier = Modifier.fillMaxSize().imePadding()) {
// 🔴 **The gate is around Root, not inside it.** Drawn over // The gate is around Root, not inside it.
// the top, the message list still renders underneath for a
// frame — and a frame is all a screenshot or a shoulder
// needs. Locked means not composed.
var locked by remember { mutableStateOf(lock.enabled) } var locked by remember { mutableStateOf(lock.enabled) }
if (locked) { if (locked) {
@@ -136,18 +101,24 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
onUnlock = { onUnlock = {
lock.authenticate( lock.authenticate(
activity = this@MainActivity, activity = this@MainActivity,
onSuccess = { lock.noteUnlocked(); locked = false }, onSuccess = {
lock.noteUnlocked()
locked = false
},
onFailure = { /* the screen stays; it says why */ }, onFailure = { /* the screen stays; it says why */ },
) )
}, },
) )
// ⚠️ Asked once on arrival too, so the first thing after // Asked once on arrival too, so the first thing after
// launch is the prompt rather than a wall somebody has // launch is the prompt rather than a wall somebody has
// to tap to discover. // to tap to discover.
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
lock.authenticate( lock.authenticate(
activity = this@MainActivity, activity = this@MainActivity,
onSuccess = { lock.noteUnlocked(); locked = false }, onSuccess = {
lock.noteUnlocked()
locked = false
},
onFailure = {}, onFailure = {},
) )
} }
@@ -157,12 +128,20 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
// Re-arm on the way out, subject to the grace period. // Re-arm on the way out, subject to the grace period.
DisposableEffect(Unit) { DisposableEffect(Unit) {
val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> val observer =
androidx.lifecycle.LifecycleEventObserver { _, event ->
when (event) { when (event) {
androidx.lifecycle.Lifecycle.Event.ON_STOP -> lock.noteLeft() androidx.lifecycle.Lifecycle.Event.ON_STOP -> {
androidx.lifecycle.Lifecycle.Event.ON_START -> lock.noteLeft()
}
androidx.lifecycle.Lifecycle.Event.ON_START -> {
if (lock.shouldLock()) locked = true if (lock.shouldLock()) locked = true
else -> Unit }
else -> {
Unit
}
} }
} }
lifecycle.addObserver(observer) lifecycle.addObserver(observer)
@@ -173,10 +152,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
} }
} }
// 🔴 **The handover: exactly one poller at a time.** The ViewModel polls // The handover: exactly one poller at a time.
// while the app is on screen; the service polls while it is not. Two
// pollers would race for the same envelopes and double-notify, so this
// pair has to stay symmetrical — if one side moves, move the other.
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
MessengerService.stop(this) MessengerService.stop(this)
@@ -184,9 +160,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
// ⚠️ Only when there is a session to receive for. Starting it signed // Only when there is a session to receive for.
// out puts a permanent notice in the shade for an app doing nothing,
// and the service stops itself — but not before the user has seen it.
if (KeystoreVault.hasSession(applicationContext)) MessengerService.start(this) if (KeystoreVault.hasSession(applicationContext)) MessengerService.start(this)
} }
} }
@@ -200,7 +174,8 @@ private fun Root(model: MessengerViewModel = viewModel()) {
// Scrollable, because a keyboard over a form needs somewhere to go. // Scrollable, because a keyboard over a form needs somewhere to go.
is MessengerViewModel.Phase.Starting, is MessengerViewModel.Phase.Starting,
is MessengerViewModel.Phase.SignedOut, is MessengerViewModel.Phase.SignedOut,
is MessengerViewModel.Phase.VaultUnreadable -> is MessengerViewModel.Phase.VaultUnreadable,
-> {
Column( Column(
modifier = frame.verticalScroll(rememberScrollState()), modifier = frame.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
@@ -212,122 +187,90 @@ private fun Root(model: MessengerViewModel = viewModel()) {
else -> Unit else -> Unit
} }
} }
}
// NOT scrollable. A `LazyColumn` inside a vertically scrolling // NOT scrollable.
// Column is measured with infinite height and crashes outright — is MessengerViewModel.Phase.SignedIn -> {
// the lists below manage their own scrolling.
is MessengerViewModel.Phase.SignedIn ->
Column(modifier = frame, verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(modifier = frame, verticalArrangement = Arrangement.spacedBy(12.dp)) {
// 🔴 **Above the screen switch, not inside one branch.** An // Above the screen switch, not inside one branch.
// incoming call has to be visible whatever screen somebody
// is on, and this used to live inside SignedIn — which the
// chat screen REPLACES. So a call arriving while you were
// reading a thread showed nothing at all.
//
// Worse, the same banner renders the first-call privacy
// prompt, and the only Call button is IN a chat. Tapping it
// set the prompt state, nothing was composed to show it,
// and the prompt gates placing the call — so calling could
// not be started at all. The button looked simply dead.
// 🔴 **A live call takes the screen.** It used to be a
// banner above whatever you were on — a line of text and
// two text buttons for the single most attention-demanding
// thing this app does. The privacy prompt stays a banner
// deliberately: it is a question asked BEFORE a call
// exists, and a full screen for it would look like a call
// already happening.
CallBanner(model, current) CallBanner(model, current)
StoryComposerDialog(model, current) StoryComposerDialog(model, current)
// 🔴 **Here, above the screen switch, for the same reason // Here, above the screen switch, for the same reason the
// the call banner is.** Nothing installs this app but the // call banner is.
// person holding the phone, so the one moment they can be
// told a fix exists is while they have it open — whatever
// screen that happens to be.
UpdateAvailableDialog(model.site?.baseUrl) UpdateAvailableDialog(model.site?.baseUrl)
if (LiveCallScreen(model, current)) return@Column if (LiveCallScreen(model, current)) return@Column
val chat = current.openChat val chat = current.openChat
if (chat != null) { if (chat != null) {
ChatScreen(model, chat, current.error, current.buddies, current.avatars, ChatScreen(
myUserId = current.userId, groups = current.groups) model,
chat,
current.error,
current.buddies,
current.avatars,
myUserId = current.userId,
groups = current.groups,
)
} else { } else {
// 🔴 **The content area is weighted, and the tab bar is // The content area is weighted, and the tab bar is not.
// not.** These used to be plain siblings, so the tab bar
// sat immediately under whatever the screen had drawn:
// near the TOP of an empty Alerts tab, and pushed clean
// OFF the bottom by the Account tab, which is taller
// than the screen. Account also could not scroll, so the
// sign-out explanation was unreadable and there was no
// way back to another tab.
//
// 🔴 **Every screen scrolls, Contacts included.**
//
// This used to exempt Contacts, because "a lazy list
// inside a vertically scrolling Column is measured with
// infinite height and crashes outright". True — but
// only for an UNBOUNDED lazy list, and the buddy list
// has carried `heightIn(max = 320.dp)` since it was put
// in its own card. The exemption outlived its reason.
//
// ⚠️ What it cost: Groups sits below the buddy list on
// this screen, so opening **New group** pushed the
// member checkboxes and the **Create group** button past
// the bottom of a screen that could not scroll. The
// name field was reachable and nothing else was — you
// could type a group name and then only Cancel.
// Measured on a 1080×2400 emulator: the field landed at
// y≈1879, the button past the tab bar at y≈2280, and a
// `uiautomator dump` found no scrollable container and
// no "Create group" node at all.
//
// ⚠️ Before exempting a screen here again, check what is
// BELOW the lazy list, not just the list itself. And
// keep the buddy list bounded — remove that height and
// this does crash.
// ⚠️ Tighter than it was. Every section already carries
// its own padding, so a 12dp gap between them on top of
// that left the list airy in a way the kit is not — its
// rows sit on a 72dp pitch with nothing extra between
// the groups of them.
Column( Column(
modifier = Modifier modifier =
Modifier
.weight(1f) .weight(1f)
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(Space.sm), verticalArrangement = Arrangement.spacedBy(Space.sm),
) { ) {
when (current.screen) { when (current.screen) {
MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current) MessengerViewModel.Phase.Screen.CONTACTS -> {
MessengerViewModel.Phase.Screen.MESSAGES -> SignedIn(model, current)
}
MessengerViewModel.Phase.Screen.MESSAGES -> {
MessagesScreen(model, current) MessagesScreen(model, current)
MessengerViewModel.Phase.Screen.CALLS -> }
MessengerViewModel.Phase.Screen.CALLS -> {
CallsScreen(model, current) CallsScreen(model, current)
MessengerViewModel.Phase.Screen.STORIES -> }
MessengerViewModel.Phase.Screen.STORIES -> {
StoriesFeed(model, current) StoriesFeed(model, current)
MessengerViewModel.Phase.Screen.WALLET -> }
MessengerViewModel.Phase.Screen.WALLET -> {
WalletScreen(model, current) WalletScreen(model, current)
// 🔴 Its own destination, not a section of the }
// Its own destination, not a section of the
// wallet. iOS put them together once because // wallet. iOS put them together once because
// they share an unlock — true, and an // they share an unlock, true, and an
// implementation detail. What it produced was // implementation detail.
// somebody pressing "wallet" and arriving at a MessengerViewModel.Phase.Screen.FILES -> {
// file list.
MessengerViewModel.Phase.Screen.FILES ->
FilesScreen(model, current) FilesScreen(model, current)
MessengerViewModel.Phase.Screen.NOTIFICATIONS -> }
MessengerViewModel.Phase.Screen.NOTIFICATIONS -> {
NotificationsScreen(model, current) NotificationsScreen(model, current)
MessengerViewModel.Phase.Screen.ACCOUNT -> }
MessengerViewModel.Phase.Screen.ACCOUNT -> {
AccountScreen(model, current) AccountScreen(model, current)
} }
} }
}
BottomNav(model, current) BottomNav(model, current)
} }
} }
} }
} }
}
} }
@Composable @Composable
private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedOut) { private fun ColumnScope.SignIn(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedOut,
) {
val relay by model.relayInput.collectAsStateWithLifecycle() val relay by model.relayInput.collectAsStateWithLifecycle()
// Observed, not read once: the account mode is discovered from the relay a // Observed, not read once: the account mode is discovered from the relay a
// moment after the address is typed, and the label has to catch up. // moment after the address is typed, and the label has to catch up.
@@ -341,24 +284,15 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
// Only a relay keeping its own accounts needs an address for reset mail. // Only a relay keeping its own accounts needs an address for reset mail.
val needsEmail = registering && site?.accountProvider == SiteConfig.AccountProvider.LOCAL val needsEmail = registering && site?.accountProvider == SiteConfig.AccountProvider.LOCAL
// 🔴 **The kit's Welcome screen, which is a masthead and not a form // The kit's Welcome screen, which is a masthead and not a form heading.
// heading.** A logo mark, a 40pt greeting, and one line saying what this
// is — centred, with room above it. This was a 24pt product name jammed
// against a Server field, which is the layout of a login dialog rather
// than the first thing anyone sees of the app. See `DESIGN.md`.
//
// ⚠️ The kit's Welcome has no fields on it at all — it is a splash before
// a phone number is asked for. Flamenet does not ask for a phone number
// and has nothing to put on a second screen, so the masthead and the form
// share one. The typography and the centring are the kit's; the fields
// are ours.
Spacer(Modifier.height(Space.xl)) Spacer(Modifier.height(Space.xl))
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Box( Box(
modifier = Modifier modifier =
Modifier
.size(48.dp) .size(48.dp)
.clip(RoundedCornerShape(Radius.medium)) .clip(RoundedCornerShape(Radius.medium))
.background(MaterialTheme.colorScheme.primary), .background(MaterialTheme.colorScheme.primary),
@@ -370,8 +304,8 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
Text("Hi there!", fontSize = 40.sp, fontWeight = FontWeight.Bold) Text("Hi there!", fontSize = 40.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(Space.sm)) Spacer(Modifier.height(Space.sm))
Text( Text(
"Flamenet Messenger. End-to-end encrypted, no phone number, and it " "Flamenet Messenger. End-to-end encrypted, no phone number, and it " +
+ "runs on whichever server you point it at.", "runs on whichever server you point it at.",
fontSize = 14.sp, fontSize = 14.sp,
color = Palette.SecondaryText, color = Palette.SecondaryText,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@@ -388,9 +322,7 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
modifier = Modifier.fillMaxWidth().testTag("relay"), modifier = Modifier.fillMaxWidth().testTag("relay"),
) )
// Say which kind of server this is BEFORE the passphrase is typed. Whether // Say which kind of server this is BEFORE the passphrase is typed.
// the passphrase leaves the device differs between the two, and that is not
// something to disclose afterwards.
site?.let { site?.let {
Text( Text(
it.identityDescription, it.identityDescription,
@@ -408,18 +340,16 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
modifier = Modifier.fillMaxWidth().testTag("handle"), modifier = Modifier.fillMaxWidth().testTag("handle"),
) )
// Only a relay keeping its own accounts asks for this — it is how the // Only a relay keeping its own accounts asks for this, it is how the relay
// relay sends a reset code, and it is the only party that ever sees it. // sends a reset code, and it is the only party that ever sees it.
// The shared service does not take one at all, so asking for it there
// would be collecting something nobody needs.
if (needsEmail) { if (needsEmail) {
OutlinedTextField( OutlinedTextField(
value = email, value = email,
onValueChange = { email = it }, onValueChange = { email = it },
label = { Text("Email") }, label = { Text("Email") },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions =
keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth().testTag("email"), modifier = Modifier.fillMaxWidth().testTag("email"),
) )
Text( Text(
@@ -434,31 +364,32 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
label = { Text("Passphrase") }, label = { Text("Passphrase") },
singleLine = true, singleLine = true,
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions( keyboardOptions =
keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
modifier = Modifier.fillMaxWidth().testTag("passphrase"), modifier = Modifier.fillMaxWidth().testTag("passphrase"),
) )
if (registering) { if (registering) {
// The age gate. Neutral, and stated honestly: the year is checked on // The age gate.
// this device to refuse an under-13 sign-up, then forgotten. Nothing
// about age is sent to any server.
OutlinedTextField( OutlinedTextField(
value = birthYear, value = birthYear,
onValueChange = { birthYear = it.filter(Char::isDigit).take(4) }, onValueChange = { birthYear = it.filter(Char::isDigit).take(4) },
label = { Text("Year of birth") }, label = { Text("Year of birth") },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions =
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
modifier = Modifier.fillMaxWidth().testTag("birth-year"), modifier = Modifier.fillMaxWidth().testTag("birth-year"),
) )
Text( Text(
"Checked on this phone to confirm you're old enough, then forgotten — never sent anywhere.", "Checked on this phone to confirm you're old enough, then forgotten, never sent anywhere.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
ageError?.let { ageError?.let {
Text(it, color = MaterialTheme.colorScheme.error, Text(
modifier = Modifier.testTag("age-error")) it,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag("age-error"),
)
} }
} }
@@ -466,28 +397,35 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("error")) Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("error"))
} }
// ⚠️ 240 wide and centred, which is the kit's primary button on this // 240 wide and centred, which is the kit's primary button on this screen,
// screen — not a full-width slab. A button that spans the phone reads as // not a full-width slab.
// the bottom of a form; one that does not reads as a choice.
Spacer(Modifier.height(Space.md)) Spacer(Modifier.height(Space.md))
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Button( Button(
onClick = { onClick = {
if (registering) { if (registering) {
// The most conservative reading — a birthday not yet // The most conservative reading, a birthday not yet passed
// passed this year — because "maybe old enough" isn't. // this year, because "maybe old enough" isn't.
val year = birthYear.toIntOrNull() ?: 0 val year = birthYear.toIntOrNull() ?: 0
val age = java.util.Calendar.getInstance().get(java.util.Calendar.YEAR) - year val age =
java.util.Calendar
.getInstance()
.get(java.util.Calendar.YEAR) - year
if (year < 1900 || age < 14) { if (year < 1900 || age < 14) {
ageError = if (year < 1900) "Enter your year of birth." ageError =
else "You must be at least 13 to use Flamenet Messenger." if (year < 1900) {
"Enter your year of birth."
} else {
"You must be at least 13 to use Flamenet Messenger."
}
return@Button return@Button
} }
ageError = null ageError = null
} }
model.signIn(handle.trim(), passphrase, register = registering, email = email.trim()) model.signIn(handle.trim(), passphrase, register = registering, email = email.trim())
}, },
enabled = !phase.busy && handle.isNotBlank() && passphrase.isNotBlank() && enabled =
!phase.busy && handle.isNotBlank() && passphrase.isNotBlank() &&
(!needsEmail || email.isNotBlank()) && (!needsEmail || email.isNotBlank()) &&
(!registering || birthYear.length == 4), (!registering || birthYear.length == 4),
shape = RoundedCornerShape(KitButton.radius), shape = RoundedCornerShape(KitButton.radius),
@@ -523,14 +461,12 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
/** /**
* Password reset, in place rather than on another screen. * Password reset, in place rather than on another screen.
*
* ⚠️ The relay answers a reset request identically whatever the truth is —
* unknown handle, no address on file, an account that authenticates elsewhere.
* So the copy here says what was *done*, never what was *found*. "No such
* account" would undo that at the last step.
*/ */
@Composable @Composable
private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String) { private fun ColumnScope.ResetPassword(
model: MessengerViewModel,
handle: String,
) {
var open by remember { mutableStateOf(false) } var open by remember { mutableStateOf(false) }
var code by remember { mutableStateOf("") } var code by remember { mutableStateOf("") }
var fresh by remember { mutableStateOf("") } var fresh by remember { mutableStateOf("") }
@@ -577,29 +513,19 @@ private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String)
/** /**
* What the bar contains, as data rather than as a shape only Compose can see. * What the bar contains, as data rather than as a shape only Compose can see.
*
* 🔴 **Extracted so a test can read it.** The bar was a hand-written run of
* `NavButton` calls, which meant nothing could assert what was in it — a
* destination could be dropped in a refactor and no test anywhere would go red.
* It has happened once already in spirit: Files and Wallet lived only inside
* the Account screen for weeks while iOS carried them in its bar, and the two
* apps disagreed with nothing to notice.
*/ */
object NavDestinations { object NavDestinations {
data class Tab(
data class Tab(val screen: MessengerViewModel.Phase.Screen, val label: String, val tag: String) val screen: MessengerViewModel.Phase.Screen,
val label: String,
val tag: String,
)
/** /**
* The bar, in order. * The bar, in order.
*
* ⚠️ **Order matches iOS**, which puts Files and Wallet between Stories and
* Account. The same destination in the same place on both phones is most of
* what "the apps stay in sync" means to somebody holding one of each.
*
* `storiesHidden` is parental supervision: it removes the surface rather
* than showing an empty one.
*/ */
fun tabs(storiesHidden: Boolean): List<Tab> = listOfNotNull( fun tabs(storiesHidden: Boolean): List<Tab> =
listOfNotNull(
Tab(Screen.CONTACTS, "Home", "nav-contacts"), Tab(Screen.CONTACTS, "Home", "nav-contacts"),
Tab(Screen.MESSAGES, "Messages", "nav-messages"), Tab(Screen.MESSAGES, "Messages", "nav-messages"),
Tab(Screen.CALLS, "Calls", "nav-calls"), Tab(Screen.CALLS, "Calls", "nav-calls"),
@@ -612,17 +538,6 @@ object NavDestinations {
/** /**
* The places to be, always visible. * The places to be, always visible.
*
* A tab bar rather than a menu: a capability hidden behind an overflow is one
* most users never find. That was the argument for putting the account here,
* and it is the same argument that brought Files and Wallet here on 2026-09-07
* — they were reachable only by scrolling most of the way down the account
* screen, which is indistinguishable from not shipping them.
*
* ⚠️ **The kit's artboard is four icons and this is now seven.** That is a
* deliberate departure, not an oversight: the earlier note here argued a wallet
* "does not earn the slot", and iOS had already decided otherwise. Two apps
* disagreeing about where a feature lives is worse than either answer.
*/ */
@Composable @Composable
private fun BottomNav( private fun BottomNav(
@@ -630,13 +545,10 @@ private fun BottomNav(
phase: MessengerViewModel.Phase.SignedIn, phase: MessengerViewModel.Phase.SignedIn,
) { ) {
val unreadNotes = phase.notifications.count { !it.read } val unreadNotes = phase.notifications.count { !it.read }
// 🔴 **A floating pill, not a full-width strip.** The kit's tab bar is a // A floating pill, not a full-width strip.
// 328x50 rounded rectangle inset from both edges, sitting inside an 84-tall
// band — it reads as a control resting on the page rather than as the
// bottom of the window. This is the single most recognisable thing about
// the design and the app had a plain row of text buttons.
Row( Row(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = Kit.pillHeight) .heightIn(min = Kit.pillHeight)
.clip(RoundedCornerShape(Kit.pillRadius)) .clip(RoundedCornerShape(Kit.pillRadius))
@@ -648,20 +560,24 @@ private fun BottomNav(
val storiesHidden = val storiesHidden =
ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current) ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current)
for (tab in NavDestinations.tabs(storiesHidden)) { for (tab in NavDestinations.tabs(storiesHidden)) {
val badge = when (tab.screen) { val badge =
// The inbox badge lives on Messages — Home is the roster, this when (tab.screen) {
// is where new mail is read. // The inbox badge lives on Messages.
Screen.MESSAGES -> phase.unread.size Screen.MESSAGES -> phase.unread.size
// Missed calls SINCE the tab was last opened — it used to count
// Missed calls SINCE the tab was last opened, it used to count
// every missed call in the log for ever, a badge nothing could // every missed call in the log for ever, a badge nothing could
// clear. Opening the tab is what "seeing" them means. // clear.
Screen.CALLS -> model.missedCallsUnseen(phase) Screen.CALLS -> model.missedCallsUnseen(phase)
// Alerts left the bar 2026-08-28 and iOS never had one; its // Alerts left the bar 2026-08-28 and iOS never had one; its
// unread count badges Account on both phones now. // unread count badges Account on both phones now.
Screen.ACCOUNT -> unreadNotes Screen.ACCOUNT -> unreadNotes
else -> 0 else -> 0
} }
val (icon, active) = when (tab.screen) { val (icon, active) =
when (tab.screen) {
Screen.CONTACTS -> KitIcon.Home to KitIcon.Home Screen.CONTACTS -> KitIcon.Home to KitIcon.Home
Screen.MESSAGES -> KitIcon.Chats to KitIcon.ChatsActive Screen.MESSAGES -> KitIcon.Chats to KitIcon.ChatsActive
Screen.CALLS -> KitIcon.Call to KitIcon.Call Screen.CALLS -> KitIcon.Call to KitIcon.Call
@@ -677,16 +593,6 @@ private fun BottomNav(
/** /**
* One tab: a drawn icon, and a count when there is one. * One tab: a drawn icon, and a count when there is one.
*
* 🔴 **No label.** The kit's bar is four icons and nothing else — the artboard
* is literally named "Bottom Bar/Without Label". This was four `TextButton`s
* reading "Contacts Stories Alerts Account", which is a row of links, and
* the counts were parenthesised into those labels: "Contacts (3)".
*
* ⚠️ The label survives as the accessibility name. Dropping the text from the
* screen must not drop it from VoiceOver/TalkBack, or the bar becomes four
* unnamed buttons — the version of this that shipped as icons-only with no
* `contentDescription` would be strictly worse than the text it replaced.
*/ */
@Composable @Composable
private fun RowScope.NavButton( private fun RowScope.NavButton(
@@ -701,31 +607,33 @@ private fun RowScope.NavButton(
) { ) {
val selected = phase.screen == screen val selected = phase.screen == screen
Box( Box(
modifier = Modifier modifier =
Modifier
.weight(1f) .weight(1f)
.height(Kit.pillHeight) .height(Kit.pillHeight)
// 🔴 `navigateTo`, not `showScreen`. Wallet and Files each have to // `navigateTo`, not `showScreen`.
// read the vault before they have anything to draw, so a bare
// `showScreen` lands somebody on an empty screen with nothing
// loading and no error — see the note on `navigateTo`.
.clickable { model.navigateTo(screen) } .clickable { model.navigateTo(screen) }
.semantics { .semantics {
contentDescription = contentDescription =
if (badge > 0) "$label, $badge unread" else label if (badge > 0) "$label, $badge unread" else label
} }.testTag(tag),
.testTag(tag),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
if (selected) activeIcon else icon, if (selected) activeIcon else icon,
contentDescription = null, contentDescription = null,
tint = if (selected) MaterialTheme.colorScheme.primary tint =
else Palette.SecondaryText, if (selected) {
MaterialTheme.colorScheme.primary
} else {
Palette.SecondaryText
},
modifier = Modifier.size(Kit.icon), modifier = Modifier.size(Kit.icon),
) )
if (badge > 0) { if (badge > 0) {
Box( Box(
modifier = Modifier modifier =
Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(top = Space.md, end = Space.lg) .padding(top = Space.md, end = Space.lg)
.size(Space.sm) .size(Space.sm)
@@ -737,9 +645,7 @@ private fun RowScope.NavButton(
} }
/** /**
* ⚠️ Internal rather than private ONLY so the JVM screenshot suite can render * Internal rather than private ONLY so the JVM screenshot suite can render it.
* it. `MainActivity` is its one real caller — the alternative was a screenshot
* harness that could photograph the app's parts and never a whole screen.
*/ */
@Composable @Composable
internal fun ColumnScope.SignedIn( internal fun ColumnScope.SignedIn(
@@ -751,28 +657,24 @@ internal fun ColumnScope.SignedIn(
var scanning by remember { mutableStateOf(false) } var scanning by remember { mutableStateOf(false) }
var scannedPrefill by remember { mutableStateOf("") } var scannedPrefill by remember { mutableStateOf("") }
val scanContext = androidx.compose.ui.platform.LocalContext.current val scanContext = androidx.compose.ui.platform.LocalContext.current
val askCamera = androidx.activity.compose.rememberLauncherForActivityResult( val askCamera =
androidx.activity.result.contract.ActivityResultContracts.RequestPermission() androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts
.RequestPermission(),
) { _ -> ) { _ ->
// Open either way — the dialog explains itself when access is refused. // Open either way, the dialog explains itself when access is refused.
scanning = true scanning = true
} }
// ⚠️ The contact screen REPLACES the list rather than sitting under it, // The contact screen REPLACES the list rather than sitting under it, the
// the same way an open chat does. A detail view stacked below a list is // same way an open chat does.
// two screens fighting over one scroll.
val profile = openProfile?.let { id -> phase.buddies.firstOrNull { it.userId == id } } val profile = openProfile?.let { id -> phase.buddies.firstOrNull { it.userId == id } }
if (profile != null) { if (profile != null) {
ContactDetail(model, phase, profile) { openProfile = null } ContactDetail(model, phase, profile) { openProfile = null }
return return
} }
// 🔴 **Home is two things now, on purpose: your people, and their // Home is two things now, on purpose: your people, and their stories.
// stories.** The conversation list, the groups and the search moved to
// the Messages tab — this screen is the kit's social front page: a
// horizontal row of contacts, then everyone's stories. Managing the
// roster stays here (the Add action, and a long-press on a circle for
// the contact's profile).
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -783,18 +685,22 @@ internal fun ColumnScope.SignedIn(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
// Scan their Address screen — iOS's home has the same button. A scan // Scan their Address screen, iOS's home has the same button.
// fills the Add-contact field and stops; nothing is added unlooked-at.
BarAction( BarAction(
icon = KitIcon.Scan, icon = KitIcon.Scan,
label = "Scan an address", label = "Scan an address",
tag = "scan-address", tag = "scan-address",
onClick = { onClick = {
val has = androidx.core.content.ContextCompat.checkSelfPermission( val has =
scanContext, android.Manifest.permission.CAMERA androidx.core.content.ContextCompat.checkSelfPermission(
scanContext,
android.Manifest.permission.CAMERA,
) == android.content.pm.PackageManager.PERMISSION_GRANTED ) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (has) scanning = true if (has) {
else askCamera.launch(android.Manifest.permission.CAMERA) scanning = true
} else {
askCamera.launch(android.Manifest.permission.CAMERA)
}
}, },
) )
BarAction( BarAction(
@@ -810,12 +716,7 @@ internal fun ColumnScope.SignedIn(
onFound = { raw -> onFound = { raw ->
scanning = false scanning = false
parseScannedAddress(raw)?.let { card -> parseScannedAddress(raw)?.let { card ->
// 🔴 **Bind by the fnid, never the handle (audit LOW).** // Bind by the fnid, never the handle (audit LOW).
// The fnid is derived from the account key and certifies
// itself; a handle is a name the RELAY maps to an account,
// so resolving a scanned code by handle handed the relay
// the choice of who you just scanned. iOS always binds the
// address; this now matches it, same relay or not.
scannedPrefill = "${'$'}{card.fnid}@${'$'}{card.relay}" scannedPrefill = "${'$'}{card.fnid}@${'$'}{card.relay}"
addingContact = true addingContact = true
} }
@@ -862,11 +763,6 @@ internal fun ColumnScope.SignedIn(
/** /**
* One icon-sized action on the trailing edge of the bar. * One icon-sized action on the trailing edge of the bar.
*
* ⚠️ Glyphs rather than vector assets, because this app ships no icon set and
* pulling one in for four buttons is a dependency for decoration. They are
* sized and spaced like the kit's 24dp icons and carry the real label for
* anyone who cannot see them.
*/ */
@Composable @Composable
private fun BarAction( private fun BarAction(
@@ -876,7 +772,8 @@ private fun BarAction(
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
Box( Box(
modifier = Modifier modifier =
Modifier
.size(Kit.icon + Space.md) .size(Kit.icon + Space.md)
.clip(CircleShape) .clip(CircleShape)
.clickable(onClick = onClick) .clickable(onClick = onClick)
@@ -884,9 +781,7 @@ private fun BarAction(
.testTag(tag), .testTag(tag),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
// 🔴 An `Icon`, not a glyph in a text run. See `KitIcons.kt` — the bar // An `Icon`, not a glyph in a text run.
// used to draw "⊞" and "+" as characters, which is most of why this
// screen read as text links rather than as chrome.
Icon( Icon(
icon, icon,
contentDescription = null, contentDescription = null,
@@ -896,7 +791,6 @@ private fun BarAction(
} }
} }
@Composable @Composable
private fun ColumnScope.VaultUnreadable( private fun ColumnScope.VaultUnreadable(
model: MessengerViewModel, model: MessengerViewModel,
@@ -913,12 +807,11 @@ private fun ColumnScope.VaultUnreadable(
"Starting fresh publishes a new device. Everyone you talk to will see " + "Starting fresh publishes a new device. Everyone you talk to will see " +
"\"security keys changed\", so tell them to expect it.", "\"security keys changed\", so tell them to expect it.",
) )
// 🔴 `discardUnreadableVault`, NOT `signOut`. `signOut()` defaults to // `discardUnreadableVault`, NOT `signOut`.
// keeping the device, so it left the unreadable vault in place and the next Button(
// restore came straight back here — the only button on a dead-end screen onClick = model::discardUnreadableVault,
// did nothing but redraw it. modifier = Modifier.testTag("start-fresh"),
Button(onClick = model::discardUnreadableVault, ) {
modifier = Modifier.testTag("start-fresh")) {
Text("Erase and start fresh") Text("Erase and start fresh")
} }
} }
@@ -54,7 +54,8 @@ fun ColumnScope.MessagesScreen(
androidx.activity.result.contract.ActivityResultContracts androidx.activity.result.contract.ActivityResultContracts
.RequestPermission(), .RequestPermission(),
) { _ -> ) { _ ->
// Open either way, the dialog explains itself when access is refused. // Open either way, the dialog explains itself when access is
// refused.
scanning = true scanning = true
} }
@@ -141,7 +141,8 @@ class MessengerService : Service() {
private suspend fun run() { private suspend fun run() {
val s = val s =
openSession() ?: run { openSession() ?: run {
// Nothing to receive for, signed out, or the vault is unreadable. // Nothing to receive for, signed out, or the vault is
// unreadable.
stopSelf() stopSelf()
return return
} }
@@ -258,9 +258,10 @@ class MessengerViewModel(
_site.value = config.copy(probeFailed = true) _site.value = config.copy(probeFailed = true)
return@launch return@launch
} }
// copy(), not a mutation: SiteConfig is a data class and the flow // copy(), not a mutation: SiteConfig is a data class and the
// conflates on equality, so an in-place write is indistinguishable // flow conflates on equality, so an in-place write is
// from the value already there and never reaches the UI. // indistinguishable from the value already there and never
// reaches the UI.
_site.value = config.copy(discovered = found) _site.value = config.copy(discovered = found)
} }
} }
@@ -308,8 +309,8 @@ class MessengerViewModel(
userId = credential.userId, userId = credential.userId,
deviceId = stored.deviceId, deviceId = stored.deviceId,
relay = SiteConfig(credential.relayBaseUrl).host, relay = SiteConfig(credential.relayBaseUrl).host,
// From the vault, because a restore has no passphrase // From the vault, because a restore has no
// to derive it from. // passphrase to derive it from.
fnid = credential.fnid, fnid = credential.fnid,
status = "Signed in on this device.", status = "Signed in on this device.",
buddies = fetchedBuddies.getOrDefault(emptyList()), buddies = fetchedBuddies.getOrDefault(emptyList()),
@@ -574,14 +575,16 @@ class MessengerViewModel(
viewModelScope.launch { viewModelScope.launch {
val current = session ?: return@launch val current = session ?: return@launch
refreshPresence() refreshPresence()
// Snoozes expire by the clock passing and nothing runs at that moment, // Snoozes expire by the clock passing and nothing runs at that
// so the set is recomputed whenever the list is drawn rather than // moment, so the set is recomputed whenever the list is drawn
// cached, a stale flag leaves a conversation muted past its deadline. // rather than cached, a stale flag leaves a conversation muted past
// its deadline.
refreshSnoozed() refreshSnoozed()
val fetched = val fetched =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { current.roster() } runCatching { current.roster() }
// Pin each contact's global address the first time we see them. // Pin each contact's global address the first time we
// see them.
.onSuccess { list -> .onSuccess { list ->
list.forEach { buddy -> list.forEach { buddy ->
runCatching { runCatching {
@@ -602,9 +605,9 @@ class MessengerViewModel(
val added = val added =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { runCatching {
// Off-server roster (P1): the add is this device's to make, the // Off-server roster (P1): the add is this device's to
// relay only resolved the handle, and that lookup was // make, the relay only resolved the handle, and that
// transient. // lookup was transient.
if (current.rosterOffServer()) { if (current.rosterOffServer()) {
val found = val found =
signedIn()?.lookedUp signedIn()?.lookedUp
@@ -1367,7 +1370,8 @@ class MessengerViewModel(
val made = val made =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { runCatching {
// A fresh X25519 key, not one derived from the passphrase. // A fresh X25519 key, not one derived from the
// passphrase.
val raw = val raw =
FilesIdentity.rawFromPkcs8(X25519.generate().private.encoded) FilesIdentity.rawFromPkcs8(X25519.generate().private.encoded)
store.saveFilesIdentityRecord(FilesIdentity.protect(raw, passphrase)) store.saveFilesIdentityRecord(FilesIdentity.protect(raw, passphrase))
@@ -1797,8 +1801,8 @@ class MessengerViewModel(
fun removeBuddy(userId: Long) = fun removeBuddy(userId: Long) =
act { current -> act { current ->
// The sealed copy first: in off-server mode it IS the roster, and in // The sealed copy first: in off-server mode it IS the roster, and
// mirror mode a remove must not resurrect on the next fetch. // in mirror mode a remove must not resurrect on the next fetch.
current.rosterRemoveLocal(userId) current.rosterRemoveLocal(userId)
if (!current.rosterOffServer()) current.api.removeBuddy(userId) if (!current.rosterOffServer()) current.api.removeBuddy(userId)
current.roster() current.roster()
@@ -1989,8 +1993,9 @@ class MessengerViewModel(
openChat = openChat =
state.openChat?.copy( state.openChat?.copy(
disappearSeconds = current.conversations.disappearSeconds(peerId), disappearSeconds = current.conversations.disappearSeconds(peerId),
// Applying the timer expires anything already past it, so // Applying the timer expires anything already past
// the thread has to be re-read rather than left on screen. // it, so the thread has to be re-read rather than
// left on screen.
lines = current.history(chat.threadId), lines = current.history(chat.threadId),
), ),
error = done.exceptionOrNull()?.let(::describe), error = done.exceptionOrNull()?.let(::describe),
@@ -2254,12 +2259,12 @@ class MessengerViewModel(
CallManager( CallManager(
context = getApplication(), context = getApplication(),
signal = { peerId, signal -> signal = { peerId, signal ->
// Fire and forget on the IO pool: signalling must not block the // Fire and forget on the IO pool: signalling must not block
// WebRTC callback thread it is raised from. // the WebRTC callback thread it is raised from.
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
runCatching { session?.sendCallSignal(peerId, signal) } runCatching { session?.sendCallSignal(peerId, signal) }
// This was `runCatching { }` and nothing else, so every // This was `runCatching { }` and nothing else, so
// failure here vanished. // every failure here vanished.
.onFailure { failure -> .onFailure { failure ->
if (failure is IdentityChangedException) { if (failure is IdentityChangedException) {
calls?.hangUp(notifyPeer = false) calls?.hangUp(notifyPeer = false)
@@ -2297,10 +2302,11 @@ class MessengerViewModel(
when (state) { when (state) {
CallManager.State.ACTIVE -> callWasConnected = true CallManager.State.ACTIVE -> callWasConnected = true
// A new call starting, outgoing OR incoming. placeCall // A new call starting, outgoing OR incoming.
// resets this too, but an incoming call never went // placeCall resets this too, but an incoming call
// through placeCall, and inheriting the previous call's // never went through placeCall, and inheriting the
// `true` filed a missed call as answered. // previous call's `true` filed a missed call as
// answered.
CallManager.State.DIALLING, CallManager.State.DIALLING,
CallManager.State.RINGING, CallManager.State.RINGING,
-> callWasConnected = false -> callWasConnected = false
@@ -2824,8 +2830,8 @@ class MessengerViewModel(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { current.forwardMessage(text, toPeerUserId, toGroup) } runCatching { current.forwardMessage(text, toPeerUserId, toGroup) }
} }
// The forward lands in ANOTHER thread, so the chat on screen is not // The forward lands in ANOTHER thread, so the chat on screen is not the
// the one that changed. // one that changed.
update { update {
it.copy( it.copy(
openChat = openChat =
@@ -2843,9 +2849,8 @@ class MessengerViewModel(
) = viewModelScope.launch { ) = viewModelScope.launch {
val current = session ?: return@launch val current = session ?: return@launch
val mid = line.id ?: return@launch val mid = line.id ?: return@launch
// Whose message it is, which is what the other side needs to find // Whose message it is, which is what the other side needs to find it.
// it: our own id for something we sent, theirs for something they // our own id for something we sent, theirs for something they did.
// did.
val author = val author =
if (line.mine) { if (line.mine) {
current.userId current.userId
@@ -2946,7 +2951,8 @@ class MessengerViewModel(
"Report sent. Whoever runs this server will see it; you will not be " + "Report sent. Whoever runs this server will see it; you will not be " +
"told what they do." "told what they do."
} else { } else {
// A relay may decline reports entirely (reports_not_accepted, 501). // A relay may decline reports entirely (reports_not_accepted,
// 501).
"That report could not be sent. This server may not take reports." "That report could not be sent. This server may not take reports."
} }
update { it.copy(error = message) } update { it.copy(error = message) }
@@ -3317,8 +3323,8 @@ class MessengerViewModel(
} }
result result
.onSuccess { sent -> .onSuccess { sent ->
// The badge has to catch up on SUCCESS too, and this is the case // The badge has to catch up on SUCCESS too, and this is the
// that actually matters. // case that actually matters.
val peer = chat.peerId val peer = chat.peerId
update { update {
it.copy( it.copy(
@@ -3344,9 +3350,9 @@ class MessengerViewModel(
it.copy( it.copy(
error = describe(failure), error = describe(failure),
// A refusal changes what we believe about them, so the // A refusal changes what we believe about them, so the
// badge has to catch up in the same breath, otherwise the // badge has to catch up in the same breath, otherwise
// header still says "pinned" while the send just failed // the header still says "pinned" while the send just
// because it is not. // failed because it is not.
openChat = openChat =
chat.peerId?.let { peer -> chat.peerId?.let { peer ->
it.openChat?.copy(trust = current.conversations.trust.state(peer)) it.openChat?.copy(trust = current.conversations.trust.state(peer))
@@ -3366,14 +3372,14 @@ class MessengerViewModel(
installCallSignalHandler() installCallSignalHandler()
polling = polling =
viewModelScope.launch { viewModelScope.launch {
// Make sure the relay holds a digest for this account before the // Make sure the relay holds a digest for this account before
// first poll. // the first poll.
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { session?.conversations?.ensureDeliveryKey() } runCatching { session?.conversations?.ensureDeliveryKey() }
} }
while (true) { while (true) {
// An offer the background service consumed while the app was // An offer the background service consumed while the app
// off screen. // was off screen.
PendingIncomingCall.take()?.let { parked -> PendingIncomingCall.take()?.let { parked ->
// Stop the ring. // Stop the ring.
runCatching { runCatching {
@@ -3389,14 +3395,15 @@ class MessengerViewModel(
} }
val current = session val current = session
if (current != null) { if (current != null) {
// A story arriving must appear without the user pulling to // A story arriving must appear without the user pulling
// refresh; it is filed by `receive()` and read back here. // to refresh; it is filed by `receive()` and read back
// here.
val received = val received =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val got = runCatching { current.receive() } val got = runCatching { current.receive() }
// Cached plaintext for anything nothing refers to any // Cached plaintext for anything nothing refers
// more, an expired message's photo, a story past its // to any more, an expired message's photo, a
// lifetime. // story past its lifetime.
runCatching { runCatching {
current.purgeOrphanedAttachments(getApplication<Application>().cacheDir) current.purgeOrphanedAttachments(getApplication<Application>().cacheDir)
} }
@@ -3429,15 +3436,16 @@ class MessengerViewModel(
) )
} }
} }
// Read back every tick rather than only when a thread was // Read back every tick rather than only when a thread
// touched: a story files itself outside the thread map, so // was touched: a story files itself outside the thread
// `threads` is empty for a poll that brought one in. // map, so `threads` is empty for a poll that brought
// one in.
val polled = val polled =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { current.stories() } runCatching { current.stories() }
} }
// Skip THIS tick's update on failure rather than leaving // Skip THIS tick's update on failure rather than
// the loop, the next poll may well succeed. // leaving the loop, the next poll may well succeed.
polled.getOrNull()?.let { live -> polled.getOrNull()?.let { live ->
update { state -> update { state ->
if (state.stories.map { it.id } == live.map { it.id }) { if (state.stories.map { it.id } == live.map { it.id }) {
@@ -3447,8 +3455,8 @@ class MessengerViewModel(
} }
} }
} }
// A picture arriving is a control message: it touches no // A picture arriving is a control message: it touches
// thread, so it needs its own signal. // no thread, so it needs its own signal.
if (current.groupNamesUpdated) { if (current.groupNamesUpdated) {
current.groupNamesUpdated = false current.groupNamesUpdated = false
withContext(Dispatchers.IO) { runCatching { current.namedGroups() } } withContext(Dispatchers.IO) { runCatching { current.namedGroups() } }
@@ -3484,8 +3492,8 @@ class MessengerViewModel(
} }
} }
if (threads.isNotEmpty()) { if (threads.isNotEmpty()) {
// Whoever just wrote may not be on the list at all, see // Whoever just wrote may not be on the list at all,
// adoptStrangers. // see adoptStrangers.
adoptStrangers(threads) adoptStrangers(threads)
update { state -> update { state ->
val open = state.openChat val open = state.openChat
@@ -3498,8 +3506,8 @@ class MessengerViewModel(
it it
} }
}, },
// A thread that is already open is being read, // A thread that is already open is being
// so it does not also become unread. // read, so it does not also become unread.
unread = state.unread + threads - setOfNotNull(open?.threadId), unread = state.unread + threads - setOfNotNull(open?.threadId),
// Refreshed here, not only at sign-in. // Refreshed here, not only at sign-in.
remoteAddresses = current.remoteAddresses(), remoteAddresses = current.remoteAddresses(),
@@ -3507,9 +3515,9 @@ class MessengerViewModel(
} }
} }
} }
// A call being set up lives or dies on signalling latency, the // A call being set up lives or dies on signalling latency,
// 5 s cadence made every answer feel geological, and stacked // the 5 s cadence made every answer feel geological, and
// with the same cadence on the other end. // stacked with the same cadence on the other end.
val settingUp = val settingUp =
when (calls?.state) { when (calls?.state) {
CallManager.State.DIALLING, CallManager.State.DIALLING,
@@ -3692,9 +3700,9 @@ internal fun describeFailure(failure: Throwable): String =
"Too many attempts. Wait a few minutes." "Too many attempts. Wait a few minutes."
} }
// Not the underlying message: at this layer it is a Java networking string // Not the underlying message: at this layer it is a Java networking
// ("Failed to connect to /10.0.2.2:443"), which names an address the person // string ("Failed to connect to /10.0.2.2:443"), which names an address
// never typed. // the person never typed.
is AccountsClient.Failure.Transport -> { is AccountsClient.Failure.Transport -> {
"Couldn't reach that server. Check the address and your connection." "Couldn't reach that server. Check the address and your connection."
} }
@@ -6,55 +6,49 @@ import java.io.ByteArrayOutputStream
/** /**
* Strips metadata from an image on its way out, whichever door it left by. * Strips metadata from an image on its way out, whichever door it left by.
*
* 🔴 **A photo sent as a *file* used to keep its GPS; sent as a *photo* it did
* not** (android#17, ios#69). The picker path runs through [ImagePrep], which
* decodes to a bitmap and re-encodes — the output is pixels, with no container
* to carry EXIF. `Session.sendFile` handed the stored bytes straight to
* `sendAttachment`. Somebody who had learned "Flamenet strips location from my
* photos" had learned something true of one button and false of the one beside
* it, and the safer one looked like the shortcut.
*
* ⚠️ **Sniffed, not trusted.** The vault stores no MIME type and a file name is
* a guess in both directions: `holiday.txt` can be a JPEG and `photo.jpg` can be
* text. Deciding by extension would strip the wrong things and miss the right
* ones.
*
* ⚠️ **Only images are touched.** "Send this exact file" is what a file send
* means, and silently rewriting somebody's PDF or spreadsheet would be its own
* surprise. Anything not recognised goes byte-for-byte.
*
* Matches `MetadataScrub` on iOS, format list included.
*/ */
object MetadataScrub { object MetadataScrub {
enum class ImageKind { JPEG, PNG, HEIC, TIFF, WEBP, GIF } enum class ImageKind { JPEG, PNG, HEIC, TIFF, WEBP, GIF }
/** What happened, so the caller can say so rather than guess. */ /** What happened, so the caller can say so rather than guess. */
data class Result(val bytes: ByteArray, val strippedKind: ImageKind?) { data class Result(
val bytes: ByteArray,
val strippedKind: ImageKind?,
) {
// ByteArray in a data class needs these, or equality compares identity. // ByteArray in a data class needs these, or equality compares identity.
override fun equals(other: Any?): Boolean = override fun equals(other: Any?): Boolean =
this === other || (other is Result && this === other || (
bytes.contentEquals(other.bytes) && strippedKind == other.strippedKind) other is Result &&
bytes.contentEquals(other.bytes) && strippedKind == other.strippedKind
)
override fun hashCode(): Int = 31 * bytes.contentHashCode() + (strippedKind?.hashCode() ?: 0) override fun hashCode(): Int = 31 * bytes.contentHashCode() + (strippedKind?.hashCode() ?: 0)
} }
/** Pure, and the reason this is testable without a device. */ /** Pure, and the reason this is testable without a device. */
fun kind(of: ByteArray): ImageKind? { fun kind(of: ByteArray): ImageKind? {
fun at(i: Int): Int? = if (i < of.size) of[i].toInt() and 0xFF else null fun at(i: Int): Int? = if (i < of.size) of[i].toInt() and 0xFF else null
fun matches(bytes: List<Int>, offset: Int = 0): Boolean =
bytes.withIndex().all { (i, b) -> at(offset + i) == b } fun matches(
bytes: List<Int>,
offset: Int = 0,
): Boolean = bytes.withIndex().all { (i, b) -> at(offset + i) == b }
if (matches(listOf(0xFF, 0xD8, 0xFF))) return ImageKind.JPEG if (matches(listOf(0xFF, 0xD8, 0xFF))) return ImageKind.JPEG
if (matches(listOf(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A))) return ImageKind.PNG if (matches(listOf(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A))) return ImageKind.PNG
if (matches(listOf(0x47, 0x49, 0x46, 0x38))) return ImageKind.GIF if (matches(listOf(0x47, 0x49, 0x46, 0x38))) return ImageKind.GIF
// TIFF, either byte order. ⚠️ Also the container many RAW files use, // TIFF, either byte order.
// which is exactly why it should be recognised rather than sent raw.
if (matches(listOf(0x49, 0x49, 0x2A, 0x00)) || if (matches(listOf(0x49, 0x49, 0x2A, 0x00)) ||
matches(listOf(0x4D, 0x4D, 0x00, 0x2A))) return ImageKind.TIFF matches(listOf(0x4D, 0x4D, 0x00, 0x2A))
// RIFF….WEBP — the size field sits between the two markers. ) {
return ImageKind.TIFF
}
// RIFF….WEBP, the size field sits between the two markers.
if (matches(listOf(0x52, 0x49, 0x46, 0x46)) && if (matches(listOf(0x52, 0x49, 0x46, 0x46)) &&
matches(listOf(0x57, 0x45, 0x42, 0x50), 8)) return ImageKind.WEBP matches(listOf(0x57, 0x45, 0x42, 0x50), 8)
) {
return ImageKind.WEBP
}
// ISO-BMFF: "ftyp" at offset 4, then a HEIF brand. // ISO-BMFF: "ftyp" at offset 4, then a HEIF brand.
if (matches(listOf(0x66, 0x74, 0x79, 0x70), 4)) { if (matches(listOf(0x66, 0x74, 0x79, 0x70), 4)) {
val brand = (8..11).mapNotNull { at(it) }.map { it.toChar() }.joinToString("") val brand = (8..11).mapNotNull { at(it) }.map { it.toChar() }.joinToString("")
@@ -68,30 +62,20 @@ object MetadataScrub {
/** /**
* Re-encode an image so nothing but pixels survives; pass anything else * Re-encode an image so nothing but pixels survives; pass anything else
* through unchanged. * through unchanged.
*
* ⚠️ **Re-encoded, not filtered.** Removing the EXIF block by hand leaves
* XMP, IPTC, MakerNote and thumbnail streams, several of which carry
* location independently. Decoding to a bitmap and writing out again is the
* only version of this with no list to keep up to date.
*
* ⚠️ Undecodable bytes are returned as they came. A file that will not
* decode is not necessarily a file with location in it, and losing
* somebody's data to a scrubber is a worse failure than the one being
* fixed.
*/ */
fun scrub(data: ByteArray): Result { fun scrub(data: ByteArray): Result {
val kind = kind(data) ?: return Result(data, null) val kind = kind(data) ?: return Result(data, null)
val raw = runCatching { BitmapFactory.decodeByteArray(data, 0, data.size) }.getOrNull() val raw =
runCatching { BitmapFactory.decodeByteArray(data, 0, data.size) }.getOrNull()
?: return Result(data, null) ?: return Result(data, null)
// 🔴 This function's whole job is to remove the EXIF block — which // This function's whole job is to remove the EXIF block, which holds
// holds the GPS coordinates AND the tag saying which way is up. Turning // the GPS coordinates AND the tag saying which way is up.
// the pixels first is what keeps the second from being collateral
// damage of removing the first. See ImagePrep.orientedFrom.
val bitmap = ImagePrep.orientedFrom(data, raw) val bitmap = ImagePrep.orientedFrom(data, raw)
val out = ByteArrayOutputStream() val out = ByteArrayOutputStream()
// PNG where a lossy pass would be a second surprise; JPEG otherwise, at // PNG where a lossy pass would be a second surprise; JPEG otherwise, at
// a quality high enough not to be visible. // a quality high enough not to be visible.
val format = when (kind) { val format =
when (kind) {
ImageKind.PNG, ImageKind.GIF, ImageKind.TIFF, ImageKind.WEBP -> Bitmap.CompressFormat.PNG ImageKind.PNG, ImageKind.GIF, ImageKind.TIFF, ImageKind.WEBP -> Bitmap.CompressFormat.PNG
ImageKind.JPEG, ImageKind.HEIC -> Bitmap.CompressFormat.JPEG ImageKind.JPEG, ImageKind.HEIC -> Bitmap.CompressFormat.JPEG
} }
@@ -146,9 +146,9 @@ private fun EmojiSection(
Modifier Modifier
.clip(RoundedCornerShape(Radius.small)) .clip(RoundedCornerShape(Radius.small))
.clickable { onPick(e) } .clickable { onPick(e) }
// Scoped by section, as on iOS: the same emoji can sit in // Scoped by section, as on iOS: the same emoji can sit
// Recent AND its own group, and a tag on the glyph alone is // in Recent AND its own group, and a tag on the glyph
// ambiguous to a test. // alone is ambiguous to a test.
.testTag("emoji-$title-$e"), .testTag("emoji-$title-$e"),
) { ) {
Text(e, fontSize = 24.sp, modifier = Modifier.padding(Space.xs)) Text(e, fontSize = 24.sp, modifier = Modifier.padding(Space.xs))
@@ -411,9 +411,9 @@ class Session(
val inUse = val inUse =
try { try {
// The SAME transformation `attachmentBytes` applies when it names // The SAME transformation `attachmentBytes` applies when it
// the file, comparing raw ids against mangled filenames would find // names the file, comparing raw ids against mangled filenames
// no match and delete everything. // would find no match and delete everything.
vault.attachmentIdsInUse().map { it.replace('/', '_') }.toSet() vault.attachmentIdsInUse().map { it.replace('/', '_') }.toSet()
} catch (e: Exception) { } catch (e: Exception) {
return 0 return 0
@@ -917,9 +917,9 @@ class Session(
.put("to", peerUserId) .put("to", peerUserId)
.put("text", text) .put("text", text)
.put("at", at) .put("at", at)
// Present only for group entries: the bubble to restate lives in // Present only for group entries: the bubble to restate lives
// the GROUP thread, not in a 1:1 thread with this member, which may // in the GROUP thread, not in a 1:1 thread with this member,
// not even exist. // which may not even exist.
.also { if (thread != null) it.put("thread", thread) }, .also { if (thread != null) it.put("thread", thread) },
) )
vault.saveOutbox(pending) vault.saveOutbox(pending)
@@ -1549,7 +1549,8 @@ class Session(
val live = val live =
all all
.filter { it.isActive(now) } .filter { it.isActive(now) }
// One place, so every screen agrees about whose stories are hidden. // One place, so every screen agrees about whose stories are
// hidden.
.filter { it.mine || !storiesMuted(it.authorId) } .filter { it.mine || !storiesMuted(it.authorId) }
// Written back so expired bytes leave the device rather than lingering // Written back so expired bytes leave the device rather than lingering
// until the next post. // until the next post.
@@ -1668,9 +1669,9 @@ class Session(
caption = caption.orEmpty(), caption = caption.orEmpty(),
postedAt = System.currentTimeMillis(), postedAt = System.currentTimeMillis(),
seen = true, seen = true,
// The poster sees the same gate their contacts will, rather // The poster sees the same gate their contacts will,
// than a story that looks different to the person who sent // rather than a story that looks different to the
// it. // person who sent it.
streamUrl = streamUrl, streamUrl = streamUrl,
), ),
now, now,
@@ -74,16 +74,17 @@ object StoryMedia {
retriever retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
?.toIntOrNull() ?: 0 ?.toIntOrNull() ?: 0
// A portrait phone clip is stored landscape plus a rotation. // A portrait phone clip is stored landscape plus a
// rotation.
if (rotation == 90 || rotation == 270) { if (rotation == 90 || rotation == 270) {
val t = w val t = w
w = h w = h
h = t h = t
} }
// The rotation is carried out of here now, not just used to // The rotation is carried out of here now, not just used to
// swap the dimensions: rebuilding the container to strip its // swap the dimensions: rebuilding the container to strip
// metadata drops the orientation with everything else, and a // its metadata drops the orientation with everything else,
// portrait story would play on its side. // and a portrait story would play on its side.
Meta(durationMs / 1000.0, w, h, rotation) Meta(durationMs / 1000.0, w, h, rotation)
}.getOrNull().also { runCatching { retriever.release() } } }.getOrNull().also { runCatching { retriever.release() } }
@@ -180,8 +180,8 @@ fun MessengerTheme(content: @Composable () -> Unit) {
) )
} else { } else {
lightColorScheme( lightColorScheme(
// The kit's #2298FF on white is about 3.3:1, fine for a bold // The kit's #2298FF on white is about 3.3:1, fine for a
// button label, not for small text. // bold button label, not for small text.
primary = Palette.Accent, primary = Palette.Accent,
onPrimary = Color.White, onPrimary = Color.White,
secondary = Palette.AccentBright, secondary = Palette.AccentBright,
@@ -165,10 +165,10 @@ private fun ColumnScope.PhraseCard(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(Radius.small)) .clip(RoundedCornerShape(Radius.small))
// `Mist`, not `SurfaceAlt`: the card is already SurfaceAlt, and // `Mist`, not `SurfaceAlt`: the card is already SurfaceAlt,
// two identical greys would leave the words looking like body // and two identical greys would leave the words looking
// text rather than the one thing on this screen worth copying // like body text rather than the one thing on this screen
// onto paper. // worth copying onto paper.
.background(Palette.Mist) .background(Palette.Mist)
.padding(Space.sm) .padding(Space.sm)
.testTag("wallet-phrase"), .testTag("wallet-phrase"),
@@ -253,8 +253,8 @@ class OutboxTest {
org.json org.json
.JSONObject() .JSONObject()
.put("id", opk.id) .put("id", opk.id)
// `Bytes` is internal to :engine, so base64 by hand rather than // `Bytes` is internal to :engine, so base64 by hand rather
// widening its visibility for a test. // than widening its visibility for a test.
.put( .put(
"pub", "pub",
android.util.Base64.encodeToString(opk.publicKey, android.util.Base64.NO_WRAP), android.util.Base64.encodeToString(opk.publicKey, android.util.Base64.NO_WRAP),
@@ -7,21 +7,8 @@ import java.io.File
/** /**
* A photo must arrive the way up it was taken. * A photo must arrive the way up it was taken.
*
* 🔴 Reported from a real phone: a photo auto-rotated. `BitmapFactory` does
* NOT apply EXIF orientation, and every image this app sends is re-encoded to
* strip EXIF — right, because that block carries the GPS coordinates the shot
* was taken at. But the same block carries the tag saying which way is up, and
* a phone camera rarely writes upright pixels: it writes landscape pixels plus
* "turn this 90°". Decode ignoring the tag, then throw the tag away, and every
* portrait photo lands on its side.
*
* ⚠️ These are source-level checks. The rotation itself needs a real Bitmap,
* which is an instrumented test — but the wiring is what regressed and is what
* a unit test can hold. See `PARITY.md`.
*/ */
class PhotoOrientationTest { class PhotoOrientationTest {
private fun source(path: String): String { private fun source(path: String): String {
val file = File("src/main/kotlin/io/flamenet/messenger/app/$path") val file = File("src/main/kotlin/io/flamenet/messenger/app/$path")
assertTrue("missing $path", file.exists()) assertTrue("missing $path", file.exists())
@@ -32,58 +19,74 @@ class PhotoOrientationTest {
@Test @Test
fun preparingAPhotoAppliesTheExifTag() { fun preparingAPhotoAppliesTheExifTag() {
val prep = source("ImagePrep.kt") val prep = source("ImagePrep.kt")
assertTrue("prepare() must orient before encoding", assertTrue(
prep.contains("orientedFrom(source, decodeScaled(source, bounds, maxSide)")) "prepare() must orient before encoding",
assertTrue("must read the tag rather than guess", prep.contains("orientedFrom(source, decodeScaled(source, bounds, maxSide)"),
prep.contains("ExifInterface.TAG_ORIENTATION")) )
assertTrue(
"must read the tag rather than guess",
prep.contains("ExifInterface.TAG_ORIENTATION"),
)
} }
/** 🔴 And so must the scrubber, whose entire job is removing that block. */ /** And so must the scrubber, whose entire job is removing that block. */
@Test @Test
fun scrubbingAppliesTheExifTagBeforeRemovingIt() { fun scrubbingAppliesTheExifTagBeforeRemovingIt() {
assertTrue("scrub() must orient before re-encoding", assertTrue(
source("MetadataScrub.kt").contains("ImagePrep.orientedFrom(data, raw)")) "scrub() must orient before re-encoding",
source("MetadataScrub.kt").contains("ImagePrep.orientedFrom(data, raw)"),
)
} }
/** Images received from a peer carry the tag too. */ /** Images received from a peer carry the tag too. */
@Test @Test
fun renderingAReceivedImageAppliesTheTag() { fun renderingAReceivedImageAppliesTheTag() {
assertTrue("decodeBounded must orient what it decodes", assertTrue(
source("ImagePrep.kt").contains("?.let { orientedFrom(source, it) }")) "decodeBounded must orient what it decodes",
source("ImagePrep.kt").contains("?.let { orientedFrom(source, it) }"),
)
} }
/** /**
* ⚠️ The mirrored tags are rare but real — a front camera writes them, and * The mirrored tags are rare but real, a front camera writes them, and a
* a selfie coming back flipped is the same class of bug as one coming back * selfie coming back flipped is the same class of bug as one coming back
* sideways. * sideways.
*/ */
@Test @Test
fun everyOrientationTagIsHandled() { fun everyOrientationTagIsHandled() {
val prep = source("ImagePrep.kt") val prep = source("ImagePrep.kt")
for (tag in listOf( for (tag in listOf(
"ORIENTATION_ROTATE_90", "ORIENTATION_ROTATE_180", "ORIENTATION_ROTATE_270", "ORIENTATION_ROTATE_90",
"ORIENTATION_FLIP_HORIZONTAL", "ORIENTATION_FLIP_VERTICAL", "ORIENTATION_ROTATE_180",
"ORIENTATION_TRANSPOSE", "ORIENTATION_TRANSVERSE", "ORIENTATION_ROTATE_270",
"ORIENTATION_FLIP_HORIZONTAL",
"ORIENTATION_FLIP_VERTICAL",
"ORIENTATION_TRANSPOSE",
"ORIENTATION_TRANSVERSE",
)) { )) {
assertTrue("$tag is not handled", prep.contains(tag)) assertTrue("$tag is not handled", prep.contains(tag))
} }
} }
/** /**
* 🔴 `windowSoftInputMode="adjustResize"` is a no-op at targetSdk 35: * `windowSoftInputMode="adjustResize"` is a no-op at targetSdk 35: Android
* Android 15 forces edge-to-edge, and an edge-to-edge window does not * 15 forces edge-to-edge, and an edge-to-edge window does not resize for
* resize for the keyboard. The insets have to be handled in Compose. * the keyboard.
*/ */
@Test @Test
fun theKeyboardDoesNotCoverWhatIsBeingTypedInto() { fun theKeyboardDoesNotCoverWhatIsBeingTypedInto() {
val activity = source("MainActivity.kt") val activity = source("MainActivity.kt")
assertTrue("the root must consume the IME inset", assertTrue(
activity.contains("Modifier.fillMaxSize().imePadding()")) "the root must consume the IME inset",
assertTrue("and import it", activity.contains( activity.contains("Modifier.fillMaxSize().imePadding()"),
"import androidx.compose.foundation.layout.imePadding")) )
assertTrue(
"and import it",
activity.contains("import androidx.compose.foundation.layout.imePadding"),
)
// The manifest line is kept — it still applies on older targets — but // The manifest line is kept, it still applies on older targets, but it
// it must never again be the only thing relied on. // must never again be the only thing relied on.
val target = File("build.gradle.kts").readText() val target = File("build.gradle.kts").readText()
assertTrue("this test's premise is targetSdk 35", target.contains("targetSdk = 35")) assertTrue("this test's premise is targetSdk 35", target.contains("targetSdk = 35"))
} }
@@ -137,16 +137,17 @@ class ScreenshotTest {
at = 1_756_300_380_000, at = 1_756_300_380_000,
delivery = Session.Delivery.DELIVERED, delivery = Session.Delivery.DELIVERED,
), ),
// The state that used to be invisible: sent while they were signed // The state that used to be invisible: sent while they were
// out, so no device took it. // signed out, so no device took it.
Session.Line( Session.Line(
"Are you around later?", "Are you around later?",
mine = true, mine = true,
at = 1_756_300_440_000, at = 1_756_300_440_000,
delivery = Session.Delivery.PENDING, delivery = Session.Delivery.PENDING,
), ),
// A sticker draws bare, no bubble, so this catches the case where // A sticker draws bare, no bubble, so this catches the case
// it falls through to the text path and renders as an empty one. // where it falls through to the text path and renders as an
// empty one.
Session.Line( Session.Line(
"", "",
mine = false, mine = false,
@@ -255,9 +256,10 @@ class ScreenshotTest {
), ),
buddiesLoaded = true, buddiesLoaded = true,
groupsLoaded = true, groupsLoaded = true,
// The row's right-hand column and second line only exist when there is // The row's right-hand column and second line only exist when there
// a thread behind them, so the render has to have one or it photographs // is a thread behind them, so the render has to have one or it
// the empty state and looks like the change never landed. // photographs the empty state and looks like the change never
// landed.
previews = previews =
mapOf( mapOf(
32L to 32L to
@@ -170,8 +170,8 @@ class AccountsClient(
.post(body.toString().toRequestBody(JSON)) .post(body.toString().toRequestBody(JSON))
.header("Accept", "application/json") .header("Accept", "application/json")
.build(), .build(),
// Only these two actually present a passphrase, so only these two can // Only these two actually present a passphrase, so only these two
// meaningfully report one as wrong. // can meaningfully report one as wrong.
carriesCredentials = path == "sessions" || path == "accounts", carriesCredentials = path == "sessions" || path == "accounts",
) )
@@ -143,8 +143,8 @@ class Conversations(
myUserId: Long, myUserId: Long,
mentions: List<Int> = emptyList(), mentions: List<Int> = emptyList(),
/** /**
* Minted by the caller, not here, because the caller also * Minted by the caller, not here, because the caller also writes the
* writes the sender's own copy and queues the retries. * sender's own copy and queues the retries.
*/ */
messageId: String? = null, messageId: String? = null,
): Sent { ): Sent {
@@ -593,8 +593,8 @@ class Conversations(
servedFnid = keys.fnid ?: servedFnid servedFnid = keys.fnid ?: servedFnid
keys.fnid?.let { trust.rememberFnid(toUserId, it) } keys.fnid?.let { trust.rememberFnid(toUserId, it) }
val full = keys.bundles.associateBy { it.deviceId } val full = keys.bundles.associateBy { it.deviceId }
// Union: every device the directory knows, carrying a full bundle // Union: every device the directory knows, carrying a full
// where the key route returned one. // bundle where the key route returned one.
stubs.map { full[it.deviceId] ?: it } stubs.map { full[it.deviceId] ?: it }
} }
// The send path had no logging at all, and that is why a message that // The send path had no logging at all, and that is why a message that
@@ -36,8 +36,9 @@ object LinkPreviewFetcher {
.Builder() .Builder()
.url(url) .url(url)
.header("Accept", "text/html") .header("Accept", "text/html")
// Identifies as a link preview rather than impersonating a browser. // Identifies as a link preview rather than impersonating a
// a site that wants to refuse these should be able to. // browser. a site that wants to refuse these should be able
// to.
.header("User-Agent", "FlamenetLinkPreview/1.0") .header("User-Agent", "FlamenetLinkPreview/1.0")
.build() .build()
http.newCall(request).execute().use { response -> http.newCall(request).execute().use { response ->
@@ -288,8 +288,8 @@ class LocalVault(
.filter { it.parentId == parentId } .filter { it.parentId == parentId }
.map { Entry(it, nameOf(it), isMissing = isMissing(it)) } .map { Entry(it, nameOf(it), isMissing = isMissing(it)) }
.sortedWith( .sortedWith(
// Folders first, then by name; unreadable rows last, where they // Folders first, then by name; unreadable rows last, where
// are least in the way of the ones you can act on. // they are least in the way of the ones you can act on.
compareByDescending<Entry> { it.isFolder } compareByDescending<Entry> { it.isFolder }
.thenBy { it.name == null } .thenBy { it.name == null }
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" }, .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" },
@@ -171,8 +171,8 @@ class MessengerApi(
} }
/** /**
* Changing the password invalidates other credentials, which is the point * Changing the password invalidates other credentials of changing it, so
* of changing it, so this device has to sign in again afterwards. * this device has to sign in again afterwards.
*/ */
fun updatePassword(password: String) { fun updatePassword(password: String) {
raw("api/flamenet/v1/messenger/me", "PUT", JSONObject().put("password", password)) raw("api/flamenet/v1/messenger/me", "PUT", JSONObject().put("password", password))
@@ -61,9 +61,9 @@ class RelayAuth(
) )
return null return null
} ?: run { } ?: run {
// A non-2xx that is not 404 arrives here as null, not as a throw, // A non-2xx that is not 404 arrives here as null, not as a
// so this branch is NOT unreachable, it was the quietest path of // throw, so this branch is NOT unreachable, it was the quietest
// the three. // path of the three.
warn( warn(
"no token: /account/token answered a non-success " + "no token: /account/token answered a non-success " +
"status; falling back to Basic, which /e2e/* refuses.", "status; falling back to Basic, which /e2e/* refuses.",
@@ -106,9 +106,9 @@ internal object Wire {
.getDecoder() .getDecoder()
.decode(text) .decode(text)
// / The engine keeps its own `Bytes.sha256` `internal`, for the reason // / The engine keeps its own `Bytes.sha256` `internal`, for the reason /
// / above: its public surface is the protocol, not its utilities. §21.4 // above: its public surface is the protocol, not its utilities. §21.4 /
// / needs one here to hash a capability before uploading it, and SHA-256 has // needs one here to hash a capability before uploading it, and SHA-256 has
// / one right answer, so a second call site cannot drift. // / one right answer, so a second call site cannot drift.
fun sha256(data: ByteArray): ByteArray = fun sha256(data: ByteArray): ByteArray =
java.security.MessageDigest java.security.MessageDigest
@@ -442,10 +442,10 @@ class Trust(
when { when {
pin.size > head.size -> false pin.size > head.size -> false
// shrank // shrank.
pin.size == head.size -> pin.root == head.root pin.size == head.size -> pin.root == head.root
// same size, same root // same size, same root.
else -> consistent(pin.size, head.size, pin.root, head.root) else -> consistent(pin.size, head.size, pin.root, head.root)
} }
} catch (_: Exception) { } catch (_: Exception) {
@@ -497,8 +497,8 @@ class Trust(
val issuer = val issuer =
when (val verdict = RelayIdentityPin.check(offered, vault.keyLogIssuerPin())) { when (val verdict = RelayIdentityPin.check(offered, vault.keyLogIssuerPin())) {
is RelayIdentityPin.Verdict.NotPublished -> { is RelayIdentityPin.Verdict.NotPublished -> {
// Nothing published and nothing pinned: a weaker deployment, // Nothing published and nothing pinned: a weaker
// not an attack. // deployment, not an attack.
vault.keyLogIssuerPin() ?: return vault.keyLogIssuerPin() ?: return
} }
@@ -70,7 +70,8 @@ object UpdateCheck {
val json = JSONObject(body) val json = JSONObject(body)
val code = json.optInt("versionCode", 0) val code = json.optInt("versionCode", 0)
// A manifest that cannot say which build it describes is not one. // A manifest that cannot say which build it describes is
// not one.
if (code <= 0) return null if (code <= 0) return null
Release( Release(
versionCode = code, versionCode = code,
@@ -89,8 +89,9 @@ class DeliveryTokenTest {
"/e2e/delivery-tokens" in path -> { "/e2e/delivery-tokens" in path -> {
val b = JSONObject(request.body.readUtf8()) val b = JSONObject(request.body.readUtf8())
// Revoke first, so a client cutting one contact off while // Revoke first, so a client cutting one contact off
// topping another up in the same call keeps what it added. // while topping another up in the same call keeps what
// it added.
b.optJSONArray("revoke")?.let { a -> b.optJSONArray("revoke")?.let { a ->
(0 until a.length()).forEach { held.remove(a.getString(it)) } (0 until a.length()).forEach { held.remove(a.getString(it)) }
} }
@@ -77,9 +77,9 @@ class GossipHeadSentTest {
override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse { override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse {
val path = request.path.orEmpty() val path = request.path.orEmpty()
return when { return when {
// `contains`, not `startsWith`: every route is under the // `contains`, not `startsWith`: every route is under
// REST mount, so the recorded path begins with that prefix // the REST mount, so the recorded path begins with that
// rather than with `/e2e`. // prefix rather than with `/e2e`.
"/e2e/log/" in path -> MockResponse().setResponseCode(404) "/e2e/log/" in path -> MockResponse().setResponseCode(404)
path.contains("/e2e/messages") -> json("""{"ok":true}""") path.contains("/e2e/messages") -> json("""{"ok":true}""")
@@ -125,10 +125,10 @@ class MultiMailboxPollTest {
relay = backupHost, relay = backupHost,
username = "me", username = "me",
appPassword = "pw", appPassword = "pw",
// The record names a hostname and cannot carry a port; how this // The record names a hostname and cannot carry a port; how
// device DIALS that host is a separate field, which is what lets a // this device DIALS that host is a separate field, which is
// test, or a self-hoster on a nonstandard port, work without the // what lets a test, or a self-hoster on a nonstandard port,
// record lying about its own name. // work without the record lying about its own name.
baseUrl = backup.url("/").toString(), baseUrl = backup.url("/").toString(),
), ),
), ),
@@ -103,9 +103,9 @@ object AccountKdf {
params: Params = Params.PBKDF2_V1, params: Params = Params.PBKDF2_V1,
): ByteArray = ): ByteArray =
SecureBytes.withSecret(master(passphrase, salt, params)) { ikm -> SecureBytes.withSecret(master(passphrase, salt, params)) { ikm ->
// ios#39, the stretched master is an intermediate nobody needs after // ios#39, the stretched master is an intermediate nobody needs
// the expansion, and it is the most valuable thing in this file: every // after the expansion, and it is the most valuable thing in this
// other secret is derived from it. // file: every other secret is derived from it.
Hkdf.derive( Hkdf.derive(
ikm = ikm, ikm = ikm,
salt = ByteArray(0), salt = ByteArray(0),
@@ -372,7 +372,8 @@ data class Content(
): String = ): String =
assemble( assemble(
kind = ref.kind, kind = ref.kind,
// A file envelope ALWAYS carries a caption, even with none written. // A file envelope ALWAYS carries a caption, even with none
// written.
caption = caption =
caption?.takeIf { it.isNotEmpty() } caption?.takeIf { it.isNotEmpty() }
?: "📎 " ?: "📎 "
@@ -781,8 +782,8 @@ data class Content(
} }
?: return Content( ?: return Content(
// The sender meant to say something; a message that // The sender meant to say something; a message that
// silently vanishes is the worst way to meet a version this // silently vanishes is the worst way to meet a version
// client does not know. // this client does not know.
caption.ifEmpty { "[unsupported attachment]" }, caption.ifEmpty { "[unsupported attachment]" },
kind = kind, kind = kind,
raw = json, raw = json,
@@ -856,11 +857,11 @@ object Attachment {
// Audio always; a story carries one when it is a video. // Audio always; a story carries one when it is a video.
.also { if (duration > 0.0) it.put("dur", duration) } .also { if (duration > 0.0) it.put("dur", duration) }
// Without this the vault round-trip erased every expiry. // Without this the vault round-trip erased every expiry.
// saveStories persists refs through here, loadStories reads "exp" // saveStories persists refs through here, loadStories reads
// back out of this very JSON, and a field never written reloads as // "exp" back out of this very JSON, and a field never written
// 0, which renders as "no expiry" AND, because isActive treats 0 as // reloads as 0, which renders as "no expiry" AND, because
// immortal, means an expired story's bytes were never dropped on // isActive treats 0 as immortal, means an expired story's bytes
// write. // were never dropped on write.
.also { if (expires > 0) it.put("exp", expires) } .also { if (expires > 0) it.put("exp", expires) }
companion object { companion object {
@@ -80,10 +80,11 @@ class DeviceIdentity private constructor(
Ed25519.fromSeed(Bytes.unb64(archive.getJSONObject("ikSig").getString("seed"))), Ed25519.fromSeed(Bytes.unb64(archive.getJSONObject("ikSig").getString("seed"))),
signedPreKey = x(archive.getJSONObject("spk")), signedPreKey = x(archive.getJSONObject("spk")),
signedPreKeyId = archive.getInt("spkId"), signedPreKeyId = archive.getInt("spkId"),
// 0 means "written before this field existed", and the honest // 0 means "written before this field existed", and the
// reading of an unknown age is "old", a device that rotates // honest reading of an unknown age is "old", a device that
// once on first launch after an upgrade is harmless, one that // rotates once on first launch after an upgrade is
// never rotates because it defaulted to `now` is not. // harmless, one that never rotates because it defaulted to
// `now` is not.
signedPreKeyCreatedAt = archive.optLong("spkAt", 0), signedPreKeyCreatedAt = archive.optLong("spkAt", 0),
) )
identity.nextPreKeyId = archive.optInt("nextId", 1) identity.nextPreKeyId = archive.optInt("nextId", 1)
@@ -135,14 +135,14 @@ class E2ESession private constructor(
val session = val session =
E2ESession( E2ESession(
// The seed of the PQ prekey the initiator used, so the first // The seed of the PQ prekey the initiator used, so the
// inbound ratchet step can decapsulate to it. // first inbound ratchet step can decapsulate to it.
ratchet = DoubleRatchet.responder(sharedSecret, signedPreKey, pq?.first), ratchet = DoubleRatchet.responder(sharedSecret, signedPreKey, pq?.first),
associatedData = associatedData =
X3DH.associatedData(theirIdentitySigning, identity.identitySigningPublic, pq?.second), X3DH.associatedData(theirIdentitySigning, identity.identitySigningPublic, pq?.second),
pendingHandshake = null, pendingHandshake = null,
// Honour what the initiator declared; this could never return // Honour what the initiator declared; this could never
// 3. // return 3.
version = version =
when { when {
pq == null -> 1 pq == null -> 1
@@ -161,9 +161,10 @@ class E2ESession private constructor(
pendingHandshake = archive.optJSONObject("pending"), pendingHandshake = archive.optJSONObject("pending"),
version = archive.optInt("version", 1), version = archive.optInt("version", 1),
// Archived for the same reason `pending` is: it is part of the // Archived for the same reason `pending` is: it is part of the
// handshake block, and a session restored without it would go on // handshake block, and a session restored without it would go
// sending prekey envelopes with the post-quantum half missing, // on sending prekey envelopes with the post-quantum half
// which the peer would answer by deriving a different key. // missing, which the peer would answer by deriving a different
// key.
pq = pq =
archive.optJSONObject("pq")?.let { archive.optJSONObject("pq")?.let {
PQEnvelope( PQEnvelope(
@@ -145,6 +145,7 @@ object SealedSender {
* @param groupId with [groupName], tells this contact what a group is * @param groupId with [groupName], tells this contact what a group is
* called. * called.
*/ */
/** /**
* §8.9b, hand a member their tag and the group key. * §8.9b, hand a member their tag and the group key.
*/ */
@@ -281,8 +281,8 @@ class Vault(
username = it.getString("username"), username = it.getString("username"),
appPassword = it.getString("appPassword"), appPassword = it.getString("appPassword"),
userId = it.getLong("userId"), userId = it.getLong("userId"),
// Optional read: a credential written before addresses existed is // Optional read: a credential written before addresses existed
// still a valid credential, and must not fail to load. // is still a valid credential, and must not fail to load.
fnid = it.optString("fnid").ifEmpty { null }, fnid = it.optString("fnid").ifEmpty { null },
) )
} }
@@ -349,8 +349,8 @@ class Vault(
Trust( Trust(
identities = (0 until a.length()).map { a.getString(it) }, identities = (0 until a.length()).map { a.getString(it) },
verified = o.optBoolean("verified", false), verified = o.optBoolean("verified", false),
// `"null"` is rejected, and that is not paranoia, records written // `"null"` is rejected, and that is not paranoia, records
// that way are already on phones. // written that way are already on phones.
fnid = o.optString("fnid").takeIf { it.isNotEmpty() && it != "null" }, fnid = o.optString("fnid").takeIf { it.isNotEmpty() && it != "null" },
changed = o.optBoolean("changed", false), changed = o.optBoolean("changed", false),
) )
@@ -31,8 +31,8 @@ class MailboxStateTest {
private val storage = MapStorage() private val storage = MapStorage()
// / Held so a SECOND vault can be opened over the same storage, which is // / Held so a SECOND vault can be opened over the same storage, which is /
// / what a restart is, and the only way to test that anything persisted. // what a restart is, and the only way to test that anything persisted.
private val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() private val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private val vault = Vault(storage, key) private val vault = Vault(storage, key)
@@ -52,8 +52,8 @@ class QrCodeRenderForDecodingTest {
"flamenet:fnid:kx7q2m", // v1 "flamenet:fnid:kx7q2m", // v1
"flamenet:fnid:" + "a".repeat(52) + "@relay1.flamenet.io", // a real address "flamenet:fnid:" + "a".repeat(52) + "@relay1.flamenet.io", // a real address
"flamenet:fnid:" + "b".repeat(52) + "@" + "long".repeat(8) + ".example.com", "flamenet:fnid:" + "b".repeat(52) + "@" + "long".repeat(8) + ".example.com",
// Exactly full, version by version: data codewords minus the mode // Exactly full, version by version: data codewords minus the
// nibble and character count. // mode nibble and character count.
"x".repeat(14), // v1 "x".repeat(14), // v1
"x".repeat(26), // v2 "x".repeat(26), // v2
"x".repeat(42), // v3 "x".repeat(42), // v3
@@ -115,7 +115,8 @@ private fun handle(req: JSONObject): JSONObject =
decrypt(req) decrypt(req)
} }
// --- the session layer, which is what an app actually uses ------------. // --- the session layer, which is what an app actually uses
// ------------.
"session_device" -> { "session_device" -> {
sessionDevice(req) sessionDevice(req)
} }
@@ -55,9 +55,9 @@ class BridgeLineTest {
"obfs4 cert=abc" to BridgeLineProblem.NoAddress, "obfs4 cert=abc" to BridgeLineProblem.NoAddress,
line(address = "198.51.100.7:notaport") to BridgeLineProblem.BadPort("notaport"), line(address = "198.51.100.7:notaport") to BridgeLineProblem.BadPort("notaport"),
"obfs4 198.51.100.7:4444 0123456789ABCDEF" to BridgeLineProblem.NoCert, "obfs4 198.51.100.7:4444 0123456789ABCDEF" to BridgeLineProblem.NoCert,
// No spaces: fields split on whitespace, so a cert with a space is // No spaces: fields split on whitespace, so a cert with a space
// truncated at the space and is a LENGTH problem, not an encoding // is truncated at the space and is a LENGTH problem, not an
// one. // encoding one.
line(cert = "!!!!not@@@@valid####base64$$$$") to BridgeLineProblem.CertNotReadable, line(cert = "!!!!not@@@@valid####base64$$$$") to BridgeLineProblem.CertNotReadable,
) )
for ((input, expected) in cases) { for ((input, expected) in cases) {
@@ -36,7 +36,7 @@ class HandshakeTest {
try { try {
return Obfs4Handshake.parseClientMessage(wire.copyOfRange(0, n), key(o), o.getLong("epoch_hour")) to n return Obfs4Handshake.parseClientMessage(wire.copyOfRange(0, n), key(o), o.getLong("epoch_hour")) to n
} catch (_: Exception) { } catch (_: Exception) {
// keep reading // keep reading.
} }
} }
return null return null