Files and Wallet are bar destinations, like iOS #116
@@ -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) },
|
||||
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 56 KiB |
|
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")
|
||||
}
|
||||
}
|
||||