Merge branch 'feat/files-and-wallet-in-the-bar' into 'main'

Files and Wallet are bar destinations, like iOS

See merge request paulhitt/flamenet-messenger-android!89
This commit was merged in pull request #116.
This commit is contained in:
Paul Hitt
2026-09-07 19:41:31 +00:00
33 changed files with 979 additions and 192 deletions
@@ -89,7 +89,7 @@ fun CallScreenBody(
initials,
fontSize = 40.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
color = Palette.OnDark,
)
}
}
@@ -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),
)
}
@@ -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)") },
@@ -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,
)
}
}
@@ -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,
)
@@ -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"),
@@ -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) },
@@ -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)
@@ -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
@@ -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<Tab> = 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")
}
}
@@ -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 }
@@ -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
@@ -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<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. ⚠️ 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)
}
}
@@ -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) },
@@ -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,
@@ -381,8 +381,12 @@ class Session(
classification: Int,
compartments: List<Int>,
): 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<Int>,
): 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,
@@ -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") },
@@ -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,
)
}
@@ -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) },
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 93 KiB

@@ -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)))
}
}
@@ -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",
)
}
}
@@ -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)
}
}
@@ -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")
}
}