Android: the app, and the two things only a phone could tell us

There is now a Flamenet Messenger app that installs, launches, creates an
account on a self-hosted relay, registers a device, publishes prekeys, and
stores all of it in an Android Keystore-backed vault. Verified on an emulator
through the actual screens, not by unit test.

92 JVM tests, 37 cross-engine, 9 on-device, 3 against live relays.

Two findings, both of which the JVM suite was structurally incapable of
producing.

**Android has no ordinary Ed25519 provider.** `KeyPairGenerator.Ed25519` and
`KeyFactory.Ed25519` come from `AndroidKeyStore` and from nowhere else -- probed
on API 35 rather than assumed -- and Keystore only makes keys it keeps. Every
Ed25519 key in this engine is one we must hold: a device identity in our own
vault, an account key derived from a passphrase. So the JCA route could never
have worked. It compiled, passed 81 tests on a laptop, and threw "Not
initialized" on the first launch on a phone. Worse, it was LATENT: a wrong
password failed before any key was minted, so the error path worked perfectly
while the happy path was broken.

Ed25519 now uses BouncyCastle's `math.ec.rfc8032` core directly -- the engine's
one real dependency, present because the platform leaves a hole, and a strictly
better answer than the non-constant-time keygen it replaces. ProviderProbeTest
records the measurement so nobody guesses again.

**The prekey field is `prekeys`, not `opks`.** I guessed. The relay accepted the
registration, returned 200, and stored nothing -- so the device was published
and could not be started with, a failure with no error anywhere. Caught by a
live test against the throwaway fleet, which exists because the same path
through the emulator takes four minutes to say "timed out" and nothing about
where.

Two testing traps, recorded where they bite:

An indeterminate progress spinner animates for ever, so Compose's test clock
never goes idle and `waitUntil` never evaluates its condition -- it fails on the
full timeout, reading as "sign-up is broken" when sign-up works. Auto-advance is
now disabled around the wait only; disabling it for the whole test is the next
mistake, because the app opens on a spinner while it reads the vault.

Wiping the Keystore in `@Before` deletes the key the already-launched activity
is holding, and every later write fails against a key that no longer exists. The
test signs out through the UI instead, which is both safe and what a person does.

Failure messages now include what the screen was showing. A bare timeout sends
you to the network, the relay and the crypto in turn while the app has been
displaying the reason the whole time.

