Android can show an address, and the Account screen looks like the app

Two gaps, one in each direction.

Android could ENCODE an address and had no way to DERIVE one, so it
could not show yours and nobody on another relay could be given it --
the cross-relay work was iOS-only in practice. The engine was missing
`localAccountSalt` and a way to get both sign-in secrets from one
stretch; asking for the account key separately re-runs 650,000 PBKDF2
iterations for a value already in hand. The salt is pinned against the
JavaScript engine, which is normative -- a Kotlin test asserting what
Kotlin computes would prove only that Kotlin is self-consistent, and
the failure to guard against is three engines disagreeing, which shows
up as one person having two addresses depending on which phone they
used.

The address is stored in the vault because it can only be derived at
sign-in: the passphrase exists for that one moment and is never kept,
so a restored session had nothing to recompute from. Caught by the
empty state saying so on the emulator rather than showing a blank.

A QR encoder, written rather than depended on. Android has no encoder
in the platform, and this is one fully specified algorithm for one
fixed job -- turning a short ASCII address into a bitmap. It is
verified by DECODING: thirteen codes, every version from 1 to 10 at
exact capacity, read back by CoreImage's detector, which shares no line
with it. That found the bug that mattered. The symbol was structurally
perfect -- finders, timing, alignment, data placement and mask all
confirmed correct against a reference symbol -- and nothing could read
it, because the fifteen format bits were written least-significant
first. It fails identically at every version with no clue where.

