Merge main
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,20 +157,12 @@ 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 ->
|
||||
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
androidx.lifecycle.Lifecycle.Event.ON_STOP -> {
|
||||
lock.noteLeft()
|
||||
}
|
||||
|
||||
androidx.lifecycle.Lifecycle.Event.ON_START -> {
|
||||
androidx.lifecycle.Lifecycle.Event.ON_STOP -> lock.noteLeft()
|
||||
androidx.lifecycle.Lifecycle.Event.ON_START ->
|
||||
if (lock.shouldLock()) locked = true
|
||||
}
|
||||
|
||||
else -> {
|
||||
Unit
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
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() {
|
||||
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
|
||||
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,15 +341,24 @@ 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
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(Radius.medium))
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
@@ -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,35 +466,28 @@ 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() &&
|
||||
enabled = !phase.busy && handle.isNotBlank() && passphrase.isNotBlank() &&
|
||||
(!needsEmail || email.isNotBlank()) &&
|
||||
(!registering || birthYear.length == 4),
|
||||
shape = RoundedCornerShape(KitButton.radius),
|
||||
@@ -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,19 +577,29 @@ 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<Tab> =
|
||||
listOfNotNull(
|
||||
fun tabs(storiesHidden: Boolean): List<Tab> = listOfNotNull(
|
||||
Tab(Screen.CONTACTS, "Home", "nav-contacts"),
|
||||
Tab(Screen.MESSAGES, "Messages", "nav-messages"),
|
||||
Tab(Screen.CALLS, "Calls", "nav-calls"),
|
||||
@@ -535,6 +612,17 @@ object NavDestinations {
|
||||
|
||||
/**
|
||||
* 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,10 +630,13 @@ 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
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = Kit.pillHeight)
|
||||
.clip(RoundedCornerShape(Kit.pillRadius))
|
||||
@@ -557,24 +648,20 @@ 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.
|
||||
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
|
||||
// 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.
|
||||
// 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) {
|
||||
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
|
||||
@@ -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,33 +701,31 @@ private fun RowScope.NavButton(
|
||||
) {
|
||||
val selected = phase.screen == screen
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.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) }
|
||||
.semantics {
|
||||
contentDescription =
|
||||
if (badge > 0) "$label, $badge unread" else label
|
||||
}.testTag(tag),
|
||||
}
|
||||
.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
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = Space.md, end = Space.lg)
|
||||
.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
|
||||
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(),
|
||||
val askCamera = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
|
||||
) { _ ->
|
||||
// Open either way, the dialog explains itself when access is refused.
|
||||
// Open either way — the dialog explains itself when access is refused.
|
||||
scanning = true
|
||||
}
|
||||
|
||||
// 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,
|
||||
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)
|
||||
}
|
||||
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,8 +876,7 @@ private fun BarAction(
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
modifier = Modifier
|
||||
.size(Kit.icon + Space.md)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onClick)
|
||||
@@ -778,7 +884,9 @@ private fun BarAction(
|
||||
.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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Int>,
|
||||
offset: Int = 0,
|
||||
): Boolean = bytes.withIndex().all { (i, b) -> at(offset + i) == b }
|
||||
fun matches(bytes: List<Int>, offset: Int = 0): Boolean =
|
||||
bytes.withIndex().all { (i, b) -> at(offset + i) == b }
|
||||
|
||||
if (matches(listOf(0xFF, 0xD8, 0xFF))) return ImageKind.JPEG
|
||||
if (matches(listOf(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,17 +68,30 @@ 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()
|
||||
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) {
|
||||
val format = when (kind) {
|
||||
ImageKind.PNG, ImageKind.GIF, ImageKind.TIFF, ImageKind.WEBP -> Bitmap.CompressFormat.PNG
|
||||
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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user