Also corrects a note that said this Mac had no Android SDK. It does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-23 17:02:16 -04:00
co-authored by Claude Opus 5
parent b1ba9cdc58
commit 5f983c1455
29 changed files with 1949 additions and 215 deletions
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="false"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.FlamenetMessenger">
<!--
allowBackup is off deliberately, and it is load-bearing here rather
than tidy. The vault's key lives in the Android Keystore and is never
backed up; the encrypted records would be. Restoring those onto a
different device would produce a vault nothing can open, which
presents as a corrupt install rather than as a missing key.
-->
<activity
android:name=".app.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,125 @@
package io.flamenet.messenger.app
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import io.flamenet.e2e.Vault
import java.io.File
import java.security.KeyStore
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
/**
* The vault's key, held by the platform, and its records, held as files.
*
* ⚠️ **The key is never seen by this process.** `AndroidKeyStore` returns a
* handle; the bytes stay in hardware-backed storage where the OS keeps them.
* That is why [io.flamenet.e2e.Vault] works with a `SecretKey` rather than raw
* bytes — a design that needed the bytes would have to keep them somewhere,
* which is exactly what this avoids.
*
* ⚠️ **The key does not leave this device, so neither does the vault.** It is
* generated here on first launch and is not exportable or backupable, which is
* the Android equivalent of the iOS client's `ThisDeviceOnly` Keychain item. A
* consequence worth stating plainly: **uninstalling the app destroys the key,
* and every stored conversation with it.** That is the intended behaviour —
* "deleted means deleted" — but it means the vault is not a backup, and the
* manifest turns `allowBackup` off so the records cannot be restored somewhere
* their key can never follow.
*/
object KeystoreVault {
private const val KEYSTORE = "AndroidKeyStore"
private const val ALIAS = "flamenet.messenger.vault.v1"
private const val DIRECTORY = "vault"
fun open(context: Context): Vault = Vault(FileStorage(File(context.filesDir, DIRECTORY)), key())
/**
* The vault key, generated on first use.
*
* ⚠️ No `setUserAuthenticationRequired`. Requiring a device unlock per use
* would sound stronger and would break background polling — the app cannot
* decrypt an incoming message while the screen is off, so mail silently
* stops arriving until the user opens the app. App lock is a separate
* feature at the UI layer, where it can be honest about what it protects.
*/
private fun key(): SecretKey {
val store = KeyStore.getInstance(KEYSTORE).apply { load(null) }
(store.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE).apply {
init(
KeyGenParameterSpec.Builder(
ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
// The vault supplies its own nonce per record, and a
// repeated nonce under one key is the catastrophic AES-GCM
// failure. Letting the caller provide it is only safe
// because that nonce is freshly random every write.
.setRandomizedEncryptionRequired(false)
.build()
)
}.generateKey()
}
/** Erase the key itself. Sign-out, and the only way the records become unrecoverable. */
fun destroy(context: Context) {
Vault(FileStorage(File(context.filesDir, DIRECTORY)), key()).forgetEverything()
KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(ALIAS)
}
/**
* Records as files under `filesDir`, which is app-private storage.
*
* ⚠️ Record names contain a device id and are used as filenames, so they
* are encoded rather than trusted: a name with a `/` in it would otherwise
* write outside the vault directory. The *unencoded* name is what goes into
* the record's AAD, so the binding survives the encoding.
*/
private class FileStorage(private val root: File) : Vault.Storage {
init { root.mkdirs() }
private fun fileFor(name: String) =
File(root, android.util.Base64.encodeToString(
name.toByteArray(Charsets.UTF_8),
android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING or android.util.Base64.NO_WRAP,
))
override fun read(name: String): ByteArray? =
fileFor(name).takeIf { it.exists() }?.readBytes()
override fun write(name: String, bytes: ByteArray) {
// Write beside and rename, so a kill mid-write leaves the previous
// record intact rather than a truncated one. A half-written session
// reads as tampering, which is the worst possible way to report
// "the phone ran out of battery".
val target = fileFor(name)
val staging = File(root, target.name + ".tmp")
staging.writeBytes(bytes)
if (!staging.renameTo(target)) {
staging.delete()
error("could not commit the vault record")
}
}
override fun delete(name: String) { fileFor(name).delete() }
override fun names(): List<String> =
root.listFiles().orEmpty()
.filterNot { it.name.endsWith(".tmp") }
.mapNotNull {
runCatching {
String(
android.util.Base64.decode(it.name, android.util.Base64.URL_SAFE),
Charsets.UTF_8,
)
}.getOrNull()
}
}
}
@@ -0,0 +1,201 @@
package io.flamenet.messenger.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MessengerTheme {
Surface(modifier = Modifier.fillMaxSize()) { Root() }
}
}
}
}
@Composable
private fun Root(model: MessengerViewModel = viewModel()) {
val phase by model.phase.collectAsStateWithLifecycle()
Scaffold { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
when (val current = phase) {
is MessengerViewModel.Phase.Starting -> CircularProgressIndicator()
is MessengerViewModel.Phase.SignedOut -> SignIn(model, current)
is MessengerViewModel.Phase.SignedIn -> SignedIn(model, current)
is MessengerViewModel.Phase.VaultUnreadable -> VaultUnreadable(model, current)
}
}
}
}
@Composable
private fun SignIn(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedOut) {
val relay by model.relayInput.collectAsStateWithLifecycle()
var handle by remember { mutableStateOf("") }
var passphrase by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var registering by remember { mutableStateOf(false) }
val needsEmail = registering &&
model.site?.accountProvider == SiteConfig.AccountProvider.LOCAL
Text("Flamenet Messenger", style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(
value = relay,
onValueChange = model::setRelay,
label = { Text("Server") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth().testTag("relay"),
)
// Say which kind of server this is BEFORE the passphrase is typed. Whether
// the passphrase leaves the device differs between the two, and that is not
// something to disclose afterwards.
model.site?.let {
Text(it.identityDescription, style = MaterialTheme.typography.bodySmall)
}
OutlinedTextField(
value = handle,
onValueChange = { handle = it },
label = { Text("Handle") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth().testTag("handle"),
)
// Only a relay keeping its own accounts asks for this — it is how the
// relay sends a reset code, and it is the only party that ever sees it.
// The shared service does not take one at all, so asking for it there
// would be collecting something nobody needs.
if (needsEmail) {
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth().testTag("email"),
)
Text(
"Used only by this server, to send a password reset.",
style = MaterialTheme.typography.bodySmall,
)
}
OutlinedTextField(
value = passphrase,
onValueChange = { passphrase = it },
label = { Text("Passphrase") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
modifier = Modifier.fillMaxWidth().testTag("passphrase"),
)
phase.error?.let {
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("error"))
}
Button(
onClick = {
model.signIn(handle.trim(), passphrase, register = registering, email = email.trim())
},
enabled = !phase.busy && handle.isNotBlank() && passphrase.isNotBlank() &&
(!needsEmail || email.isNotBlank()),
modifier = Modifier.fillMaxWidth().testTag("submit"),
) {
if (phase.busy) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Text(if (registering) "Create account" else "Sign in")
}
}
TextButton(
onClick = { registering = !registering },
modifier = Modifier.fillMaxWidth().testTag("toggle-register"),
) {
Text(if (registering) "I already have an account" else "Create an account")
}
}
@Composable
private fun SignedIn(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedIn) {
Text("Signed in", style = MaterialTheme.typography.headlineMedium)
Text("Relay: ${phase.relay}")
Text("Device: ${phase.deviceId}", modifier = Modifier.testTag("device-id"))
Text(phase.status, style = MaterialTheme.typography.bodySmall)
Button(onClick = model::signOut, modifier = Modifier.testTag("sign-out")) {
Text("Sign out")
}
// Signing out destroys the Keystore key, and every stored conversation with
// it. Said here rather than in a dialog nobody reads.
Text(
"Signing out erases this phone's keys and stored messages. They cannot be recovered.",
style = MaterialTheme.typography.bodySmall,
)
}
@Composable
private fun VaultUnreadable(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.VaultUnreadable,
) {
Text("Stored keys unreadable", style = MaterialTheme.typography.headlineMedium)
Text(
"This phone has keys saved for Flamenet Messenger, but they cannot be opened. " +
"That usually means the app was restored onto a different device, where the " +
"key that protects them could not follow.",
)
Text(phase.detail, style = MaterialTheme.typography.bodySmall)
Text(
"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")) {
Text("Erase and start fresh")
}
}
@@ -0,0 +1,199 @@
package io.flamenet.messenger.app
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import io.flamenet.client.AccountsClient
import io.flamenet.client.RelayClient
import io.flamenet.client.RelayAccounts
import io.flamenet.client.RelayException
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Vault
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* What the app is currently doing, and everything the screens read.
*
* Deliberately one state object rather than a scattering of flags. The iOS
* client's equivalent grew a `isLoading` next to an `error` next to a `phase`,
* and combinations that should be impossible — loading *and* signed out *and*
* showing an error — became reachable.
*/
class MessengerViewModel(app: Application) : AndroidViewModel(app) {
sealed interface Phase {
/** Reading the vault. Brief, but it decides which screen is first. */
data object Starting : Phase
data class SignedOut(val busy: Boolean = false, val error: String? = null) : Phase
data class SignedIn(
val handle: String,
val deviceId: String,
val relay: String,
val status: String,
) : Phase
/**
* The vault exists and will not open.
*
* ⚠️ Its own state, never folded into [SignedOut]. Treating it as
* signed-out sends the user to sign in, which mints a **new** device
* identity — and every contact then sees "security keys changed", the
* warning that is supposed to mean an attack. This screen has to say
* what happened and make the user choose.
*/
data class VaultUnreadable(val detail: String) : Phase
}
private val _phase = MutableStateFlow<Phase>(Phase.Starting)
val phase: StateFlow<Phase> = _phase.asStateFlow()
private val _relayInput = MutableStateFlow("relay.flamenet.io")
val relayInput: StateFlow<String> = _relayInput.asStateFlow()
private var vault: Vault? = null
private var identity: DeviceIdentity? = null
val site: SiteConfig? get() = SiteConfig.from(_relayInput.value)
fun setRelay(value: String) { _relayInput.value = value }
init { restore() }
/** Open the vault. A device already registered here goes straight in. */
private fun restore() = viewModelScope.launch {
val opened = withContext(Dispatchers.IO) {
runCatching {
val v = KeystoreVault.open(getApplication())
vault = v
v.loadIdentity()
}
}
opened.fold(
onSuccess = { stored ->
identity = stored
_phase.value = if (stored == null) {
Phase.SignedOut()
} else {
Phase.SignedIn(
handle = "",
deviceId = stored.deviceId,
relay = _relayInput.value,
status = "Device restored from this phone's vault.",
)
}
},
onFailure = { failure ->
_phase.value = Phase.VaultUnreadable(
failure.message ?: failure::class.simpleName.orEmpty())
},
)
}
fun signIn(handle: String, passphrase: String, register: Boolean, email: String = "") {
val config = site ?: run {
_phase.value = Phase.SignedOut(error = "That doesn't look like a server address.")
return
}
_phase.value = Phase.SignedOut(busy = true)
viewModelScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching { authenticate(config, handle, passphrase, register, email) }
}
result.fold(
onSuccess = { _phase.value = it },
onFailure = { _phase.value = Phase.SignedOut(error = describe(it)) },
)
}
}
/**
* Sign in, then make sure this phone has a device published.
*
* ⚠️ The identity is minted **once** and reused. Registering a fresh one on
* every sign-in is the failure mode this whole vault exists to prevent.
*/
private fun authenticate(
config: SiteConfig,
handle: String,
passphrase: String,
register: Boolean,
email: String,
): Phase {
val relayAccounts = RelayAccounts(config.baseUrl)
val account = when (config.accountProvider) {
SiteConfig.AccountProvider.SHARED -> {
// The passphrase is stretched on this device and only the
// derived secret reaches the account service. The token that
// comes back is then exchanged for a relay session — the relay
// learns a product-scoped pseudonym and not the handle.
val accounts = AccountsClient(SiteConfig.SHARED_ACCOUNT_SERVICE)
if (register) accounts.register(handle, passphrase)
relayAccounts.sharedSession(accounts.signIn(handle, passphrase).token)
}
// A relay-local account authenticates against the relay itself,
// which is what "this server keeps its own accounts" means. The
// relay returns a long-lived credential; the password is not kept.
SiteConfig.AccountProvider.LOCAL ->
if (register) {
relayAccounts.signUp(handle, email.ifBlank { "$handle@example.invalid" }, passphrase)
} else {
relayAccounts.signIn(handle, passphrase)
}
}
val credential = account.appPassword
val vault = vault ?: error("the vault is not open")
val device = identity ?: DeviceIdentity.create().also {
vault.saveIdentity(it)
identity = it
}
val relay = RelayClient(config.baseUrl, username = account.username, appPassword = credential)
relay.useRelayAuth(device)
// Publishing prekeys is what makes this phone reachable. A device that
// registers without them can send and can never be started with.
val preKeys = device.generateOneTimePreKeys(20)
relay.registerDevice(device, preKeys)
vault.saveIdentity(device)
return Phase.SignedIn(
handle = account.username,
deviceId = device.deviceId,
relay = config.host,
status = "Registered with ${preKeys.size} one-time prekeys published.",
)
}
fun signOut() = viewModelScope.launch {
withContext(Dispatchers.IO) { KeystoreVault.destroy(getApplication()) }
identity = null
vault = KeystoreVault.open(getApplication())
_phase.value = Phase.SignedOut()
}
/**
* ⚠️ Wrong handle and wrong passphrase must read the same. The account
* service answers them identically on purpose, and a message here that
* distinguishes them would undo that at the last step.
*/
private fun describe(failure: Throwable): String = when (failure) {
is AccountsClient.Failure.BadCredentials -> "That handle or passphrase isn't right."
is AccountsClient.Failure.HandleTaken -> "That handle is already taken."
is AccountsClient.Failure.RateLimited -> "Too many attempts. Wait a few minutes."
is AccountsClient.Failure.Transport -> failure.message.orEmpty()
is RelayException.Unauthorized -> "That handle or passphrase isn't right."
is RelayException.NotFound -> "This server doesn't look like a Flamenet relay."
is RelayException.RateLimited -> "Too many attempts. Wait a few minutes."
is Vault.Corrupt -> "This phone's stored keys could not be read."
else -> failure.message ?: "Something went wrong reaching that server."
}
}
@@ -0,0 +1,94 @@
package io.flamenet.messenger.app
import java.net.URI
/**
* Which relay this install talks to, and where its accounts live.
*
* The three deployment shapes a user can be in, and the only one they have to
* choose is the address:
*
* - **Managed** — our relay, accounts in the shared service.
* - **Self-hosted, shared identity** — their relay, accounts in the shared
* service, so one credential still covers Messenger and Files.
* - **Self-hosted, local** — their relay, its own account table. Not a lesser
* mode: everything works, it simply is not the same login as Files.
*/
data class SiteConfig(val baseUrl: String) {
enum class AccountProvider {
/** The shared FlameNet account service. One credential across products. */
SHARED,
/** The relay's own account table — what keeps self-hosting one service, not two. */
LOCAL,
}
companion object {
/**
* The hosts whose accounts live in the shared service.
*
* ⚠️ **A fixed list, decided here rather than asked of the relay.**
* Letting a relay declare its own identity provider would mean a
* compromised relay could name a host it controls and collect
* passphrase-derived secrets for accounts that are not its own. A
* self-hoster is unaffected: an unlisted host is treated as a relay
* with its own provider, which is the correct default.
*/
private val SHARED_ACCOUNT_HOSTS = setOf(
"flamenet.io",
"www.flamenet.io",
"messenger.flamenet.io",
// The same service under a second name, not a second service. It
// has to be listed explicitly — an unlisted host is treated as
// self-hosted, and that would silently be the wrong provider.
"relay.flamenet.io",
)
const val SHARED_ACCOUNT_SERVICE = "https://accounts.flamenet.io"
/**
* Accept what a person would actually type.
*
* ⚠️ Defaults to **https**. A bare hostname becoming `http://` would
* hand every request — key material included — to anyone on the path,
* and it would work, which is the worst outcome.
*/
fun from(typed: String): SiteConfig? {
val trimmed = typed.trim().removeSuffix("/")
if (trimmed.isEmpty()) return null
val withScheme = if (trimmed.contains("://")) trimmed else "https://$trimmed"
val uri = runCatching { URI(withScheme) }.getOrNull() ?: return null
val host = uri.host ?: return null
// A host with no dot is a typo or a LAN name; either way it is not
// something to silently accept as a public relay.
if (host.isBlank() || !host.contains('.')) return null
if (uri.scheme !in setOf("https", "http")) return null
val port = if (uri.port > 0) ":${uri.port}" else ""
return SiteConfig("${uri.scheme}://$host$port")
}
}
val host: String get() = runCatching { URI(baseUrl).host }.getOrNull().orEmpty()
/**
* Derived from the host every time rather than stored: a persisted value
* would outlive a change to the list above and quietly keep using the
* wrong provider.
*/
val accountProvider: AccountProvider
get() = if (host.lowercase() in SHARED_ACCOUNT_HOSTS) {
AccountProvider.SHARED
} else {
AccountProvider.LOCAL
}
val usesSharedAccounts: Boolean get() = accountProvider == AccountProvider.SHARED
/** What to tell the user this relay does about identity, in their words. */
val identityDescription: String
get() = when (accountProvider) {
AccountProvider.SHARED -> "One Flamenet account, shared with Flamenet Files."
AccountProvider.LOCAL -> "This server keeps its own accounts."
}
}
@@ -0,0 +1,44 @@
package io.flamenet.messenger.app
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* The Flamenet palette, matched to the iOS client's `Theme.swift` so the two
* apps look like the same product rather than two takes on it.
*/
private val Ember = Color(0xFFE2571F)
private val EmberDim = Color(0xFFB33F12)
private val Ash = Color(0xFF16130F)
private val Parchment = Color(0xFFFBF7F2)
@Composable
fun MessengerTheme(content: @Composable () -> Unit) {
val dark = isSystemInDarkTheme()
MaterialTheme(
colorScheme = if (dark) {
darkColorScheme(
primary = Ember,
onPrimary = Color.White,
background = Ash,
surface = Color(0xFF1F1B16),
onBackground = Parchment,
onSurface = Parchment,
)
} else {
lightColorScheme(
primary = EmberDim,
onPrimary = Color.White,
background = Parchment,
surface = Color.White,
onBackground = Ash,
onSurface = Ash,
)
},
content = content,
)
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Flamenet Messenger</string>
</resources>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
A bare Material theme. Compose paints everything; this exists so the
window has a background before the first frame, which is the difference
between launching and flashing white.
-->
<style name="Theme.FlamenetMessenger" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
</style>
</resources>