The Account screen was a flat stack of fields, buttons and paragraphs
with dividers between them, so a security setting carried the same
weight as a text box and "Delete account" the same as "Save display
name". Cards now, matching the Contacts screen that already had them,
with the destructive pair on their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-27 13:36:58 -04:00
co-authored by Claude Opus 5
parent 28d7485c26
commit e46d51430a
10 changed files with 1007 additions and 115 deletions
@@ -0,0 +1,133 @@
package io.flamenet.messenger.app
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color as AndroidColor
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.flamenet.e2e.QrCode
/**
* Your address, as a code somebody can scan and a string they can copy.
*
* 🔴 **Android had no way to show this at all**, which made the cross-relay
* work iOS-only in practice: an address you cannot display is an address
* nobody can send to. The engine could encode an fnid and had no way to derive
* one, so there was nothing to put on a screen even if a screen had existed.
*
* ⚠️ **The string stays visible under the code.** An fnid is a derived key, not
* a name — an address that is nearly right addresses somebody else — and a
* scanner is not always to hand. Copy is there because retyping 52 characters
* off a screen is how the wrong person gets messaged.
*/
@Composable
fun AddressCard(fnid: String?, relay: String) {
val context = LocalContext.current
Column(Modifier.fillMaxWidth()) {
Text("Your address", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(Space.sm))
if (fnid == null) {
// ⚠️ Said plainly rather than hidden. Without an address this phone
// cannot be reached from another relay, and a blank space would
// read as the feature not existing rather than as it not working.
Text(
"This device could not derive your address. You can still message "
+ "people on ${relay}, but nobody on another relay can reach you.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag("address-missing"),
)
return@Column
}
val address = "$fnid@$relay"
val encoded = "flamenet:$address"
val bitmap = remember(encoded) { qrBitmap(encoded) }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (bitmap != null) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "Your address as a QR code",
// ⚠️ None, or the modules blur when scaled up and the code
// stops scanning — the default filtering is smooth.
filterQuality = FilterQuality.None,
contentScale = ContentScale.FillBounds,
modifier = Modifier.size(180.dp).testTag("address-qr"),
)
Spacer(Modifier.height(Space.sm))
}
Text(
address,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.testTag("address-text"),
)
Spacer(Modifier.height(Space.sm))
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
OutlinedButton(
onClick = { copy(context, "Flamenet address", address) },
modifier = Modifier.testTag("copy-address"),
) { Text("Copy address") }
}
}
Spacer(Modifier.height(Space.sm))
Text(
"Derived from your passphrase, not issued by a server. Sign in anywhere "
+ "with the same passphrase and this stays the same — which is what "
+ "lets it outlive any one relay.",
style = MaterialTheme.typography.bodySmall,
)
}
}
private fun copy(context: Context, label: String, text: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText(label, text))
}
/**
* One module per pixel plus a quiet zone; Compose scales it up.
*
* ⚠️ The four-module quiet zone is not decoration. A QR printed hard against
* other content does not scan, and the margin has to be part of the bitmap
* because the surrounding card has a background of its own.
*/
private fun qrBitmap(text: String, quiet: Int = 4): Bitmap? {
val matrix = runCatching { QrCode.encode(text) }.getOrNull() ?: return null
val side = matrix.size + quiet * 2
val pixels = IntArray(side * side) { AndroidColor.WHITE }
for (r in 0 until matrix.size) {
for (c in 0 until matrix.size) {
if (matrix[r, c]) pixels[(r + quiet) * side + (c + quiet)] = AndroidColor.BLACK
}
}
return Bitmap.createBitmap(pixels, side, side, Bitmap.Config.ARGB_8888)
}
@@ -3,6 +3,10 @@ package io.flamenet.messenger.app
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.Surface
import androidx.compose.material3.ButtonDefaults
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -498,125 +502,178 @@ fun ColumnScope.AccountScreen(
Text("Device ${phase.deviceId}", style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag("device-id"))
Divider()
Spacer(Modifier.height(Space.lg))
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = { Text("Display name") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("display-name"),
)
Button(
onClick = { model.updateDisplayName(displayName.trim()); displayName = "" },
enabled = displayName.isNotBlank() && !phase.busy,
modifier = Modifier.fillMaxWidth().testTag("save-display-name"),
) { Text("Save display name") }
// 🔴 **Cards, like every other screen in this app.** This one was a flat
// stack of full-width fields, buttons and paragraph-length explanations
// with `Divider()` between them — no grouping, so "change your password"
// and "delete your account" carried exactly the same visual weight, and a
// security setting sat in the same undifferentiated column as a text box.
// The Contacts screen was given this treatment and Account was missed.
SettingsCard {
AddressCard(fnid = phase.fnid, relay = phase.relay)
}
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("New password") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth().testTag("new-password"),
)
Button(
onClick = { model.updatePassword(password); password = "" },
enabled = password.length >= 8 && !phase.busy,
modifier = Modifier.fillMaxWidth().testTag("save-password"),
) { Text("Change password") }
Text(
"Changing it signs this device out — that is what changing it means.",
style = MaterialTheme.typography.bodySmall,
)
Divider()
// -- Security (§19) ------------------------------------------------------
Text("Security", style = MaterialTheme.typography.titleMedium)
if (phase.forkDetected) {
// Above the toggle, not below: this is a statement about the server the
// toggle configures, and it changes what the toggle means.
Text(
"⚠️ This relay showed two different key logs.",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.testTag("fork-detected"),
Spacer(Modifier.height(Space.md))
SettingsCard {
Text("Profile", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(Space.sm))
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = { Text("Display name") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("display-name"),
)
Spacer(Modifier.height(Space.sm))
Button(
onClick = { model.updateDisplayName(displayName.trim()); displayName = "" },
enabled = displayName.isNotBlank() && !phase.busy,
modifier = Modifier.fillMaxWidth().testTag("save-display-name"),
) { Text("Save display name") }
Spacer(Modifier.height(Space.lg))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("New password") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth().testTag("new-password"),
)
Spacer(Modifier.height(Space.sm))
Button(
onClick = { model.updatePassword(password); password = "" },
enabled = password.length >= 8 && !phase.busy,
modifier = Modifier.fillMaxWidth().testTag("save-password"),
) { Text("Change password") }
Spacer(Modifier.height(Space.sm))
Text(
"Two devices compared what this server told each of them about its key log, and the "
+ "answers cannot both be true. A server doing that can hand different people "
+ "different keys for the same person. Treat conversations here as compromised "
+ "until you have verified safety numbers in person.",
"Changing it signs this device out — that is what changing it means.",
style = MaterialTheme.typography.bodySmall,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Require published keys", modifier = Modifier.weight(1f))
Switch(
checked = phase.requireKeyLog,
onCheckedChange = model::setRequireKeyLog,
modifier = Modifier.testTag("require-key-log"),
// -- Security (§19) ------------------------------------------------------
Spacer(Modifier.height(Space.md))
SettingsCard {
Text("Security", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(Space.sm))
if (phase.forkDetected) {
// Above the toggle, not below: this is a statement about the server
// the toggle configures, and it changes what the toggle means.
Text(
"⚠️ This relay showed two different key logs.",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.testTag("fork-detected"),
)
Text(
"Two devices compared what this server told each of them about its key log, and the "
+ "answers cannot both be true. A server doing that can hand different people "
+ "different keys for the same person. Treat conversations here as compromised "
+ "until you have verified safety numbers in person.",
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.height(Space.sm))
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Require published keys", modifier = Modifier.weight(1f))
Switch(
checked = phase.requireKeyLog,
onCheckedChange = model::setRequireKeyLog,
modifier = Modifier.testTag("require-key-log"),
)
}
Text(
"Refuse to send unless this server can prove the recipient's keys appear in the public "
+ "log it publishes. Stronger, but older servers keep no log and every message to "
+ "them will fail. Your keys are always compared against what you saw before, "
+ "whether this is on or off.",
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.height(Space.lg))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Sealed sender", modifier = Modifier.weight(1f))
TextButton(
onClick = model::rotateDeliveryKey,
modifier = Modifier.testTag("rotate-delivery-key"),
) { Text("New delivery key") }
}
Text(
"Messages you send are addressed to the recipient without naming you, once they have "
+ "your delivery key. Replacing it is the only way to stop someone who is abusing "
+ "it — and it cuts off every contact until you next message them.",
style = MaterialTheme.typography.bodySmall,
)
}
Text(
"Refuse to send unless this server can prove the recipient's keys appear in the public "
+ "log it publishes. Stronger, but older servers keep no log and every message to "
+ "them will fail. Your keys are always compared against what you saw before, "
+ "whether this is on or off.",
style = MaterialTheme.typography.bodySmall,
)
Row(
// ⚠️ **The destructive pair, in their own card.** Sign-out lives here
// rather than under the contact list because this is where people look for
// it, and because it is destructive enough to sit beside the other
// destructive thing rather than under a list of friends. Separating the
// card is what stops "Sign out" reading like one more setting.
Spacer(Modifier.height(Space.md))
SettingsCard {
Button(
onClick = model::signOut,
modifier = Modifier.fillMaxWidth().testTag("sign-out"),
) { Text("Sign out") }
Spacer(Modifier.height(Space.sm))
Text(
"Signing out erases this phone's keys and stored messages. They cannot be recovered.",
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.height(Space.lg))
Button(
onClick = { if (confirmingDelete) model.deleteAccount() else confirmingDelete = true },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
modifier = Modifier.fillMaxWidth().testTag("delete-account"),
) {
Text(if (confirmingDelete) "Really delete — this cannot be undone" else "Delete account")
}
Spacer(Modifier.height(Space.sm))
Text(
"Deletes the account on ${phase.relay} and erases this phone's keys and messages.",
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(Space.lg))
}
/**
* One group of settings on its own surface.
*
* ⚠️ Matches the cards the Contacts screen already uses rather than inventing a
* second card style — same shape, same elevation, same padding. Two kinds of
* card in one app reads as an unfinished redesign, which is what this was.
*/
@Composable
private fun SettingsCard(content: @Composable ColumnScope.() -> Unit) {
Surface(
shape = RoundedCornerShape(Radius.medium),
color = MaterialTheme.colorScheme.surface,
shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Sealed sender", modifier = Modifier.weight(1f))
TextButton(
onClick = model::rotateDeliveryKey,
modifier = Modifier.testTag("rotate-delivery-key"),
) { Text("New delivery key") }
Column(Modifier.padding(Space.lg), content = content)
}
Text(
"Messages you send are addressed to the recipient without naming you, once they have "
+ "your delivery key. Replacing it is the only way to stop someone who is abusing "
+ "it — and it cuts off every contact until you next message them.",
style = MaterialTheme.typography.bodySmall,
)
Divider()
// Sign-out lives here rather than under the contact list, because this is
// where people look for it — and because it is destructive enough to sit
// beside the other destructive things rather than under a list of friends.
Button(
onClick = model::signOut,
modifier = Modifier.fillMaxWidth().testTag("sign-out"),
) { Text("Sign out") }
Text(
"Signing out erases this phone's keys and stored messages. They cannot be recovered.",
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = { if (confirmingDelete) model.deleteAccount() else confirmingDelete = true },
modifier = Modifier.fillMaxWidth().testTag("delete-account"),
) {
Text(if (confirmingDelete) "Really delete — this cannot be undone" else "Delete account")
}
Text(
"Deletes the account on ${phase.relay} and erases this phone's keys and messages.",
style = MaterialTheme.typography.bodySmall,
)
}
@Composable
@@ -8,7 +8,10 @@ import io.flamenet.client.RelayClient
import io.flamenet.client.RelayAccounts
import io.flamenet.client.MessengerApi
import io.flamenet.client.RelayException
import io.flamenet.e2e.AccountKdf
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Ed25519
import io.flamenet.e2e.FNID
import io.flamenet.e2e.Vault
import kotlinx.coroutines.Dispatchers
import io.flamenet.client.IdentityTrust
@@ -50,6 +53,19 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val deviceId: String,
val relay: String,
val status: String,
/**
* This account's global address, `fnid:...`, or null if it could
* not be derived.
*
* 🔴 **Derived on this device, never issued.** It is the public
* half of a key stretched from the passphrase, which is what lets
* it survive a relay disappearing — and what makes it the only
* thing somebody on another server can address. Null is a real
* state worth rendering honestly rather than hiding: without it
* this phone cannot be reached across a relay boundary at all.
*/
val fnid: String? = null,
val buddies: List<MessengerApi.Buddy> = emptyList(),
/**
@@ -245,6 +261,11 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
userId = credential.userId,
deviceId = stored.deviceId,
relay = SiteConfig(credential.relayBaseUrl).host,
// From the vault, because a restore has no passphrase
// to derive it from. Without this the Account screen
// said "this device could not derive your address"
// after every relaunch, which was true and useless.
fnid = credential.fnid,
status = "Signed in on this device.",
buddies = fetchedBuddies.getOrDefault(emptyList()),
buddiesLoaded = fetchedBuddies.isSuccess,
@@ -302,6 +323,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
email: String,
): Phase {
val relayAccounts = RelayAccounts(config.baseUrl)
// Set by the shared branch below, which gets it free from the stretch
// it already runs. Left null for a relay-local account, which derives
// its own further down.
var sharedSeed: ByteArray? = null
val account = when (config.accountProvider) {
SiteConfig.AccountProvider.SHARED -> {
// The passphrase is stretched on this device and only the
@@ -310,7 +335,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
// learns a product-scoped pseudonym and not the handle.
val accounts = AccountsClient(config.accountServiceUrl)
if (register) accounts.register(handle, passphrase)
relayAccounts.sharedSession(accounts.signIn(handle, passphrase).token, handle)
val session = accounts.signIn(handle, passphrase)
sharedSeed = session.accountKeySeed
relayAccounts.sharedSession(session.token, handle)
}
// A relay-local account authenticates against the relay itself,
// which is what "this server keeps its own accounts" means. The
@@ -324,6 +351,28 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}
val credential = account.appPassword
// 🔴 **The address, derived here because this is the only moment the
// passphrase exists.** Android could encode an address and had no way
// to produce its own, so the Account screen could not show one and
// nobody on another relay could be given one — the federation work was
// iOS-only in practice. A shared account gets the seed back from the
// one stretch sign-in already paid for; a relay-local account has no
// stored salt by design, so the salt is a pure function of the handle.
//
// ⚠️ `runCatching`: an address is worth having and nothing else on this
// screen depends on it. A device too old for Ed25519 from a raw seed
// should still sign in.
val accountKeySeed: ByteArray? = when (config.accountProvider) {
SiteConfig.AccountProvider.SHARED -> sharedSeed
SiteConfig.AccountProvider.LOCAL -> runCatching {
AccountKdf.accountKeySeed(
passphrase, AccountKdf.localAccountSalt(account.username))
}.getOrNull()
}
val fnid = accountKeySeed?.let {
runCatching { FNID.address(Ed25519.fromSeed(it).publicKey) }.getOrNull()
}
val vault = vault ?: error("the vault is not open")
val device = identity ?: DeviceIdentity.create().also {
vault.saveIdentity(it)
@@ -344,6 +393,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
username = account.username,
appPassword = credential,
userId = account.userId,
fnid = fnid,
))
session = Session(
@@ -362,6 +412,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
userId = account.userId,
deviceId = device.deviceId,
relay = config.host,
fnid = fnid,
status = "Registered with ${preKeys.size} one-time prekeys published.",
buddies = fetchedBuddies.getOrDefault(emptyList()),
buddiesLoaded = fetchedBuddies.isSuccess,