diff --git a/app/build.gradle.kts b/app/build.gradle.kts index caf227d..46d45c6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -184,6 +184,10 @@ dependencies { // module's classpath would make `:client` unable to change its own // dependencies without breaking a screen that never mentioned it. 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("androidx.compose.material3:material3") implementation("androidx.compose.material:material-icons-extended") diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt index fa9b116..a871375 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt @@ -2,6 +2,9 @@ package io.flamenet.messenger.app import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.Matrix +import androidx.exifinterface.media.ExifInterface +import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream /** @@ -40,7 +43,7 @@ object ImagePrep { BitmapFactory.decodeByteArray(source, 0, source.size, bounds) 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. val attempts = @@ -83,7 +86,9 @@ object ImagePrep { val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeByteArray(source, 0, source.size, bounds) 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( @@ -97,6 +102,57 @@ object ImagePrep { 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. */ private fun scaleTo( bitmap: Bitmap, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index 47ed96d..5c86a94 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.KeyboardOptions import androidx.compose.foundation.verticalScroll +import io.flamenet.messenger.app.MessengerViewModel.Phase.Screen import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider @@ -60,28 +62,47 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 - * silently cannot show, the callback never fires and the lock screen sits there - * looking broken. + * silently cannot show β€” the callback never fires and the lock screen sits + * there looking broken. `FragmentActivity` extends `ComponentActivity`, so + * `setContent` and everything else is unaffected. */ class MainActivity : androidx.fragment.app.FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { 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( 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) - != android.content.pm.PackageManager.PERMISSION_GRANTED - ) { + != android.content.pm.PackageManager.PERMISSION_GRANTED) { requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 1001) } @@ -89,8 +110,25 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { setContent { MessengerTheme { - Surface(modifier = Modifier.fillMaxSize()) { - // The gate is around Root, not inside it. + // πŸ”΄ **`android:windowSoftInputMode="adjustResize"` stopped doing + // 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) } if (locked) { @@ -98,24 +136,18 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { onUnlock = { lock.authenticate( activity = this@MainActivity, - onSuccess = { - lock.noteUnlocked() - locked = false - }, + onSuccess = { lock.noteUnlocked(); locked = false }, 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 // to tap to discover. LaunchedEffect(Unit) { lock.authenticate( activity = this@MainActivity, - onSuccess = { - lock.noteUnlocked() - locked = false - }, + onSuccess = { lock.noteUnlocked(); locked = false }, onFailure = {}, ) } @@ -125,22 +157,14 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { // Re-arm on the way out, subject to the grace period. DisposableEffect(Unit) { - val observer = - androidx.lifecycle.LifecycleEventObserver { _, event -> - when (event) { - androidx.lifecycle.Lifecycle.Event.ON_STOP -> { - lock.noteLeft() - } - - androidx.lifecycle.Lifecycle.Event.ON_START -> { - if (lock.shouldLock()) locked = true - } - - else -> { - Unit - } - } + val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> + when (event) { + androidx.lifecycle.Lifecycle.Event.ON_STOP -> lock.noteLeft() + androidx.lifecycle.Lifecycle.Event.ON_START -> + if (lock.shouldLock()) locked = true + else -> Unit } + } lifecycle.addObserver(observer) onDispose { lifecycle.removeObserver(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() { super.onStart() MessengerService.stop(this) @@ -157,7 +184,9 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { override fun 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) } } @@ -171,8 +200,7 @@ private fun Root(model: MessengerViewModel = viewModel()) { // Scrollable, because a keyboard over a form needs somewhere to go. is MessengerViewModel.Phase.Starting, is MessengerViewModel.Phase.SignedOut, - is MessengerViewModel.Phase.VaultUnreadable, - -> { + is MessengerViewModel.Phase.VaultUnreadable -> Column( modifier = frame.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -184,90 +212,122 @@ private fun Root(model: MessengerViewModel = viewModel()) { else -> Unit } } - } - // NOT scrollable. - is MessengerViewModel.Phase.SignedIn -> { + // NOT scrollable. A `LazyColumn` inside a vertically scrolling + // 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)) { - // 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) StoryComposerDialog(model, current) - // Here, above the screen switch, for the same reason the - // call banner is. + // πŸ”΄ **Here, above the screen switch, for the same reason + // 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) if (LiveCallScreen(model, current)) return@Column val chat = current.openChat if (chat != null) { - ChatScreen( - model, - chat, - current.error, - current.buddies, - current.avatars, - myUserId = current.userId, - groups = current.groups, - ) + ChatScreen(model, chat, current.error, current.buddies, current.avatars, + myUserId = current.userId, groups = current.groups) } 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( - modifier = - Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(Space.sm), ) { when (current.screen) { - MessengerViewModel.Phase.Screen.CONTACTS -> { - SignedIn(model, current) - } - - MessengerViewModel.Phase.Screen.MESSAGES -> { + MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current) + MessengerViewModel.Phase.Screen.MESSAGES -> MessagesScreen(model, current) - } - - MessengerViewModel.Phase.Screen.CALLS -> { + MessengerViewModel.Phase.Screen.CALLS -> CallsScreen(model, current) - } - - MessengerViewModel.Phase.Screen.STORIES -> { + MessengerViewModel.Phase.Screen.STORIES -> StoriesFeed(model, current) - } - - MessengerViewModel.Phase.Screen.WALLET -> { + MessengerViewModel.Phase.Screen.WALLET -> 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 - // they share an unlock, true, and an - // implementation detail. - MessengerViewModel.Phase.Screen.FILES -> { + // they share an unlock β€” true, and an + // implementation detail. What it produced was + // somebody pressing "wallet" and arriving at a + // file list. + MessengerViewModel.Phase.Screen.FILES -> FilesScreen(model, current) - } - - MessengerViewModel.Phase.Screen.NOTIFICATIONS -> { + MessengerViewModel.Phase.Screen.NOTIFICATIONS -> NotificationsScreen(model, current) - } - - MessengerViewModel.Phase.Screen.ACCOUNT -> { + MessengerViewModel.Phase.Screen.ACCOUNT -> AccountScreen(model, current) - } } } BottomNav(model, current) } } - } } } } @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() // 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. @@ -281,18 +341,27 @@ private fun ColumnScope.SignIn( // Only a relay keeping its own accounts needs an address for reset mail. 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)) Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { Box( - modifier = - Modifier - .size(48.dp) - .clip(RoundedCornerShape(Radius.medium)) - .background(MaterialTheme.colorScheme.primary), + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(Radius.medium)) + .background(MaterialTheme.colorScheme.primary), contentAlignment = Alignment.Center, ) { Text("F", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Color.White) @@ -301,8 +370,8 @@ private fun ColumnScope.SignIn( Text("Hi there!", fontSize = 40.sp, fontWeight = FontWeight.Bold) Spacer(Modifier.height(Space.sm)) Text( - "Flamenet Messenger. End-to-end encrypted, no phone number, and it " + - "runs on whichever server you point it at.", + "Flamenet Messenger. End-to-end encrypted, no phone number, and it " + + "runs on whichever server you point it at.", fontSize = 14.sp, color = Palette.SecondaryText, textAlign = TextAlign.Center, @@ -319,7 +388,9 @@ private fun ColumnScope.SignIn( 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 { Text( it.identityDescription, @@ -337,16 +408,18 @@ private fun ColumnScope.SignIn( modifier = Modifier.fillMaxWidth().testTag("handle"), ) - // Only a relay keeping its own accounts asks for this, it is how the relay - // sends a reset code, and it is the only party that ever sees it. + // Only a relay keeping its own accounts asks for this β€” it is how the + // 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) { OutlinedTextField( value = email, onValueChange = { email = it }, label = { Text("Email") }, singleLine = true, - keyboardOptions = - KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), modifier = Modifier.fillMaxWidth().testTag("email"), ) Text( @@ -361,32 +434,31 @@ private fun ColumnScope.SignIn( label = { Text("Passphrase") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), - keyboardOptions = - KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), modifier = Modifier.fillMaxWidth().testTag("passphrase"), ) 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( value = birthYear, onValueChange = { birthYear = it.filter(Char::isDigit).take(4) }, label = { Text("Year of birth") }, singleLine = true, - keyboardOptions = - KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), modifier = Modifier.fillMaxWidth().testTag("birth-year"), ) 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, ) ageError?.let { - Text( - it, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.testTag("age-error"), - ) + Text(it, color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag("age-error")) } } @@ -394,37 +466,30 @@ private fun ColumnScope.SignIn( Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("error")) } - // 240 wide and centred, which is the kit's primary button on this screen, - // not a full-width slab. + // ⚠️ 240 wide and centred, which is the kit's primary button on this + // 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)) Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Button( onClick = { if (registering) { - // The most conservative reading, a birthday not yet passed - // this year, because "maybe old enough" isn't. + // The most conservative reading β€” a birthday not yet + // passed this year β€” because "maybe old enough" isn't. 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) { - ageError = - if (year < 1900) { - "Enter your year of birth." - } else { - "You must be at least 13 to use Flamenet Messenger." - } + ageError = if (year < 1900) "Enter your year of birth." + else "You must be at least 13 to use Flamenet Messenger." return@Button } ageError = null } model.signIn(handle.trim(), passphrase, register = registering, email = email.trim()) }, - enabled = - !phase.busy && handle.isNotBlank() && passphrase.isNotBlank() && - (!needsEmail || email.isNotBlank()) && - (!registering || birthYear.length == 4), + enabled = !phase.busy && handle.isNotBlank() && passphrase.isNotBlank() && + (!needsEmail || email.isNotBlank()) && + (!registering || birthYear.length == 4), shape = RoundedCornerShape(KitButton.radius), modifier = Modifier.width(240.dp).height(KitButton.height).testTag("submit"), ) { @@ -458,12 +523,14 @@ private fun ColumnScope.SignIn( /** * 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 -private fun ColumnScope.ResetPassword( - model: MessengerViewModel, - handle: String, -) { +private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String) { var open by remember { mutableStateOf(false) } var code by remember { mutableStateOf("") } var fresh by remember { mutableStateOf("") } @@ -510,31 +577,52 @@ private fun ColumnScope.ResetPassword( /** * 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 { - data class Tab( - val screen: MessengerViewModel.Phase.Screen, - val label: String, - val tag: String, - ) + + data class Tab(val screen: MessengerViewModel.Phase.Screen, val label: String, val tag: String) /** * 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 = - listOfNotNull( - Tab(Screen.CONTACTS, "Home", "nav-contacts"), - Tab(Screen.MESSAGES, "Messages", "nav-messages"), - Tab(Screen.CALLS, "Calls", "nav-calls"), - Tab(Screen.STORIES, "Stories", "nav-stories").takeUnless { storiesHidden }, - Tab(Screen.FILES, "Files", "nav-files"), - Tab(Screen.WALLET, "Wallet", "nav-wallet"), - Tab(Screen.ACCOUNT, "Account", "nav-account"), - ) + fun tabs(storiesHidden: Boolean): List = listOfNotNull( + Tab(Screen.CONTACTS, "Home", "nav-contacts"), + Tab(Screen.MESSAGES, "Messages", "nav-messages"), + Tab(Screen.CALLS, "Calls", "nav-calls"), + Tab(Screen.STORIES, "Stories", "nav-stories").takeUnless { storiesHidden }, + Tab(Screen.FILES, "Files", "nav-files"), + Tab(Screen.WALLET, "Wallet", "nav-wallet"), + Tab(Screen.ACCOUNT, "Account", "nav-account"), + ) } /** * 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 private fun BottomNav( @@ -542,14 +630,17 @@ private fun BottomNav( phase: MessengerViewModel.Phase.SignedIn, ) { 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( - modifier = - Modifier - .fillMaxWidth() - .heightIn(min = Kit.pillHeight) - .clip(RoundedCornerShape(Kit.pillRadius)) - .background(MaterialTheme.colorScheme.surfaceVariant), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = Kit.pillHeight) + .clip(RoundedCornerShape(Kit.pillRadius)) + .background(MaterialTheme.colorScheme.surfaceVariant), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly, ) { @@ -557,32 +648,28 @@ private fun BottomNav( val storiesHidden = ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current) for (tab in NavDestinations.tabs(storiesHidden)) { - val badge = - when (tab.screen) { - // The inbox badge lives on Messages. - Screen.MESSAGES -> phase.unread.size - - // Missed calls SINCE the tab was last opened, it used to count - // every missed call in the log for ever, a badge nothing could - // clear. - Screen.CALLS -> model.missedCallsUnseen(phase) - - // Alerts left the bar 2026-08-28 and iOS never had one; its - // unread count badges Account on both phones now. - Screen.ACCOUNT -> unreadNotes - - else -> 0 - } - val (icon, active) = - when (tab.screen) { - Screen.CONTACTS -> KitIcon.Home to KitIcon.Home - Screen.MESSAGES -> KitIcon.Chats to KitIcon.ChatsActive - Screen.CALLS -> KitIcon.Call to KitIcon.Call - Screen.STORIES -> KitIcon.Stories to KitIcon.Stories - Screen.FILES -> KitIcon.Files to KitIcon.Files - Screen.WALLET -> KitIcon.Wallet to KitIcon.Wallet - else -> KitIcon.Account to KitIcon.Account - } + val badge = when (tab.screen) { + // The inbox badge lives on Messages β€” Home is the roster, this + // is where new mail is read. + Screen.MESSAGES -> phase.unread.size + // Missed calls SINCE the tab was last opened β€” it used to count + // every missed call in the log for ever, a badge nothing could + // clear. Opening the tab is what "seeing" them means. + Screen.CALLS -> model.missedCallsUnseen(phase) + // Alerts left the bar 2026-08-28 and iOS never had one; its + // unread count badges Account on both phones now. + Screen.ACCOUNT -> unreadNotes + else -> 0 + } + val (icon, active) = when (tab.screen) { + Screen.CONTACTS -> KitIcon.Home to KitIcon.Home + Screen.MESSAGES -> KitIcon.Chats to KitIcon.ChatsActive + Screen.CALLS -> KitIcon.Call to KitIcon.Call + Screen.STORIES -> KitIcon.Stories to KitIcon.Stories + Screen.FILES -> KitIcon.Files to KitIcon.Files + Screen.WALLET -> KitIcon.Wallet to KitIcon.Wallet + else -> KitIcon.Account to KitIcon.Account + } NavButton(model, phase, tab.screen, icon, active, tab.label, badge, tab.tag) } } @@ -590,6 +677,16 @@ private fun BottomNav( /** * 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 private fun RowScope.NavButton( @@ -604,45 +701,45 @@ private fun RowScope.NavButton( ) { val selected = phase.screen == screen Box( - modifier = - Modifier - .weight(1f) - .height(Kit.pillHeight) - // `navigateTo`, not `showScreen`. - .clickable { model.navigateTo(screen) } - .semantics { - contentDescription = - if (badge > 0) "$label, $badge unread" else label - }.testTag(tag), + modifier = Modifier + .weight(1f) + .height(Kit.pillHeight) + // πŸ”΄ `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) } + .semantics { + contentDescription = + if (badge > 0) "$label, $badge unread" else label + } + .testTag(tag), contentAlignment = Alignment.Center, ) { Icon( if (selected) activeIcon else icon, contentDescription = null, - tint = - if (selected) { - MaterialTheme.colorScheme.primary - } else { - Palette.SecondaryText - }, + tint = if (selected) MaterialTheme.colorScheme.primary + else Palette.SecondaryText, modifier = Modifier.size(Kit.icon), ) if (badge > 0) { Box( - modifier = - Modifier - .align(Alignment.TopEnd) - .padding(top = Space.md, end = Space.lg) - .size(Space.sm) - .clip(CircleShape) - .background(Palette.Pink), + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = Space.md, end = Space.lg) + .size(Space.sm) + .clip(CircleShape) + .background(Palette.Pink), ) } } } /** - * 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 internal fun ColumnScope.SignedIn( @@ -654,24 +751,28 @@ internal fun ColumnScope.SignedIn( var scanning by remember { mutableStateOf(false) } var scannedPrefill by remember { mutableStateOf("") } val scanContext = androidx.compose.ui.platform.LocalContext.current - val askCamera = - androidx.activity.compose.rememberLauncherForActivityResult( - androidx.activity.result.contract.ActivityResultContracts - .RequestPermission(), - ) { _ -> - // Open either way, the dialog explains itself when access is refused. - scanning = true - } + val askCamera = androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.RequestPermission() + ) { _ -> + // Open either way β€” the dialog explains itself when access is refused. + scanning = true + } - // The contact screen REPLACES the list rather than sitting under it, the - // same way an open chat does. + // ⚠️ The contact screen REPLACES the list rather than sitting under it, + // the same way an open chat does. A detail view stacked below a list is + // two screens fighting over one scroll. val profile = openProfile?.let { id -> phase.buddies.firstOrNull { it.userId == id } } if (profile != null) { ContactDetail(model, phase, profile) { openProfile = null } 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( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -682,22 +783,18 @@ internal fun ColumnScope.SignedIn( fontWeight = FontWeight.Bold, 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( icon = KitIcon.Scan, label = "Scan an address", tag = "scan-address", onClick = { - val has = - androidx.core.content.ContextCompat.checkSelfPermission( - scanContext, - android.Manifest.permission.CAMERA, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (has) { - scanning = true - } else { - askCamera.launch(android.Manifest.permission.CAMERA) - } + val has = androidx.core.content.ContextCompat.checkSelfPermission( + scanContext, android.Manifest.permission.CAMERA + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + if (has) scanning = true + else askCamera.launch(android.Manifest.permission.CAMERA) }, ) BarAction( @@ -713,7 +810,12 @@ internal fun ColumnScope.SignedIn( onFound = { raw -> scanning = false 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}" addingContact = true } @@ -760,6 +862,11 @@ internal fun ColumnScope.SignedIn( /** * 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 private fun BarAction( @@ -769,16 +876,17 @@ private fun BarAction( onClick: () -> Unit, ) { Box( - modifier = - Modifier - .size(Kit.icon + Space.md) - .clip(CircleShape) - .clickable(onClick = onClick) - .semantics { contentDescription = label } - .testTag(tag), + modifier = Modifier + .size(Kit.icon + Space.md) + .clip(CircleShape) + .clickable(onClick = onClick) + .semantics { contentDescription = label } + .testTag(tag), 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, contentDescription = null, @@ -788,6 +896,7 @@ private fun BarAction( } } + @Composable private fun ColumnScope.VaultUnreadable( model: MessengerViewModel, @@ -804,11 +913,12 @@ private fun ColumnScope.VaultUnreadable( "Starting fresh publishes a new device. Everyone you talk to will see " + "\"security keys changed\", so tell them to expect it.", ) - // `discardUnreadableVault`, NOT `signOut`. - Button( - onClick = model::discardUnreadableVault, - modifier = Modifier.testTag("start-fresh"), - ) { + // πŸ”΄ `discardUnreadableVault`, NOT `signOut`. `signOut()` defaults to + // keeping the device, so it left the unreadable vault in place and the next + // restore came straight back here β€” the only button on a dead-end screen + // did nothing but redraw it. + Button(onClick = model::discardUnreadableVault, + modifier = Modifier.testTag("start-fresh")) { Text("Erase and start fresh") } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MetadataScrub.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MetadataScrub.kt index 12d81a9..436c64d 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MetadataScrub.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MetadataScrub.kt @@ -6,49 +6,55 @@ import java.io.ByteArrayOutputStream /** * 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 { + enum class ImageKind { JPEG, PNG, HEIC, TIFF, WEBP, GIF } /** 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. override fun equals(other: Any?): Boolean = - this === other || ( - other is Result && - bytes.contentEquals(other.bytes) && strippedKind == other.strippedKind - ) - + this === other || (other is Result && + bytes.contentEquals(other.bytes) && strippedKind == other.strippedKind) override fun hashCode(): Int = 31 * bytes.contentHashCode() + (strippedKind?.hashCode() ?: 0) } /** Pure, and the reason this is testable without a device. */ fun kind(of: ByteArray): ImageKind? { fun at(i: Int): Int? = if (i < of.size) of[i].toInt() and 0xFF else null - - fun matches( - bytes: List, - offset: Int = 0, - ): Boolean = bytes.withIndex().all { (i, b) -> at(offset + i) == b } + fun matches(bytes: List, 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(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A))) return ImageKind.PNG 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)) || - matches(listOf(0x4D, 0x4D, 0x00, 0x2A)) - ) { - return ImageKind.TIFF - } - // RIFF….WEBP, the size field sits between the two markers. + matches(listOf(0x4D, 0x4D, 0x00, 0x2A))) return ImageKind.TIFF + // RIFF….WEBP β€” the size field sits between the two markers. 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. if (matches(listOf(0x66, 0x74, 0x79, 0x70), 4)) { val brand = (8..11).mapNotNull { at(it) }.map { it.toChar() }.joinToString("") @@ -62,20 +68,33 @@ object MetadataScrub { /** * Re-encode an image so nothing but pixels survives; pass anything else * 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 { val kind = kind(data) ?: return Result(data, null) - val bitmap = - runCatching { BitmapFactory.decodeByteArray(data, 0, data.size) }.getOrNull() - ?: return Result(data, null) + val raw = runCatching { BitmapFactory.decodeByteArray(data, 0, data.size) }.getOrNull() + ?: 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() // PNG where a lossy pass would be a second surprise; JPEG otherwise, at // a quality high enough not to be visible. - val format = - when (kind) { - ImageKind.PNG, ImageKind.GIF, ImageKind.TIFF, ImageKind.WEBP -> Bitmap.CompressFormat.PNG - ImageKind.JPEG, ImageKind.HEIC -> Bitmap.CompressFormat.JPEG - } + val format = when (kind) { + ImageKind.PNG, ImageKind.GIF, ImageKind.TIFF, ImageKind.WEBP -> Bitmap.CompressFormat.PNG + ImageKind.JPEG, ImageKind.HEIC -> Bitmap.CompressFormat.JPEG + } val ok = runCatching { bitmap.compress(format, 95, out) }.getOrDefault(false) bitmap.recycle() return if (ok && out.size() > 0) Result(out.toByteArray(), kind) else Result(data, null) diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/PhotoOrientationTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/PhotoOrientationTest.kt new file mode 100644 index 0000000..f9b2ad2 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/PhotoOrientationTest.kt @@ -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")) + } +}