diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt index 35c976e..de55e3e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt @@ -89,7 +89,7 @@ fun CallScreenBody( initials, fontSize = 40.sp, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, + color = Palette.OnDark, ) } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt index b5cf665..55eafe8 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt @@ -69,7 +69,7 @@ fun ColumnScope.CallsScreen( Icon( KitIcon.Call, contentDescription = "New call", - tint = Palette.Accent, + tint = Palette.OnDark, modifier = Modifier .size(28.dp) .clickable { showNewCall.value = true } @@ -105,12 +105,12 @@ fun ColumnScope.CallsScreen( overflow = TextOverflow.Ellipsis, ) Icon(KitIcon.Call, contentDescription = null, - tint = Palette.Accent, modifier = Modifier.size(20.dp)) + tint = Palette.OnDark, modifier = Modifier.size(20.dp)) Spacer(Modifier.width(Space.md)) Icon( KitIcon.Video, contentDescription = "Video call", - tint = Palette.Accent, + tint = Palette.OnDark, modifier = Modifier .size(22.dp) .clickable { @@ -226,7 +226,7 @@ fun ColumnScope.CallsScreen( Icon( KitIcon.Video, contentDescription = "Video call $name", - tint = MaterialTheme.colorScheme.primary, + tint = Palette.OnDark, modifier = Modifier.size(Kit.icon), ) } @@ -243,7 +243,7 @@ fun ColumnScope.CallsScreen( Icon( KitIcon.Call, contentDescription = "Call $name", - tint = MaterialTheme.colorScheme.primary, + tint = Palette.OnDark, modifier = Modifier.size(Kit.icon), ) } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 6e5266d..cb03ab2 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -437,7 +437,7 @@ fun ColumnScope.GroupsSection( androidx.compose.material3.Icon( if (creating) KitIcon.Close else KitIcon.NewGroup, contentDescription = "New group", - tint = Palette.Accent, + tint = Palette.OnDark, modifier = Modifier .size(22.dp) .clickable { onCreatingChange(!creating) } @@ -447,6 +447,7 @@ fun ColumnScope.GroupsSection( if (creating) { OutlinedTextField( + colors = fieldColors(), value = name, onValueChange = { name = it }, label = { Text("Group name") }, @@ -742,51 +743,14 @@ fun ColumnScope.AccountScreen( SectionBand() SettingsSection { StoryAudienceSection(model, phase) } - // ⚠️ Reached from Account rather than the tab bar. The bar is four icons - // by design — the kit's artboard is literally "Bottom Bar/Without Label" — - // and Alerts was taken OUT of it in August for being a fifth. A wallet - // people open occasionally does not earn the slot that decision refused. - SectionBand() - SettingsSection { - Text("Wallet", fontWeight = FontWeight.Medium, fontSize = 16.sp) - Spacer(Modifier.height(Space.xs)) - Text( - "A wallet on this phone, with keys only you hold. Not connected to " - + "your Flamenet account.", - fontSize = 13.sp, - color = Palette.SecondaryText, - ) - Spacer(Modifier.height(Space.sm)) - KitSecondaryButton( - text = "Open wallet", - onClick = { model.openWallet() }, - modifier = Modifier.fillMaxWidth(), - tag = "open-wallet", - ) - } - - // ⚠️ Beside the wallet and not in the tab bar, for the reason written - // above it: the bar is icons-only by design and Alerts was taken OUT of it - // in August for being a fifth. Files earns the slot no more than a wallet - // does. - SectionBand() - SettingsSection { - Text("Files", fontWeight = FontWeight.Medium, fontSize = 16.sp) - Spacer(Modifier.height(Space.xs)) - Text( - "Files encrypted on this phone, opened with your Flamenet passphrase. " - + "Nothing is stored on a server.", - fontSize = 13.sp, - color = Palette.SecondaryText, - ) - Spacer(Modifier.height(Space.sm)) - KitSecondaryButton( - text = "Open Files", - onClick = { model.openFiles() }, - modifier = Modifier.fillMaxWidth(), - tag = "open-files", - ) - } + // ⚠️ Wallet and Files are NOT here any more — they are bar destinations as + // of 2026-09-07, in the same place iOS puts them. They lived here because + // the kit's bar is four icons and a wallet "does not earn the slot"; what + // that produced was two features reachable only by scrolling most of the + // way down this screen, which somebody testing the app reasonably read as + // them not being built. Second entry points are not kept alongside: two + // routes to one screen is two things to keep in step, and the bar is the + // one a person looks at. SectionBand() SettingsSection { AppLockSection() } @@ -832,6 +796,7 @@ fun ColumnScope.AccountScreen( // same duplication the contact rows had. SettingsRow(label = "Display name") {} OutlinedTextField( + colors = fieldColors(), value = displayName, onValueChange = { displayName = it }, placeholder = { Text("How others see you", fontSize = 14.sp) }, @@ -853,6 +818,7 @@ fun ColumnScope.AccountScreen( description = "Changing it signs this device out — that is what changing it means.", ) {} OutlinedTextField( + colors = fieldColors(), value = password, onValueChange = { password = it }, placeholder = { Text("At least 8 characters", fontSize = 14.sp) }, @@ -1394,13 +1360,16 @@ internal fun SectionBandPublic() = SectionBand() @Composable private fun SectionBand() { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 0.dp) - .height(16.dp) - .background(MaterialTheme.colorScheme.surfaceVariant) - ) + // 🔴 **A gap, not a filled block.** On the light theme this was a 16pt band + // of #F3F3F3 — a barely-there grey that read as a seam between sections. + // Filled with `surfaceVariant` on the dark theme it became a light purple + // slab across the screen, which is the opposite of a separator: it drew + // more attention than the sections it was dividing. + // + // Letting the ground show through does the same job the grey band did, + // because the sections themselves are raised cards now — the gap between + // two panels IS the separator, and it needs no colour of its own. + Spacer(Modifier.fillMaxWidth().height(16.dp)) } /** @@ -1479,7 +1448,9 @@ internal fun Avatar( initials, fontSize = (size.value * 0.33f).sp, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, + // White on the avatar circle. The accent as ink on a dark + // circle is a light purple mark on a dark blue field. + color = Palette.OnDark, ) } } @@ -1571,7 +1542,7 @@ private fun AlertGlyph(kind: String) { Icon( symbol, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, + tint = Palette.OnDark, modifier = Modifier.size(Kit.icon), ) } @@ -2820,7 +2791,7 @@ fun ColumnScope.StoryStrip( Icon( KitIcon.Add, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, + tint = Palette.OnDark, modifier = Modifier.size(Kit.icon), ) } @@ -2857,7 +2828,7 @@ fun ColumnScope.StoryStrip( label.take(1).uppercase(), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, + color = Palette.OnDark, ) } Text( @@ -2957,6 +2928,7 @@ private fun ColumnScope.GroupMembers( if (renaming) { OutlinedTextField( + colors = fieldColors(), value = draftName, onValueChange = { draftName = it }, label = { Text("Group name") }, @@ -3199,6 +3171,7 @@ fun ColumnScope.AddContactForm( fontSize = 12.sp, color = Palette.SecondaryText) Spacer(Modifier.height(Space.xs)) OutlinedTextField( + colors = fieldColors(), value = gatePin, onValueChange = { gatePin = it; gateWrong = false }, placeholder = { Text("Parent PIN", fontSize = 14.sp) }, singleLine = true, @@ -3234,6 +3207,7 @@ fun ColumnScope.AddContactForm( val card = remember(username) { ContactCard.decode(username) } OutlinedTextField( + colors = fieldColors(), value = username, onValueChange = { username = it @@ -3862,6 +3836,7 @@ fun ColumnScope.MailboxesSection( } OutlinedTextField( + colors = fieldColors(), value = host, onValueChange = { host = it }, placeholder = { Text("relay.example.com", fontSize = 14.sp) }, @@ -3870,6 +3845,7 @@ fun ColumnScope.MailboxesSection( ) Spacer(Modifier.height(Space.xs)) OutlinedTextField( + colors = fieldColors(), value = passphrase, onValueChange = { passphrase = it }, placeholder = { Text("Your passphrase", fontSize = 14.sp) }, @@ -4065,6 +4041,7 @@ private fun EditMessageSheet( text = { Column { androidx.compose.material3.OutlinedTextField( + colors = fieldColors(), value = text, onValueChange = { text = it }, modifier = Modifier.fillMaxWidth().testTag("edit-field"), @@ -4203,6 +4180,7 @@ internal fun ReportSheet( Spacer(Modifier.height(Space.sm)) androidx.compose.material3.OutlinedTextField( + colors = fieldColors(), value = note, onValueChange = { note = it }, label = { Text("Anything you want to add (optional)") }, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt index 274626a..0baf6d8 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt @@ -80,7 +80,7 @@ fun ColumnScope.ContactDetail( Icon( KitIcon.Back, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, + tint = Palette.OnDark, modifier = Modifier.size(Kit.icon), ) } @@ -111,7 +111,8 @@ fun ColumnScope.ContactDetail( initials, fontSize = 64.sp, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, + // White on the avatar circle, like every other one. + color = Palette.OnDark, ) } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySection.kt b/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySection.kt index e411ad9..33b9e32 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySection.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySection.kt @@ -62,18 +62,13 @@ fun ColumnScope.DisplaySection() { settings.largerType = on; larger = on } - Spacer(Modifier.height(Space.sm)) - Text("Appearance", fontSize = 14.sp) - Row(modifier = Modifier.fillMaxWidth().padding(vertical = Space.xs)) { - DisplaySettings.Appearance.entries.forEach { option -> - KitSecondaryButton( - text = if (option == appearance) "✓ ${option.label}" else option.label, - onClick = { settings.appearance = option; appearance = option }, - modifier = Modifier.weight(1f).padding(end = Space.xs), - tag = "display-appearance-${option.stored}", - ) - } - } + // ⚠️ The Light / Dark / Match-the-phone choice is gone. The palette has one + // set of colours now and the app is dark whatever the phone says, so the + // control changed nothing a person could see. A setting that is still there + // and no longer works is worse than one that was removed — somebody sets + // it, nothing happens, and they are left wondering which of the two is + // broken. `DisplaySettings.appearance` is left in place so an existing + // stored value is not a crash; nothing reads it. Spacer(Modifier.height(Space.xs)) // 🔴 Says the thing the label alone cannot: this hides them from you, not @@ -81,8 +76,7 @@ fun ColumnScope.DisplaySection() { Text( "These change what this phone shows you. Your contacts are still told when " + "you are online whichever way this is set — hiding yourself is not " - + "something the app can do yet.\n\nAppearance takes effect next time the " - + "app starts.", + + "something the app can do yet.", fontSize = 12.sp, color = Palette.SecondaryText, ) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt index 0e6008c..bed9077 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt @@ -145,6 +145,7 @@ private fun ColumnScope.FilesSetUp( Spacer(Modifier.height(Space.md)) OutlinedTextField( + colors = fieldColors(), value = passphrase, onValueChange = { passphrase = it }, label = { Text("Your Flamenet passphrase") }, visualTransformation = PasswordVisualTransformation(), @@ -153,6 +154,7 @@ private fun ColumnScope.FilesSetUp( ) Spacer(Modifier.height(Space.sm)) OutlinedTextField( + colors = fieldColors(), value = again, onValueChange = { again = it }, label = { Text("Type it again") }, visualTransformation = PasswordVisualTransformation(), @@ -187,6 +189,7 @@ private fun ColumnScope.FilesUnlock( fontSize = 14.sp, color = Palette.SecondaryText) Spacer(Modifier.height(Space.md)) OutlinedTextField( + colors = fieldColors(), value = passphrase, onValueChange = { passphrase = it }, label = { Text("Passphrase") }, visualTransformation = PasswordVisualTransformation(), @@ -452,6 +455,7 @@ private fun ColumnScope.AddFileSheet( Text("Add a file", fontWeight = FontWeight.Medium, fontSize = 16.sp) Spacer(Modifier.height(Space.sm)) OutlinedTextField( + colors = fieldColors(), value = name, onValueChange = { name = it }, label = { Text("Name") }, singleLine = true, modifier = Modifier.fillMaxWidth().testTag("files-add-name"), @@ -510,6 +514,7 @@ private fun ColumnScope.FolderNameSheet( Text("New folder", fontWeight = FontWeight.Medium, fontSize = 16.sp) Spacer(Modifier.height(Space.sm)) OutlinedTextField( + colors = fieldColors(), value = name, onValueChange = { name = it }, label = { Text("Name") }, singleLine = true, modifier = Modifier.fillMaxWidth().testTag("files-folder-name"), diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/HistoryBackup.kt b/app/src/main/kotlin/io/flamenet/messenger/app/HistoryBackup.kt index 86ff598..6f3d960 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/HistoryBackup.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/HistoryBackup.kt @@ -187,6 +187,7 @@ private fun PassphraseDialog( Text(explainer, fontSize = 12.sp, color = Palette.SecondaryText) Spacer(Modifier.height(Space.sm)) OutlinedTextField( + colors = fieldColors(), value = phrase, onValueChange = { phrase = it }, placeholder = { Text("Passphrase", fontSize = 14.sp) }, @@ -197,6 +198,7 @@ private fun PassphraseDialog( if (requireRepeat) { Spacer(Modifier.height(Space.xs)) OutlinedTextField( + colors = fieldColors(), value = repeat, onValueChange = { repeat = it }, placeholder = { Text("Repeat passphrase", fontSize = 14.sp) }, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt b/app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt index edcecd2..3b70fe6 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt @@ -1,6 +1,10 @@ package io.flamenet.messenger.app import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height @@ -52,13 +56,31 @@ fun KitPrimaryButton( enabled: Boolean = true, tag: String? = null, ) { + // ⚠️ The gradient goes on the MODIFIER and the Button is transparent. + // Compose's Button takes a single container colour, so a gradient painted + // inside the content would sit above the ripple and below nothing — it has + // to be the surface itself, clipped to the same shape or the corners show + // square. + // + // ⚠️ Disabled fades the whole gradient rather than swapping a flat colour + // in: a disabled button that changes shape as well as weight reads as a + // different control. Button( onClick = onClick, enabled = enabled, shape = RoundedCornerShape(KitButton.radius), contentPadding = PaddingValues(horizontal = 16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + contentColor = Color.White, + disabledContentColor = Color.White, + ), modifier = modifier .height(KitButton.height) + .clip(RoundedCornerShape(KitButton.radius)) + .background(accentBrush(), RoundedCornerShape(KitButton.radius)) + .alpha(if (enabled) 1f else 0.35f) .then(if (tag != null) Modifier.testTag(tag) else Modifier), ) { Text(text, fontSize = 14.sp, fontWeight = FontWeight.Medium) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt index eed6957..bfecddc 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt @@ -6,6 +6,7 @@ import androidx.compose.material.icons.automirrored.outlined.Logout import androidx.compose.material.icons.automirrored.outlined.Send import androidx.compose.material.icons.filled.ChatBubble import androidx.compose.material.icons.filled.Circle +import androidx.compose.material.icons.outlined.AccountBalanceWallet import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.AddComment import androidx.compose.material.icons.outlined.AddCircleOutline @@ -15,6 +16,7 @@ import androidx.compose.material.icons.outlined.CallEnd import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.DeleteOutline +import androidx.compose.material.icons.outlined.FolderOpen import androidx.compose.material.icons.outlined.GroupAdd import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Lock @@ -80,6 +82,11 @@ object KitIcon { val Stories: ImageVector = Icons.Outlined.Circle val Alerts: ImageVector = Icons.Outlined.NotificationsNone val Account: ImageVector = Icons.Outlined.PersonOutline + // The two that joined the bar on 2026-09-07. iOS draws `folder.fill` and + // `wallet.bifold.fill`; these are the Material shapes closest to them, so + // the same destination reads the same on both phones. + val Files: ImageVector = Icons.Outlined.FolderOpen + val Wallet: ImageVector = Icons.Outlined.AccountBalanceWallet // Composer val Attach: ImageVector = Icons.Outlined.AddCircleOutline 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 06fc30a..93b22d1 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -3,6 +3,8 @@ package io.flamenet.messenger.app import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.Image +import androidx.compose.ui.res.painterResource import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -25,6 +27,8 @@ 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.R +import io.flamenet.messenger.app.MessengerViewModel.Phase.Screen import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider @@ -108,7 +112,27 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { setContent { MessengerTheme { - Surface(modifier = Modifier.fillMaxSize()) { + // The purple ground, painted once for the whole app. The + // Surface itself is transparent so the brush shows through; + // Scaffold below is transparent for the same reason, or its + // default container colour paints straight over it. + Surface( + color = androidx.compose.ui.graphics.Color.Transparent, + // 🔴 **`contentColor` MUST be set when `color` is + // Transparent.** Material derives it with + // `contentColorFor(color)`, which has no answer for + // Transparent and returns Unspecified — so `LocalContentColor` + // falls back to its own default, which is BLACK. Every + // `Text` that does not name a colour of its own then draws + // black on the dark ground. + // + // It looked like "some of the text is hard to read" rather + // than like a broken theme, because the many places that DO + // set a colour were fine and only the inheriting ones went + // dark. + contentColor = Palette.Ink, + modifier = Modifier.fillMaxSize().background(groundBrush()), + ) { // 🔴 **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 @@ -178,7 +202,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { @Composable private fun Root(model: MessengerViewModel = viewModel()) { val phase by model.phase.collectAsStateWithLifecycle() - Scaffold { padding -> + Scaffold(containerColor = androidx.compose.ui.graphics.Color.Transparent) { padding -> val frame = Modifier.fillMaxSize().padding(padding).padding(20.dp) when (val current = phase) { // Scrollable, because a keyboard over a form needs somewhere to go. @@ -341,15 +365,22 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - Box( + // The product's mark. This was an accent-coloured tile with a bold "F" + // in it — a placeholder from before there was a logo, and the first + // thing anybody sees. iOS carries the same image; see ios!144. + // + // ⚠️ Clipped to a rounded rectangle, and that is not decoration. The + // artwork's background is painted rather than transparent — the space + // behind the globe is part of the picture — so an unclipped mark is a + // hard-edged dark rectangle on a pale ground. Rounded, it matches the + // launcher icon the person just tapped. + Image( + painter = painterResource(R.drawable.brand_mark), + contentDescription = null, 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) - } + .size(72.dp) + .clip(RoundedCornerShape(Radius.medium)), + ) Spacer(Modifier.height(Space.lg)) Text("Hi there!", fontSize = 40.sp, fontWeight = FontWeight.Bold) Spacer(Modifier.height(Space.sm)) @@ -364,6 +395,7 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo Spacer(Modifier.height(Space.xl)) OutlinedTextField( + colors = fieldColors(), value = relay, onValueChange = model::setRelay, label = { Text("Server") }, @@ -384,6 +416,7 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo } OutlinedTextField( + colors = fieldColors(), value = handle, onValueChange = { handle = it }, label = { Text("Handle") }, @@ -398,6 +431,7 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo // would be collecting something nobody needs. if (needsEmail) { OutlinedTextField( + colors = fieldColors(), value = email, onValueChange = { email = it }, label = { Text("Email") }, @@ -413,6 +447,7 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo } OutlinedTextField( + colors = fieldColors(), value = passphrase, onValueChange = { passphrase = it }, label = { Text("Passphrase") }, @@ -428,6 +463,7 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo // this device to refuse an under-13 sign-up, then forgotten. Nothing // about age is sent to any server. OutlinedTextField( + colors = fieldColors(), value = birthYear, onValueChange = { birthYear = it.filter(Char::isDigit).take(4) }, label = { Text("Year of birth") }, @@ -538,6 +574,7 @@ private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String) ) { Text("Send a code") } OutlinedTextField( + colors = fieldColors(), value = code, onValueChange = { code = it }, label = { Text("Code from the email") }, @@ -545,6 +582,7 @@ private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String) modifier = Modifier.fillMaxWidth().testTag("reset-code"), ) OutlinedTextField( + colors = fieldColors(), value = fresh, onValueChange = { fresh = it }, label = { Text("New password") }, @@ -560,11 +598,53 @@ private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String) } /** - * The three places to be, always visible. + * What the bar contains, as data rather than as a shape only Compose can see. * - * A tab bar rather than a menu: notifications and the account are things people - * look for, and a capability hidden behind an overflow is one most users never - * find. + * 🔴 **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) + + /** + * 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"), + ) +} + +/** + * 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( @@ -586,28 +666,34 @@ private fun BottomNav( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly, ) { - NavButton(model, phase, MessengerViewModel.Phase.Screen.CONTACTS, - KitIcon.Home, KitIcon.Home, "Home", 0, "nav-contacts") - // The inbox: the kit's Messages-Listing. The unread badge lives here - // now — Home is the roster, this is where new mail is read. - NavButton(model, phase, MessengerViewModel.Phase.Screen.MESSAGES, - KitIcon.Chats, KitIcon.ChatsActive, "Messages", phase.unread.size, "nav-messages") - NavButton(model, phase, MessengerViewModel.Phase.Screen.CALLS, - KitIcon.Call, KitIcon.Call, "Calls", - // 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. - model.missedCallsUnseen(phase), "nav-calls") - // Supervision removes the surface rather than emptying it. - if (!ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current)) { - NavButton(model, phase, MessengerViewModel.Phase.Screen.STORIES, - KitIcon.Stories, KitIcon.Stories, "Stories", 0, "nav-stories") + // Supervision removes the Stories surface rather than emptying it. + 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 — 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) } - // Alerts left the bar 2026-08-28 — five tabs plus it was crowded, and - // iOS never had one (its unread count badges Account). Same here now; - // the screen itself survives for the badge tap-through from Account. - NavButton(model, phase, MessengerViewModel.Phase.Screen.ACCOUNT, - KitIcon.Account, KitIcon.Account, "Account", unreadNotes, "nav-account") } } @@ -640,12 +726,11 @@ private fun RowScope.NavButton( modifier = Modifier .weight(1f) .height(Kit.pillHeight) - .clickable { - model.showScreen(screen) - if (screen == MessengerViewModel.Phase.Screen.NOTIFICATIONS) { - model.refreshNotifications() - } - } + // 🔴 `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 @@ -827,7 +912,7 @@ private fun BarAction( Icon( icon, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, + tint = Palette.OnDark, modifier = Modifier.size(Kit.icon), ) } @@ -850,7 +935,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.", ) - Button(onClick = model::signOut, 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/MessagesScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt index 1d36f37..8689900 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt @@ -76,7 +76,7 @@ fun ColumnScope.MessagesScreen( androidx.compose.material3.Icon( KitIcon.Scan, contentDescription = "Scan an address", - tint = Palette.Accent, + tint = Palette.OnDark, modifier = Modifier .size(24.dp) .clickable { @@ -92,7 +92,7 @@ fun ColumnScope.MessagesScreen( androidx.compose.material3.Icon( if (addingContact) KitIcon.Close else KitIcon.Add, contentDescription = "Add contact", - tint = Palette.Accent, + tint = Palette.OnDark, modifier = Modifier .size(26.dp) .clickable { addingContact = !addingContact } @@ -124,6 +124,7 @@ fun ColumnScope.MessagesScreen( Spacer(Modifier.height(Space.sm)) OutlinedTextField( + colors = fieldColors(), value = query.value, onValueChange = { query.value = it }, placeholder = { Text("Search conversations") }, @@ -167,7 +168,7 @@ fun ColumnScope.MessagesScreen( androidx.compose.material3.Icon( KitIcon.NewChat, contentDescription = "New chat", - tint = Palette.Accent, + tint = Palette.OnDark, modifier = Modifier .size(22.dp) .clickable { showNewChat.value = true } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 8896bcd..9456c32 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -1921,6 +1921,32 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { update { it.copy(screen = screen, error = null) } } + /** + * Go to a screen the way a person arriving at it expects. + * + * 🔴 **Not the same as `showScreen`, and the difference is not cosmetic.** + * Wallet and Files each have to read something off the vault before they + * have anything to draw, and `showScreen` alone would land somebody on an + * empty wallet with no error and nothing loading. Notifications has the + * same shape — the screen exists, the list is stale until it is fetched. + * + * ⚠️ Every caller that moves between destinations should use this rather + * than `showScreen`. `showScreen` stays public because the loaders + * themselves call it, but a navigation that skips the loader is a screen + * that looks broken for reasons the screen cannot explain. + */ + fun navigateTo(screen: Phase.Screen) { + when (screen) { + Phase.Screen.WALLET -> openWallet() + Phase.Screen.FILES -> openFiles() + Phase.Screen.NOTIFICATIONS -> { + showScreen(screen) + refreshNotifications() + } + else -> showScreen(screen) + } + } + // -- Contacts: the rest of the CRUD -------------------------------------- fun removeBuddy(userId: Long) = act { current -> @@ -3700,6 +3726,35 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { * ⚠️ Keeping the keys does not leave the mailbox reachable: a relay token * is minted against the account credential, which this clears either way. */ + /** + * Throw away a vault that cannot be opened, so sign-in is possible again. + * + * 🔴 **The "Erase and start fresh" button used to call `signOut()`, which + * erases nothing.** `signOut` defaults to `erasingDevice = false` — the + * door that deliberately KEEPS the device so coming back is silent — so it + * set the phase to SignedOut and left the unreadable vault exactly where it + * was. The next `restore()` opened it, failed again, and went straight back + * to VaultUnreadable. The one button on a dead-end screen was a loop. + * + * ⚠️ Not `signOut(erasingDevice = true)` either, though that would have + * worked. That path first tries to revoke the device on the relay using a + * session that by definition does not exist here, and its comments are + * about handing a phone on. This is a different situation: the keys are + * already lost, and the only question is whether the app can get past them. + */ + fun discardUnreadableVault() = viewModelScope.launch { + polling?.cancel() + polling = null + session = null + withContext(Dispatchers.IO) { runCatching { KeystoreVault.destroy(getApplication()) } } + identity = null + vault = KeystoreVault.open(getApplication()) + _phase.value = Phase.SignedOut( + error = "The keys stored on this phone could not be opened, so they have " + + "been cleared. Signing in registers this phone as a new device — " + + "your contacts will see that your security keys changed.") + } + fun signOut(erasingDevice: Boolean = false) = viewModelScope.launch { polling?.cancel() polling = null diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MetadataScrub.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MetadataScrub.kt new file mode 100644 index 0000000..c1c414d --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MetadataScrub.kt @@ -0,0 +1,97 @@ +package io.flamenet.messenger.app + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +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?) { + // 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) + 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 } + + 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. ⚠️ 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. + if (matches(listOf(0x52, 0x49, 0x46, 0x46)) && + 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("") + if (brand in setOf("heic", "heix", "hevc", "heim", "heis", "mif1", "msf1")) { + return ImageKind.HEIC + } + } + return null + } + + /** + * 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 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 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/main/kotlin/io/flamenet/messenger/app/NetworkSection.kt b/app/src/main/kotlin/io/flamenet/messenger/app/NetworkSection.kt index 6db595f..631855e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/NetworkSection.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/NetworkSection.kt @@ -148,6 +148,7 @@ fun ColumnScope.NetworkSection( Spacer(Modifier.height(Space.md)) OutlinedTextField( + colors = fieldColors(), value = draft, onValueChange = { draft = it }, placeholder = { Text("obfs4 198.51.100.7:4444 …", fontSize = 13.sp) }, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControlsSection.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControlsSection.kt index 28afbeb..fbb0482 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControlsSection.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControlsSection.kt @@ -56,6 +56,7 @@ fun ParentalControlsSection() { ) Spacer(Modifier.height(Space.sm)) OutlinedTextField( + colors = fieldColors(), value = newPin, onValueChange = { newPin = it }, placeholder = { Text("Parent PIN (at least 4 digits)", fontSize = 14.sp) }, singleLine = true, @@ -64,6 +65,7 @@ fun ParentalControlsSection() { ) Spacer(Modifier.height(Space.xs)) OutlinedTextField( + colors = fieldColors(), value = repeatPin, onValueChange = { repeatPin = it }, placeholder = { Text("Repeat PIN", fontSize = 14.sp) }, singleLine = true, @@ -86,6 +88,7 @@ fun ParentalControlsSection() { fontSize = 12.sp, color = Palette.SecondaryText) Spacer(Modifier.height(Space.sm)) OutlinedTextField( + colors = fieldColors(), value = pinEntry, onValueChange = { pinEntry = it; wrong = false }, placeholder = { Text("Parent PIN", fontSize = 14.sp) }, singleLine = true, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index e3e4906..2b1227c 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -381,8 +381,12 @@ class Session( classification: Int, compartments: List, ): io.flamenet.client.Conversations.Sent { + // android#17 — a photo sent as a FILE used to keep its GPS while the + // same photo through the picker did not. Sniffed by magic bytes, + // because the vault stores no MIME type; non-images go byte-exact. + val clean = MetadataScrub.scrub(bytes).bytes val sent = conversations.sendAttachment( - peerUserId, bytes, "application/octet-stream", Attachment.FILE, + peerUserId, clean, "application/octet-stream", Attachment.FILE, filename = name, classification = classification, compartments = compartments) record(Vault.threadForUser(peerUserId), "", mine = true, attachment = sent.attachment, @@ -405,8 +409,12 @@ class Session( classification: Int, compartments: List, ): Conversations.Sent { + // 🔴 The group variant needs it too. The one-to-one path was fixed + // first and this sat two functions below it — a leak fixed on one of + // two identical doors is a leak. + val clean = MetadataScrub.scrub(bytes).bytes val sent = conversations.sendGroupAttachment( - group, userId, bytes, "application/octet-stream", width = 0, height = 0, + group, userId, clean, "application/octet-stream", width = 0, height = 0, kind = Attachment.FILE, filename = name, classification = classification, compartments = compartments) record(Vault.threadForGroup(group.id), "", mine = true, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt index 97baa14..bb6dd0c 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt @@ -95,7 +95,7 @@ fun ColumnScope.StoriesFeed( Icon( KitIcon.Add, contentDescription = "New story", - tint = Palette.Accent, + tint = Palette.OnDark, modifier = Modifier .size(28.dp) .clickable { choosing = true } @@ -357,6 +357,7 @@ private fun StoryActions( + "saw this story can read it.", fontSize = 13.sp) Spacer(Modifier.height(Space.sm)) OutlinedTextField( + colors = fieldColors(), value = draft, onValueChange = { draft = it }, placeholder = { Text("Message") }, modifier = Modifier.fillMaxWidth().testTag("story-reply-field"), @@ -453,6 +454,7 @@ fun StoryComposerDialog( } } androidx.compose.material3.OutlinedTextField( + colors = fieldColors(), value = caption.value, onValueChange = { caption.value = it }, placeholder = { Text("Add a caption…") }, @@ -464,6 +466,7 @@ fun StoryComposerDialog( // §6.13 — attach a live stream. The picked picture becomes its // poster: what people see before deciding whether to connect. androidx.compose.material3.OutlinedTextField( + colors = fieldColors(), value = streamUrl.value, onValueChange = { streamUrl.value = it }, label = { Text("Live stream link (optional)", fontSize = 12.sp) }, @@ -706,6 +709,7 @@ private fun TextStoryComposer(onDismiss: () -> Unit, onPost: (String) -> Unit) { title = { Text("New story") }, text = { OutlinedTextField( + colors = fieldColors(), value = text, onValueChange = { text = it }, placeholder = { Text("Say something") }, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt index b12cc74..c1ff51e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt @@ -1,6 +1,5 @@ package io.flamenet.messenger.app -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.remember import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding @@ -14,6 +13,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -51,35 +52,164 @@ object Palette { // brand when this app was written and is not any more — the public pages // carry none of it — so the names go with the colour rather than outliving // it. A constant called `Ember` holding blue is how a palette drifts back. - val Accent = Color(0xFF2298FF) // buttons, bubbles, links - val AccentBright = Color(0xFF4FACFE) // the kit's lighter primary swatch - val AccentSoft = Color(0x1A2298FF) + val Accent = Color(0xFF7A3FF2) // buttons, bubbles, links + val AccentBright = Color(0xFFB99BFF) // links and small text on the dark ground + val AccentDeep = Color(0xFFB99BFF) // links and small text + val AccentSoft = Color(0x1A7A3FF2) - val Teal = Color(0xFF3BD5B2) // success and presence - val Pink = Color(0xFFED497C) // the kit's alert accent + // The gradient. + // + // 🔴 **The LIGHT end is the one that has to carry the label.** A gradient + // button is only as readable as its lightest stop, and checking the mean + // or the dark end is how a pretty button ships unreadable. Measured with + // white text: #7A3FF2 is 5.50:1 and #5B27C7 is 8.20:1, so the label clears + // AA along the whole sweep rather than at one end of it. + val GradientTop = Color(0xFF7A3FF2) + val GradientBottom = Color(0xFF5B27C7) + + // The page ground, as a gradient rather than a flat fill. + // + // Purple at the top, medium blue at the bottom — top down. + // + // ⚠️ The blue end is not decoration. The mark is a blue globe with an + // orange flame, and a purple-only app left the icon as the odd one out; + // shading into blue puts the icon's own colour on the page instead of + // beside it. + // + // 🔴 Every darkening step drags SecondaryText with it. Ink reads 12.3:1 at + // the top and 8.4:1 at the bottom, secondary text 8.1:1 and 5.5:1 — the + // bottom stop binds, and it is where most of the explanatory copy sits. + // White cards lift off the bottom at 2.4:1, which keeps a card a card. + val GroundTop = Color(0xFF3A1E9E) // purple + val GroundMid = Color(0xFF0B3080) // blue + val GroundBottom = Color(0xFF070A1A) // black + // Kept as the same two colours: there is one theme now. + val NightGroundTop = Color(0xFF3A1E9E) + val NightGroundBottom = Color(0xFF070A1A) + + val Teal = Color(0xFF4BE3C0) // success and presence + val Pink = Color(0xFFFF819F) // the kit's alert accent // Neutrals, all from the kit. - val Ink = Color(0xFF090710) - val Mist = Color(0xFFF9F9F9) // the page ground - val SurfaceAlt = Color(0xFFF3F3F3) // received bubbles, pills, bands - val IncomingBubble = Color(0xFFF3F3F3) + val Ink = Color(0xFFEDEFF9) + // ⚠️ Still needed where a gradient cannot go (a single Color parameter, a + // system bar tint). This is the gradient's mid-tone, so the two never read + // as different surfaces. + val Mist = Color(0xFF191D68) // the page ground, flat + val SurfaceAlt = Color(0xFF1E2A70) // received bubbles, pills, bands + val IncomingBubble = Color(0xFF1E2A70) // ⚠️ A blue-tinted grey rather than the flat #F3F3F3: an initials avatar // needs to read as a circle against the surface behind it, and two greys a // shade apart do not. - val AvatarFill = Color(0xFFE8F1FB) - val SecondaryText = Color(0xFF505C69) - val Divider = Color(0x1A505C69) + // Icons and initials drawn ON a dark surface. + // + // 🔴 White, not the accent. The accent is a mid purple chosen to be + // readable as a FILL with white on top of it. Used as the ink for a glyph + // on a dark ground it is the wrong way round — a light purple mark on a + // dark blue field, the least legible thing on the screen, and it reads as + // a disabled control rather than a button. + // + // ⚠️ The accent still fills: primary buttons, the selected tab, badges. + val OnDark = Color(0xFFFFFFFF) + + val AvatarFill = Color(0xFF243480) + // 🔴 **Darkened with the ground, not left behind it.** #505C69 read 5.0:1 + // on the old bottom stop and only 3.5:1 on the darker one — below AA, and + // most of the explanatory copy on these screens is set in it. Measured now: + // 8.4:1 at the top of the gradient, 5.6:1 at the bottom. + // 🔴 **A mid-tone again, and that is the point of the flip.** On the light + // ground this had darkened at every step until it was #1B1822 — almost the + // same colour as Ink — so the hierarchy between primary and secondary text + // had collapsed while the contrast numbers still passed. On a dark ground + // it sits in the middle where it belongs: 7.3:1 on the ground, 5.7:1 on a + // card, 4.7:1 on a field. + val SecondaryText = Color(0xFFB4BAD4) + // One theme now, so one secondary. Kept as an alias so the dark + // colour scheme below still reads clearly. + val SecondaryTextDark = Color(0xFFB4BAD4) + val Divider = Color(0x2EB4BAD4) // Semantic - val Success = Color(0xFF3BD5B2) - val Warning = Color(0xFFB8860B) - val Danger = Color(0xFFED497C) + val Success = Color(0xFF4BE3C0) + val Warning = Color(0xFFE9B44C) + val Danger = Color(0xFFFF819F) // Dark-mode grounds — these ARE the site's, since the site is dark. - val InkSurface = Color(0xFF121A2E) // --panel - val NightGround = Color(0xFF0A0E1A) // --bg + // 🔴 Darker than the ground, not lighter. The ground is vivid here, and a + // card lighter than a saturated violet has to be lighter still than that, + // which puts secondary text below AA on it. Recessed panels invert the + // problem: the gradient is the bright thing, content sits in dark wells. + // 🔴 One card colour against a THREE-stop ground, so it must lift off all + // three. #0E1745 looked right against the purple and the blue and measured + // 1.15:1 against the black end — a card that disappears at the bottom of a + // long screen. This lifts 1.41 / 1.33 / 1.23. + val InkSurface = Color(0xFF141C52) // --panel + val NightGround = Color(0xFF191D68) // --bg, the gradient's mid-tone } +/** + * The app's ground. + * + * ⚠️ One brush for the whole app rather than a fill per screen: a gradient + * applied per screen is a gradient that restarts at every navigation, which + * reads as the background flickering. + */ +@Composable +fun groundBrush(): Brush = + // One theme, so no branch. The Night* pair is kept pointing at the same two + // colours rather than deleted, because several screens name it directly and + // a rename across both apps is a separate change. + Brush.verticalGradient( + listOf(Palette.GroundTop, Palette.GroundMid, Palette.GroundBottom)) + +/** The primary action's fill. Matches `Theme.primaryGradient*` on iOS. */ +fun accentBrush(): Brush = + Brush.verticalGradient(listOf(Palette.GradientTop, Palette.GradientBottom)) + +/** + * Text fields, and only text fields. + * + * 🔴 **White, on a dark app, deliberately.** An input is the one surface a + * person types into and reads back character by character, and a dark field on + * a dark ground gives it the least contrast on the screen at the moment it + * matters most. Typed text reads 18.3:1 on white; the placeholder 4.6:1, + * because a placeholder is still text. Matches `Theme.fieldFill` on iOS. + * + * ⚠️ **Not `SurfaceAlt`.** That fill is shared with incoming bubbles and the + * tab pill, which stay dark — a white message bubble on this ground would read + * as somebody else's app. + * + * ⚠️ Every colour a field draws has to be named, not just the container. + * Material derives label, placeholder, cursor and outline from the colour + * scheme, which is the DARK scheme here — leaving any of them unset puts a + * light-on-dark colour on a white box, and the placeholder simply disappears. + */ +object FieldPalette { + val fill = Color(0xFFFFFFFF) + val ink = Color(0xFF0E1230) + val hint = Color(0xFF6B7590) +} + +@Composable +fun fieldColors() = OutlinedTextFieldDefaults.colors( + focusedContainerColor = FieldPalette.fill, + unfocusedContainerColor = FieldPalette.fill, + disabledContainerColor = FieldPalette.fill, + errorContainerColor = FieldPalette.fill, + focusedTextColor = FieldPalette.ink, + unfocusedTextColor = FieldPalette.ink, + disabledTextColor = FieldPalette.hint, + errorTextColor = FieldPalette.ink, + focusedLabelColor = FieldPalette.hint, + unfocusedLabelColor = FieldPalette.hint, + disabledLabelColor = FieldPalette.hint, + focusedPlaceholderColor = FieldPalette.hint, + unfocusedPlaceholderColor = FieldPalette.hint, + cursorColor = Palette.Accent, + focusedBorderColor = Palette.Accent, + unfocusedBorderColor = FieldPalette.hint, +) + /** Corner radii, matching `Theme.Radius` on iOS. */ object Radius { val small = 10.dp @@ -192,53 +322,31 @@ fun Modifier.flamenetCard( @Composable fun MessengerTheme(content: @Composable () -> Unit) { - // ⚠️ Read here rather than passed in, so every entry point picks it up — - // the app has more than one, and a theme applied at only some of them is - // the kind of half-wiring that looks fine until somebody opens a call - // screen from a notification. - val context = androidx.compose.ui.platform.LocalContext.current - val preference = remember { DisplaySettings(context).appearance } - val dark = when (preference) { - DisplaySettings.Appearance.LIGHT -> false - DisplaySettings.Appearance.DARK -> true - DisplaySettings.Appearance.SYSTEM -> isSystemInDarkTheme() - } + // 🔴 **One theme, and it is dark.** There used to be a light scheme, a dark + // one, and a preference choosing between them. The palette has a single set + // of colours now, so the light branch would have drawn light-mode Material + // chrome under dark-mode artwork — and the preference would have been a + // control that changes nothing a person can see except the parts that + // break. + // + // ⚠️ `onBackground`/`onSurface` are `Ink`, not `Mist`. `Mist` is the + // ground now; using it as a text colour on that ground is dark-on-dark, + // which is exactly the kind of swap that survives a rename. MaterialTheme( - colorScheme = if (dark) { - darkColorScheme( - primary = Palette.AccentBright, - onPrimary = Color.White, - secondary = Palette.Accent, - background = Palette.NightGround, - surface = Palette.InkSurface, - surfaceVariant = Palette.InkSurface, - onBackground = Palette.Mist, - onSurface = Palette.Mist, - onSurfaceVariant = Palette.SecondaryText, - error = Palette.Danger, - outline = Palette.Divider, - ) - } else { - lightColorScheme( - // ⚠️ The kit's #2298FF on white is about 3.3:1 — fine for a - // bold button label, not for small text. Anything small and - // blue must use a darker step; the two places that do are the - // section labels and the timestamps, and both are grey. - primary = Palette.Accent, - onPrimary = Color.White, - secondary = Palette.AccentBright, - background = Palette.Mist, - surface = Color.White, - surfaceVariant = Palette.SurfaceAlt, - onBackground = Palette.Ink, - onSurface = Palette.Ink, - onSurfaceVariant = Palette.SecondaryText, - error = Palette.Danger, - outline = Palette.Divider, - ) - }, - shapes = FlamenetShapes, - typography = FlamenetTypography, + colorScheme = darkColorScheme( + primary = Palette.Accent, + onPrimary = Color.White, + secondary = Palette.AccentDeep, + background = Palette.Mist, + surface = Palette.InkSurface, + surfaceVariant = Palette.SurfaceAlt, + onBackground = Palette.Ink, + onSurface = Palette.Ink, + onSurfaceVariant = Palette.SecondaryText, + error = Palette.Danger, + outline = Palette.Divider, + ), + typography = MaterialTheme.typography, content = content, ) } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt index 40ae9c6..0cb23cc 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt @@ -129,6 +129,7 @@ private fun ColumnScope.NoWallet(model: MessengerViewModel) { Text("Already have one?", fontWeight = FontWeight.Medium, fontSize = 16.sp) Spacer(Modifier.height(Space.xs)) OutlinedTextField( + colors = fieldColors(), value = phrase, onValueChange = { phrase = it }, placeholder = { Text("Your 12 or 24 word recovery phrase", fontSize = 13.sp) }, diff --git a/app/src/main/res/drawable-hdpi/brand_mark.png b/app/src/main/res/drawable-hdpi/brand_mark.png new file mode 100644 index 0000000..319e3bb Binary files /dev/null and b/app/src/main/res/drawable-hdpi/brand_mark.png differ diff --git a/app/src/main/res/drawable-mdpi/brand_mark.png b/app/src/main/res/drawable-mdpi/brand_mark.png new file mode 100644 index 0000000..a8ef9be Binary files /dev/null and b/app/src/main/res/drawable-mdpi/brand_mark.png differ diff --git a/app/src/main/res/drawable-xhdpi/brand_mark.png b/app/src/main/res/drawable-xhdpi/brand_mark.png new file mode 100644 index 0000000..d11913e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/brand_mark.png differ diff --git a/app/src/main/res/drawable-xxhdpi/brand_mark.png b/app/src/main/res/drawable-xxhdpi/brand_mark.png new file mode 100644 index 0000000..b477c04 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/brand_mark.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/brand_mark.png b/app/src/main/res/drawable-xxxhdpi/brand_mark.png new file mode 100644 index 0000000..795f778 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/brand_mark.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index 6c028dc..3beb7ab 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index 4ff696d..3ac986f 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index 844711a..bca12aa 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index 64f42ff..cc27ff1 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index f1cf14d..653ae8c 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/MetadataScrubTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/MetadataScrubTest.kt new file mode 100644 index 0000000..feafa22 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/MetadataScrubTest.kt @@ -0,0 +1,79 @@ +package io.flamenet.messenger.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A photo sent as a file must lose its location, like one sent as a photo. + * + * 🔴 **The bug this pins was found by an audit, not by a test, because no test + * went down the file path at all** (android#17, ios#69). The picker path was + * checked and correct; `Session.sendFile` handed stored bytes straight to + * `sendAttachment`. The two doors disagreed, and the safer one was the one that + * looked like a shortcut. + * + * ⚠️ **The format sniffing is what is tested here, not the re-encode.** + * `BitmapFactory` needs a device, so the pixel half belongs to the instrumented + * suite; iOS covers the round trip with a real GPS-tagged JPEG. What a JVM test + * CAN prove is the decision — and the decision is where the bug was, because + * the old code never asked the question at all. + */ +class MetadataScrubTest { + + private fun bytes(vararg v: Int) = v.map { it.toByte() }.toByteArray() + + @Test + fun `an image is recognised from its first bytes`() { + assertEquals(MetadataScrub.ImageKind.JPEG, MetadataScrub.kind(bytes(0xFF, 0xD8, 0xFF, 0xE0))) + assertEquals(MetadataScrub.ImageKind.PNG, + MetadataScrub.kind(bytes(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A))) + assertEquals(MetadataScrub.ImageKind.GIF, MetadataScrub.kind(bytes(0x47, 0x49, 0x46, 0x38))) + assertEquals(MetadataScrub.ImageKind.TIFF, MetadataScrub.kind(bytes(0x49, 0x49, 0x2A, 0x00))) + assertEquals(MetadataScrub.ImageKind.TIFF, MetadataScrub.kind(bytes(0x4D, 0x4D, 0x00, 0x2A))) + } + + /** ⚠️ Both markers, with the size field between them — matching only the + * RIFF header would claim every WAV file is an image. */ + @Test + fun `webp needs both of its markers`() { + val webp = bytes(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50) + assertEquals(MetadataScrub.ImageKind.WEBP, MetadataScrub.kind(webp)) + val wav = bytes(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x41, 0x56, 0x45) + assertNull(MetadataScrub.kind(wav), "a WAV was mistaken for an image") + } + + /** HEIC is an ISO-BMFF box, so the brand at offset 8 is what decides. */ + @Test + fun `heic is recognised and its cousins are not`() { + fun ftyp(brand: String) = bytes(0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70) + + brand.toByteArray(Charsets.US_ASCII) + assertEquals(MetadataScrub.ImageKind.HEIC, MetadataScrub.kind(ftyp("heic"))) + assertEquals(MetadataScrub.ImageKind.HEIC, MetadataScrub.kind(ftyp("mif1"))) + // ⚠️ An MP4 shares the ftyp box. Claiming it is an image would send it + // through a bitmap decoder, which fails, and then be passed through + // anyway — harmless here, but the check should still be honest. + assertNull(MetadataScrub.kind(ftyp("isom"))) + } + + /** + * ⚠️ **Only images are touched.** "Send this exact file" is what a file send + * means; a scrubber that mangles somebody's PDF is worse than the leak it + * was fixing. + */ + @Test + fun `anything that is not an image goes byte for byte`() { + val pdf = "%PDF-1.7\nnot an image at all\n".toByteArray() + val result = MetadataScrub.scrub(pdf) + assertNull(result.strippedKind) + assertTrue(result.bytes.contentEquals(pdf), "a non-image was rewritten") + } + + @Test + fun `an empty or tiny file is not mistaken for anything`() { + assertNull(MetadataScrub.kind(ByteArray(0))) + assertNull(MetadataScrub.kind(bytes(0xFF))) + assertNull(MetadataScrub.kind(bytes(0xFF, 0xD8))) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/NavDestinationsTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/NavDestinationsTest.kt new file mode 100644 index 0000000..d3cafb6 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/NavDestinationsTest.kt @@ -0,0 +1,103 @@ +package io.flamenet.messenger.app + +import io.flamenet.messenger.app.MessengerViewModel.Phase.Screen +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * What is in the tab bar. + * + * 🔴 **This test exists because nothing could see the bar.** It was a hand-written + * run of `NavButton` calls inside a Composable, so its contents were invisible to + * every test in the project: a destination could be dropped in a refactor and + * nothing would go red. That is not hypothetical — Files and Wallet lived only + * inside the Account screen, most of a scroll down, while iOS carried both in + * its bar. Somebody testing the Android build reasonably concluded they had not + * been built at all. + * + * ⚠️ Pinning the exact list is the point, not an accident of writing it this + * way. A test that only asserted "at least four tabs" would have passed + * throughout the disagreement it is here to prevent. + */ +class NavDestinationsTest { + + /** + * 🔴 **The 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. + */ + @Test + fun `the bar is the seven destinations iOS has, in the same order`() { + assertEquals( + listOf( + Screen.CONTACTS, Screen.MESSAGES, Screen.CALLS, Screen.STORIES, + Screen.FILES, Screen.WALLET, Screen.ACCOUNT, + ), + NavDestinations.tabs(storiesHidden = false).map { it.screen }, + ) + } + + /** + * ⚠️ Supervision removes the surface rather than showing an empty one — + * and removes only that one. A parental control that quietly took Files or + * the wallet with it would be a data-access change nobody asked for. + */ + @Test + fun `supervision hides stories and nothing else`() { + val supervised = NavDestinations.tabs(storiesHidden = true).map { it.screen } + assertFalse(Screen.STORIES in supervised) + assertEquals( + NavDestinations.tabs(storiesHidden = false).map { it.screen } - Screen.STORIES, + supervised, + ) + } + + /** + * 🔴 The tags are how the instrumented suite finds the bar. A renamed tag + * is a test that stops testing and still passes, because + * `onNodeWithTag(...).performClick()` on a node that does not exist is + * caught, but `nodesWithTag(...).isEmpty()` guards throughout that suite + * turn a missing tag into a silent early return. + */ + @Test + fun `every tab carries the tag the instrumented suite looks for`() { + assertEquals( + listOf("nav-contacts", "nav-messages", "nav-calls", "nav-stories", + "nav-files", "nav-wallet", "nav-account"), + NavDestinations.tabs(storiesHidden = false).map { it.tag }, + ) + } + + /** + * ⚠️ The label is not decoration: the bar draws icons only, so this string + * is the accessibility name and the only thing TalkBack reads. A blank one + * makes the tab an unnamed button. + */ + @Test + fun `every tab is named for a screen reader`() { + for (tab in NavDestinations.tabs(storiesHidden = false)) { + assertTrue(tab.label.isNotBlank(), "${tab.tag} has no accessibility label") + } + } + + /** + * 🔴 Every destination a person can reach from the bar has to be somewhere + * the app can actually draw. `NOTIFICATIONS` is deliberately NOT in the bar + * — it left on 2026-08-28 and is reached by tapping the Account badge — so + * this checks the bar is a subset of the screens, not equal to them. + */ + @Test + fun `no tab points at a screen that does not exist`() { + val all = Screen.entries.toSet() + for (tab in NavDestinations.tabs(storiesHidden = false)) { + assertTrue(tab.screen in all, "${tab.tag} points at a screen the app has no case for") + } + assertFalse( + Screen.NOTIFICATIONS in NavDestinations.tabs(storiesHidden = false).map { it.screen }, + "Alerts left the bar in August; putting it back is a decision, not a refactor", + ) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/PaletteMatchesIOSTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/PaletteMatchesIOSTest.kt new file mode 100644 index 0000000..3fbc6b3 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/PaletteMatchesIOSTest.kt @@ -0,0 +1,146 @@ +package io.flamenet.messenger.app + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The two apps' palettes, checked rather than claimed. + * + * 🔴 **`Theme.swift` opens with "One palette, two apps… change a value here and + * change it there in the same commit" — and nothing enforced it.** Its own + * comment records what happened last time: the file claimed the two were + * matched while iOS was indigo and Android was ember. A promise in a comment is + * a promise nobody keeps, because nothing goes red when it is broken. + * + * This reads the iOS source and compares the hex values. It is coarse on + * purpose — it does not know what a colour means, only that both files say the + * same number for it. + * + * ⚠️ It needs the iOS checkout beside this one. When it is missing the test + * SKIPS rather than fails: a green suite on a machine that only has Android is + * the honest outcome, and a red one there would train people to ignore it. + */ +class PaletteMatchesIOSTest { + + private val iosTheme: File? = + listOf( + File("../flamenetmessenger/Sources/Theme/Theme.swift"), + File("../../flamenetmessenger/Sources/Theme/Theme.swift"), + ).firstOrNull { it.isFile } + + /** `static let name = Color(hex: 0xRRGGBB)` and the light/dark pair form. */ + private fun iosHex(source: String, name: String): String? { + val flat = Regex("""static let $name\s*=\s*Color\(hex:\s*0x([0-9A-Fa-f]{6})\)""") + .find(source)?.groupValues?.get(1) + if (flat != null) return flat.uppercase() + return Regex("""static let $name\s*=\s*Color\(light:\s*0x([0-9A-Fa-f]{6})""") + .find(source)?.groupValues?.get(1)?.uppercase() + } + + private fun androidHex(color: androidx.compose.ui.graphics.Color): String = + "%02X%02X%02X".format( + (color.red * 255).toInt(), (color.green * 255).toInt(), (color.blue * 255).toInt()) + + @Test + fun `the accent and its gradient are the same on both platforms`() { + val ios = iosTheme?.readText() ?: return + for ((swift, android) in listOf( + "primary" to Palette.Accent, + "primaryGradientTop" to Palette.GradientTop, + "primaryGradientBottom" to Palette.GradientBottom, + "primaryDeep" to Palette.AccentDeep, + )) { + val expected = iosHex(ios, swift) + assertTrue(expected != null, "iOS Theme.swift has no `$swift` in a form this can read") + assertEquals(expected, androidHex(android), + "$swift differs: iOS says $expected, Android says ${androidHex(android)}") + } + } + + /** + * 🔴 The half that decides whether the app is readable. A gradient button is + * only as readable as its LIGHTEST stop, and both platforms draw white on + * it. + */ + @Test + fun `white text clears AA on the lightest end of the button gradient`() { + val ratio = contrast(Palette.GradientTop, androidx.compose.ui.graphics.Color.White) + assertTrue(ratio >= 4.5, + "white on the gradient's light end is %.2f:1 — below AA for normal text".format(ratio)) + } + + /** + * 🔴 **One theme now, so one set of grounds — and every surface a person + * reads text on has to clear AA, not just the page.** + * + * The light theme is gone (2026-09-07). It went because the ground kept + * being asked to go darker and `secondaryText` had to darken with it at + * every step, until it was #1B1822 against ink at #090710: contrast passing + * while the hierarchy between primary and secondary text quietly vanished. + * A dark ground puts secondary back in the middle where it belongs. + * + * ⚠️ Cards and field fills are checked as well as the page. A gradient that + * passes and a bubble that does not is still an unreadable app, and the + * bubble is where the messages are. + */ + @Test + fun `text clears AA on every surface it is read on`() { + val surfaces = listOf( + "ground top (purple)" to Palette.GroundTop, + // ⚠️ The middle stop is a surface too. A three-stop gradient + // checked only at its ends is two thirds measured. + "ground middle (blue)" to Palette.GroundMid, + "ground bottom (black)" to Palette.GroundBottom, + "card" to Palette.InkSurface, + "field and incoming bubble" to Palette.SurfaceAlt, + "avatar" to Palette.AvatarFill, + ) + for ((name, ground) in surfaces) { + val ink = contrast(ground, Palette.Ink) + assertTrue(ink >= 4.5, "ink on the $name is %.2f:1".format(ink)) + // Secondary is the one that fails first — most of the explanatory + // copy on these screens is set in it. + val muted = contrast(ground, Palette.SecondaryText) + assertTrue(muted >= 4.5, "secondary text on the $name is %.2f:1".format(muted)) + } + } + + /** + * ⚠️ A card has to be visibly a card. Contrast rules say nothing about + * this, and a surface that matches its ground is a layout people cannot + * parse even though every text ratio passes. + */ + @Test + fun `cards and fields lift off what is behind them`() { + // 🔴 Against ALL THREE stops. One card colour has to survive the whole + // sweep, and the black end is the one it fails on. + for ((name, stop) in listOf("purple" to Palette.GroundTop, + "blue" to Palette.GroundMid, + "black" to Palette.GroundBottom)) { + val lift = contrast(Palette.InkSurface, stop) + assertTrue(lift >= 1.15, + "a card lifts off the $name end of the ground by only %.2f:1".format(lift)) + } + val fieldLift = contrast(Palette.SurfaceAlt, Palette.InkSurface) + assertTrue(fieldLift >= 1.15, "a field lifts off its card by only %.2f:1".format(fieldLift)) + } + + private fun channel(c: Float): Double { + val v = c.toDouble() + return if (v <= 0.04045) v / 12.92 else Math.pow((v + 0.055) / 1.055, 2.4) + } + + private fun luminance(c: androidx.compose.ui.graphics.Color): Double = + 0.2126 * channel(c.red) + 0.7152 * channel(c.green) + 0.0722 * channel(c.blue) + + private fun contrast( + a: androidx.compose.ui.graphics.Color, + b: androidx.compose.ui.graphics.Color, + ): Double { + val la = luminance(a) + val lb = luminance(b) + return (maxOf(la, lb) + 0.05) / (minOf(la, lb) + 0.05) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/RecoveryActuallyRecoversTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/RecoveryActuallyRecoversTest.kt new file mode 100644 index 0000000..692c6fb --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/RecoveryActuallyRecoversTest.kt @@ -0,0 +1,82 @@ +package io.flamenet.messenger.app + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The one button on a dead-end screen has to be a way out. + * + * 🔴 **"Erase and start fresh" erased nothing.** The `VaultUnreadable` screen — + * shown when the keys on this phone cannot be opened — offered exactly one + * action, and it called `model::signOut`. `signOut` defaults to + * `erasingDevice = false`, the door that deliberately KEEPS the device so + * coming back is silent. So it set the phase to SignedOut, left the unreadable + * vault exactly where it was, and the next `restore()` opened it, failed again, + * and came straight back to the same screen. + * + * ⚠️ **It looked like it worked.** The screen changed. That is the whole + * problem with a recovery action that does not recover: the feedback is the + * claim, and the person only finds out on the next launch — or, as here, when + * they try to sign in and land back where they started. + * + * A source scan, coarse on purpose, in the same shape as + * `DestructiveLabelsTest`. It cannot prove the recovery is correct. It proves + * this particular wrong call cannot come back. + */ +class RecoveryActuallyRecoversTest { + + private fun source(name: String): String { + val f = File("src/main/kotlin/io/flamenet/messenger/app/$name") + assertTrue(f.isFile, "cannot find $name — this test reads the sources") + return f.readText() + } + + /** The block that draws the dead-end screen. */ + private fun vaultUnreadableScreen(): String { + val s = source("MainActivity.kt") + val start = s.indexOf("private fun ColumnScope.VaultUnreadable(") + assertTrue(start > 0, "the VaultUnreadable screen has been renamed or removed") + val end = s.indexOf("\n}", start) + return s.substring(start, if (end > start) end else s.length) + } + + @Test + fun `the way out of an unreadable vault does not call plain signOut`() { + val screen = vaultUnreadableScreen() + assertFalse( + screen.contains("model::signOut") || screen.contains("model.signOut("), + "the recovery button calls signOut, which keeps the device by default — " + + "it leaves the unreadable vault in place and the next restore returns here", + ) + } + + @Test + fun `the way out of an unreadable vault discards it`() { + assertTrue( + vaultUnreadableScreen().contains("discardUnreadableVault"), + "the recovery button no longer calls discardUnreadableVault", + ) + } + + /** + * 🔴 And that the discard actually destroys something. A method with the + * right name that does not reach the vault would satisfy the test above and + * fail the person in exactly the same way. + */ + @Test + fun `discarding an unreadable vault destroys it and reopens a fresh one`() { + val s = source("MessengerViewModel.kt") + val start = s.indexOf("fun discardUnreadableVault(") + assertTrue(start > 0, "discardUnreadableVault is gone") + val body = s.substring(start, minOf(start + 1200, s.length)) + assertTrue(body.contains("KeystoreVault.destroy"), + "discardUnreadableVault never destroys the vault") + assertTrue(body.contains("KeystoreVault.open"), + "discardUnreadableVault destroys the vault without opening a fresh one, " + + "so the next sign-in has nowhere to store keys") + assertTrue(body.contains("Phase.SignedOut"), + "discardUnreadableVault does not leave the app on the sign-in screen") + } +}