Merge main

This commit is contained in:
Paul Hitt
2026-09-08 20:21:39 -04:00
5 changed files with 557 additions and 278 deletions
+4
View File
@@ -184,6 +184,10 @@ 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
// camera decodes on its side. This reads the tag so the rotation can be
// applied before re-encoding drops it. See ImagePrep.orientedFrom.
implementation("androidx.exifinterface:exifinterface:1.3.7")
implementation(platform("androidx.compose:compose-bom:2024.12.01")) implementation(platform("androidx.compose:compose-bom:2024.12.01"))
implementation("androidx.compose.material3:material3") implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended") implementation("androidx.compose.material:material-icons-extended")
@@ -2,6 +2,9 @@ package io.flamenet.messenger.app
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Matrix
import androidx.exifinterface.media.ExifInterface
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
/** /**
@@ -40,7 +43,7 @@ object ImagePrep {
BitmapFactory.decodeByteArray(source, 0, source.size, bounds) BitmapFactory.decodeByteArray(source, 0, source.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val decoded = decodeScaled(source, bounds, maxSide) ?: return null val decoded = orientedFrom(source, decodeScaled(source, bounds, maxSide) ?: return null)
// Steps, best first. // Steps, best first.
val attempts = val attempts =
@@ -83,7 +86,9 @@ object ImagePrep {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(source, 0, source.size, bounds) BitmapFactory.decodeByteArray(source, 0, source.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
return decodeScaled(source, bounds, maxSide) // Same tag, same problem: an image received from a peer that still
// carries EXIF must be turned before it is drawn.
return decodeScaled(source, bounds, maxSide)?.let { orientedFrom(source, it) }
} }
private fun decodeScaled( private fun decodeScaled(
@@ -97,6 +102,57 @@ object ImagePrep {
return BitmapFactory.decodeByteArray(source, 0, source.size, opts) return BitmapFactory.decodeByteArray(source, 0, source.size, opts)
} }
/**
* 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 {
val tag = runCatching {
ExifInterface(ByteArrayInputStream(source))
.getAttributeInt(ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL)
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
val matrix = Matrix()
when (tag) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
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
// flipped is the same class of bug as one coming back sideways.
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f); matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(270f); matrix.postScale(-1f, 1f)
}
else -> return bitmap
}
val turned = runCatching {
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}.getOrNull() ?: return bitmap
if (turned !== bitmap) bitmap.recycle()
return turned
}
/** Scale so the longest side is at most [maxSide]. Never enlarges. */ /** Scale so the longest side is at most [maxSide]. Never enlarges. */
private fun scaleTo( private fun scaleTo(
bitmap: Bitmap, bitmap: Bitmap,
@@ -13,6 +13,7 @@ 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
@@ -25,6 +26,7 @@ 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
@@ -60,28 +62,47 @@ 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 there * silently cannot show — the callback never fires and the lock screen sits
* looking broken. * there looking broken. `FragmentActivity` extends `ComponentActivity`, so
* `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. // ⚠️ Runtime permission on API 33+, which is this app's minSdk. Without
// 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)
} }
@@ -89,8 +110,25 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
setContent { setContent {
MessengerTheme { MessengerTheme {
Surface(modifier = Modifier.fillMaxSize()) { // 🔴 **`android:windowSoftInputMode="adjustResize"` stopped doing
// The gate is around Root, not inside it. // anything at targetSdk 35.** Android 15 forces apps that target
// 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()) {
// 🔴 **The gate is around Root, not inside it.** Drawn over
// 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) {
@@ -98,24 +136,18 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
onUnlock = { onUnlock = {
lock.authenticate( lock.authenticate(
activity = this@MainActivity, activity = this@MainActivity,
onSuccess = { onSuccess = { lock.noteUnlocked(); locked = false },
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 = { onSuccess = { lock.noteUnlocked(); locked = false },
lock.noteUnlocked()
locked = false
},
onFailure = {}, onFailure = {},
) )
} }
@@ -125,20 +157,12 @@ 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 = val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
androidx.lifecycle.LifecycleEventObserver { _, event ->
when (event) { when (event) {
androidx.lifecycle.Lifecycle.Event.ON_STOP -> { androidx.lifecycle.Lifecycle.Event.ON_STOP -> lock.noteLeft()
lock.noteLeft() androidx.lifecycle.Lifecycle.Event.ON_START ->
}
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)
@@ -149,7 +173,10 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
} }
} }
// The handover: exactly one poller at a time. // 🔴 **The handover: exactly one poller at a time.** The ViewModel polls
// 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)
@@ -157,7 +184,9 @@ 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. // ⚠️ Only when there is a session to receive for. Starting it signed
// 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)
} }
} }
@@ -171,8 +200,7 @@ 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),
@@ -184,90 +212,122 @@ private fun Root(model: MessengerViewModel = viewModel()) {
else -> Unit else -> Unit
} }
} }
}
// NOT scrollable. // NOT scrollable. A `LazyColumn` inside a vertically scrolling
is MessengerViewModel.Phase.SignedIn -> { // Column is measured with infinite height and crashes outright —
// 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. // 🔴 **Above the screen switch, not inside one branch.** An
// 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 the // 🔴 **Here, above the screen switch, for the same reason
// call banner is. // the call banner is.** Nothing installs this app but the
// 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( ChatScreen(model, chat, current.error, current.buddies, current.avatars,
model, myUserId = current.userId, groups = current.groups)
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 not. // 🔴 **The content area is weighted, and the tab bar is
// 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 -> { MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current)
SignedIn(model, current) MessengerViewModel.Phase.Screen.MESSAGES ->
}
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. // implementation detail. What it produced was
MessengerViewModel.Phase.Screen.FILES -> { // somebody pressing "wallet" and arriving at a
// 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( private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedOut) {
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.
@@ -281,15 +341,24 @@ private fun ColumnScope.SignIn(
// 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 heading. // 🔴 **The kit's Welcome screen, which is a masthead and not a form
// 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),
@@ -301,8 +370,8 @@ private fun ColumnScope.SignIn(
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,
@@ -319,7 +388,9 @@ private fun ColumnScope.SignIn(
modifier = Modifier.fillMaxWidth().testTag("relay"), modifier = Modifier.fillMaxWidth().testTag("relay"),
) )
// Say which kind of server this is BEFORE the passphrase is typed. // Say which kind of server this is BEFORE the passphrase is typed. Whether
// 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,
@@ -337,16 +408,18 @@ private fun ColumnScope.SignIn(
modifier = Modifier.fillMaxWidth().testTag("handle"), modifier = Modifier.fillMaxWidth().testTag("handle"),
) )
// Only a relay keeping its own accounts asks for this, it is how the relay // Only a relay keeping its own accounts asks for this — it is how the
// sends a reset code, and it is the only party that ever sees it. // relay 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(
KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth().testTag("email"), modifier = Modifier.fillMaxWidth().testTag("email"),
) )
Text( Text(
@@ -361,32 +434,31 @@ private fun ColumnScope.SignIn(
label = { Text("Passphrase") }, label = { Text("Passphrase") },
singleLine = true, singleLine = true,
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
keyboardOptions = keyboardOptions = KeyboardOptions(
KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
modifier = Modifier.fillMaxWidth().testTag("passphrase"), modifier = Modifier.fillMaxWidth().testTag("passphrase"),
) )
if (registering) { if (registering) {
// The age gate. // The age gate. Neutral, and stated honestly: the year is checked on
// 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(
KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), 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( Text(it, color = MaterialTheme.colorScheme.error,
it, modifier = Modifier.testTag("age-error"))
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag("age-error"),
)
} }
} }
@@ -394,35 +466,28 @@ private fun ColumnScope.SignIn(
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 screen, // ⚠️ 240 wide and centred, which is the kit's primary button on this
// not a full-width slab. // screen — not a full-width slab. A button that spans the phone reads as
// 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 passed // The most conservative reading — a birthday not yet
// this year, because "maybe old enough" isn't. // passed this year — because "maybe old enough" isn't.
val year = birthYear.toIntOrNull() ?: 0 val year = birthYear.toIntOrNull() ?: 0
val age = val age = java.util.Calendar.getInstance().get(java.util.Calendar.YEAR) - year
java.util.Calendar
.getInstance()
.get(java.util.Calendar.YEAR) - year
if (year < 1900 || age < 14) { if (year < 1900 || age < 14) {
ageError = ageError = if (year < 1900) "Enter your year of birth."
if (year < 1900) { else "You must be at least 13 to use Flamenet Messenger."
"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 = enabled = !phase.busy && handle.isNotBlank() && passphrase.isNotBlank() &&
!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),
@@ -458,12 +523,14 @@ private fun ColumnScope.SignIn(
/** /**
* 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( private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String) {
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("") }
@@ -510,19 +577,29 @@ private fun ColumnScope.ResetPassword(
/** /**
* 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(
val screen: MessengerViewModel.Phase.Screen, data class Tab(val screen: MessengerViewModel.Phase.Screen, val label: String, val tag: String)
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> = fun tabs(storiesHidden: Boolean): List<Tab> = listOfNotNull(
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"),
@@ -535,6 +612,17 @@ 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(
@@ -542,10 +630,13 @@ 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. // 🔴 **A floating pill, not a full-width strip.** The kit's tab bar is a
// 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))
@@ -557,24 +648,20 @@ 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 = val badge = when (tab.screen) {
when (tab.screen) { // The inbox badge lives on Messages — Home is the roster, this
// The inbox badge lives on Messages. // is where new mail is read.
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. // clear. Opening the tab is what "seeing" them means.
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) = val (icon, active) = when (tab.screen) {
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
@@ -590,6 +677,16 @@ 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(
@@ -604,33 +701,31 @@ 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`. // 🔴 `navigateTo`, not `showScreen`. Wallet and Files each have to
// 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 = tint = if (selected) MaterialTheme.colorScheme.primary
if (selected) { else Palette.SecondaryText,
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)
@@ -642,7 +737,9 @@ private fun RowScope.NavButton(
} }
/** /**
* Internal rather than private ONLY so the JVM screenshot suite can render it. * ⚠️ Internal rather than private ONLY so the JVM screenshot suite can render
* 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(
@@ -654,24 +751,28 @@ 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 = val askCamera = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.compose.rememberLauncherForActivityResult( androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
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 // ⚠️ The contact screen REPLACES the list rather than sitting under it,
// same way an open chat does. // the same way an open chat does. A detail view stacked below a list is
// 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 stories. // 🔴 **Home is two things now, on purpose: your people, and their
// 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,
@@ -682,22 +783,18 @@ 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. // Scan their Address screen — iOS's home has the same button. A scan
// fills the Add-contact field and stops; nothing is added unlooked-at.
BarAction( 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 = val has = androidx.core.content.ContextCompat.checkSelfPermission(
androidx.core.content.ContextCompat.checkSelfPermission( scanContext, android.Manifest.permission.CAMERA
scanContext,
android.Manifest.permission.CAMERA,
) == android.content.pm.PackageManager.PERMISSION_GRANTED ) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (has) { if (has) scanning = true
scanning = true else askCamera.launch(android.Manifest.permission.CAMERA)
} else {
askCamera.launch(android.Manifest.permission.CAMERA)
}
}, },
) )
BarAction( BarAction(
@@ -713,7 +810,12 @@ 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
} }
@@ -760,6 +862,11 @@ 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(
@@ -769,8 +876,7 @@ 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)
@@ -778,7 +884,9 @@ private fun BarAction(
.testTag(tag), .testTag(tag),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
// An `Icon`, not a glyph in a text run. // 🔴 An `Icon`, not a glyph in a text run. See `KitIcons.kt` — the bar
// 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,
@@ -788,6 +896,7 @@ private fun BarAction(
} }
} }
@Composable @Composable
private fun ColumnScope.VaultUnreadable( private fun ColumnScope.VaultUnreadable(
model: MessengerViewModel, model: MessengerViewModel,
@@ -804,11 +913,12 @@ 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`. // 🔴 `discardUnreadableVault`, NOT `signOut`. `signOut()` defaults to
Button( // keeping the device, so it left the unreadable vault in place and the next
onClick = model::discardUnreadableVault, // restore came straight back here — the only button on a dead-end screen
modifier = Modifier.testTag("start-fresh"), // did nothing but redraw it.
) { Button(onClick = model::discardUnreadableVault,
modifier = Modifier.testTag("start-fresh")) {
Text("Erase and start fresh") Text("Erase and start fresh")
} }
} }
@@ -6,49 +6,55 @@ 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( data class Result(val bytes: ByteArray, val strippedKind: ImageKind?) {
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 || ( this === other || (other is Result &&
other is Result && bytes.contentEquals(other.bytes) && strippedKind == other.strippedKind)
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 =
fun matches( bytes.withIndex().all { (i, b) -> at(offset + i) == b }
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. // TIFF, either byte order. ⚠️ Also the container many RAW files use,
// 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)) matches(listOf(0x4D, 0x4D, 0x00, 0x2A))) return ImageKind.TIFF
) { // 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) matches(listOf(0x57, 0x45, 0x42, 0x50), 8)) return ImageKind.WEBP
) {
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("")
@@ -62,17 +68,30 @@ 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 bitmap = val raw = runCatching { BitmapFactory.decodeByteArray(data, 0, data.size) }.getOrNull()
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
// holds the GPS coordinates AND the tag saying which way is up. Turning
// 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 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 = val format = when (kind) {
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
} }
@@ -0,0 +1,90 @@
package io.flamenet.messenger.app
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
/**
* 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 {
private fun source(path: String): String {
val file = File("src/main/kotlin/io/flamenet/messenger/app/$path")
assertTrue("missing $path", file.exists())
return file.readText()
}
/** The send path must turn the pixels before it drops the tag. */
@Test
fun preparingAPhotoAppliesTheExifTag() {
val prep = source("ImagePrep.kt")
assertTrue("prepare() must orient before encoding",
prep.contains("orientedFrom(source, decodeScaled(source, bounds, maxSide)"))
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. */
@Test
fun scrubbingAppliesTheExifTagBeforeRemovingIt() {
assertTrue("scrub() must orient before re-encoding",
source("MetadataScrub.kt").contains("ImagePrep.orientedFrom(data, raw)"))
}
/** Images received from a peer carry the tag too. */
@Test
fun renderingAReceivedImageAppliesTheTag() {
assertTrue("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
* a selfie coming back flipped is the same class of bug as one coming back
* sideways.
*/
@Test
fun everyOrientationTagIsHandled() {
val prep = source("ImagePrep.kt")
for (tag in listOf(
"ORIENTATION_ROTATE_90", "ORIENTATION_ROTATE_180", "ORIENTATION_ROTATE_270",
"ORIENTATION_FLIP_HORIZONTAL", "ORIENTATION_FLIP_VERTICAL",
"ORIENTATION_TRANSPOSE", "ORIENTATION_TRANSVERSE",
)) {
assertTrue("$tag is not handled", prep.contains(tag))
}
}
/**
* 🔴 `windowSoftInputMode="adjustResize"` is a no-op at targetSdk 35:
* Android 15 forces edge-to-edge, and an edge-to-edge window does not
* resize for the keyboard. The insets have to be handled in Compose.
*/
@Test
fun theKeyboardDoesNotCoverWhatIsBeingTypedInto() {
val activity = source("MainActivity.kt")
assertTrue("the root must consume the IME inset",
activity.contains("Modifier.fillMaxSize().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
// it must never again be the only thing relied on.
val target = File("build.gradle.kts").readText()
assertTrue("this test's premise is targetSdk 35", target.contains("targetSdk = 35"))
}
}