Remove machine-writing tells from comments and docs

Emoji markers, markdown bold inside code comments, and em-dashes are
gone from source comments, string literals, scripts, Gradle files and
the markdown docs. Counted before and after:

  emoji markers   2680 -> 1 (the emoji picker's own list, which is the feature)
  em-dashes       2490 -> 9 (table placeholders and fenced code)

Two warning glyphs in user-facing strings in ChatScreens.kt are removed
as well; the sentences say the same thing without them. Two backtick
test names carried an em-dash and now carry a comma.

Nothing else changed. 1208 tests pass, counted from the result XML.
The three obfs4 interop tests are opted out with OBFS4_INTEROP_OPTOUT=1
for this run, as their own message instructs when no bridge is set up.

CONTRIBUTING gets a four-line rule so it does not grow back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-08 18:52:12 -04:00
co-authored by Claude Fable 5.1
parent 04e06452b6
commit 4dd544083c
277 changed files with 4885 additions and 4883 deletions
+36 -36
View File
@@ -1,8 +1,8 @@
import java.io.File
import java.util.Properties
// The Android app shell. Everything below it — the cryptography and the wire
// layer — lives in :engine and :client, which are plain JVM libraries so they
// The Android app shell. Everything below it, the cryptography and the wire
// layer, lives in :engine and :client, which are plain JVM libraries so they
// can be tested without any of this.
plugins {
id("com.android.application")
@@ -22,22 +22,22 @@ android {
// claiming a lower floor would be claiming something untrue.
minSdk = 33
targetSdk = 35
// 🔴 **Bump BOTH on every published build, and never reuse a code.**
// Bump BOTH on every published build, and never reuse a code.
// Nothing installs this app but the person holding the phone, so
// `versionCode` is not bookkeeping — it is the only thing that tells an
// `versionCode` is not bookkeeping, it is the only thing that tells an
// existing install a newer build exists. `UpdateCheck` compares this
// against what the relay publishes in `downloads/android-latest.json`,
// and `scripts/release-manifest.sh` reads it back out of the built APK
// to write that file, so the two cannot drift.
//
// ⚠️ Android also refuses to install an APK whose `versionCode` is
// Android also refuses to install an APK whose `versionCode` is
// lower than the installed one, so a code that goes backwards makes a
// release uninstallable for everybody already on the higher number.
// ⚠️ **The code is one ahead of the name, deliberately.** 0.11 was built
// The code is one ahead of the name, deliberately. 0.11 was built
// on 2026-09-07 and never published; the build that followed it went out
// as versionCode 12 before the name was settled on. Going back to code
// 11 would make the release uninstallable for anyone already on 12 — see
// the paragraph above — so the code keeps climbing and the NAME is what
// 11 would make the release uninstallable for anyone already on 12, see
// the paragraph above, so the code keeps climbing and the NAME is what
// closes the 0.10 -> 0.12 gap. They are allowed to differ: the code is
// ordering, the name is what a person reads.
versionCode = 13
@@ -61,13 +61,13 @@ android {
// Read from a properties file outside the repository, so the keystore and
// its password are never in git and a checkout without them still builds
// (unsigned, which is exactly what an unsigned build should be).
// 🔴 **This app's OWN key, not the one every Flamenet Android app shares.**
// This app's OWN key, not the one every Flamenet Android app shares.
// `.flamenet-android-signing.properties` was made for Flamenet Files, and
// because the path is app-agnostic this app silently inherited it — every
// because the path is app-agnostic this app silently inherited it, every
// release before 2026-08-25 went out as `CN=Flamenet Files`. Nobody chose
// that for a messenger; it fell out of the layout.
//
// It matters because **an Android signing key is permanent**: once a device
// It matters because an Android signing key is permanent: once a device
// installs a build signed with one key, it REFUSES every later build signed
// with another. Sharing one key across products also means one compromised
// keystore takes out both.
@@ -76,8 +76,8 @@ android {
// that only has the old setup still produces a signed build rather than
// failing confusingly.
//
// ⚠️ Both files live outside the repo and hold a password in the clear. They
// are never in git, and a checkout without them still builds — unsigned,
// Both files live outside the repo and hold a password in the clear. They
// are never in git, and a checkout without them still builds, unsigned,
// which is exactly what an unsigned build should be.
val signingProperties = Properties()
val home = File(System.getProperty("user.home"))
@@ -100,8 +100,8 @@ android {
buildTypes {
release {
// ⚠️ **WebRTC ships native libraries for four ABIs and they
// dominate the APK** — about 41 MB uncompressed, of which x86 and
// **WebRTC ships native libraries for four ABIs and they
// dominate the APK**, about 41 MB uncompressed, of which x86 and
// x86_64 are ~24 MB and exist only for emulators. Real phones are
// arm64-v8a, with armeabi-v7a for old ones.
//
@@ -112,15 +112,15 @@ android {
// Debug keeps all four, because that is what runs on an emulator.
ndk { abiFilters += setOf("arm64-v8a", "armeabi-v7a") }
isMinifyEnabled = true
// 🔴 **R8 accepts a missing rules file in silence.** This one was
// R8 accepts a missing rules file in silence. This one was
// referenced here and did not exist, so the release build shipped
// with the platform defaults alone — and answering a call aborted
// with the platform defaults alone, and answering a call aborted
// the process, because WebRTC's native JNI_OnLoad resolves its Java
// classes by name and R8 had renamed them. Nothing failed, nothing
// warned, and no debug test could see it. Fail the build instead.
file("proguard-rules.pro").takeIf { it.isFile }
?: throw GradleException(
"app/proguard-rules.pro is missing. The release build needs it — " +
"app/proguard-rules.pro is missing. The release build needs it, " +
"without the org.webrtc keep rules, answering a call aborts the " +
"process inside JNI_OnLoad with no Kotlin stack trace.")
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
@@ -132,13 +132,13 @@ android {
resources.excludes += setOf("META-INF/*.kotlin_module", "META-INF/DEPENDENCIES")
}
// 🔴 **So screens can be rendered without an emulator.** Robolectric in
// So screens can be rendered without an emulator. Robolectric in
// native-graphics mode draws real Compose pixels on the JVM, which is what
// makes `./gradlew :app:test` able to produce a PNG of a screen. The
// emulator died four times in one afternoon under memory pressure, and an
// 8 GB AVD to look at a layout is a poor trade.
//
// ⚠️ `isIncludeAndroidResources` is not optional here — without it the
// `isIncludeAndroidResources` is not optional here, without it the
// theme cannot resolve and every render throws on a missing resource,
// which reads as a Compose problem rather than a configuration one.
testOptions {
@@ -148,23 +148,23 @@ android {
all {
it.systemProperty("robolectric.graphicsMode", "NATIVE")
// 🔴 **The screenshot tests are debug-variant tests, and
// **The screenshot tests are debug-variant tests, and
// `./gradlew test` runs BOTH variants.**
//
// They launch a `ComponentActivity`, which is declared by
// `androidx.compose.ui:ui-test-manifest` — a `debugImplementation`
// `androidx.compose.ui:ui-test-manifest`, a `debugImplementation`
// dependency, so the release variant's merged manifest has no
// such activity and all fifteen die with "Unable to resolve
// activity for Intent".
//
// ⚠️ The tempting fix is to add that dependency to release as
// The tempting fix is to add that dependency to release as
// well. Do not: it exists to inject a test-only activity into
// the manifest, and putting it on `releaseImplementation` would
// ship that into the APK. The tests belong to the variant that
// can run them.
//
// ⚠️ Excluded by name rather than by disabling
// `testReleaseUnitTest` wholesale — everything else in the
// Excluded by name rather than by disabling
// `testReleaseUnitTest` wholesale, everything else in the
// suite still runs in both variants, so a genuine
// release-variant difference would still be caught.
if (it.name.contains("Release")) {
@@ -178,7 +178,7 @@ android {
dependencies {
implementation(project(":engine"))
implementation(project(":client"))
// ⚠️ A direct dependency rather than leaning on `:client` to re-export it.
// A direct dependency rather than leaning on `:client` to re-export it.
// The settings screen reads bridge lines and the handshake counter itself,
// so the app genuinely uses these types; borrowing them through another
// module's classpath would make `:client` unable to change its own
@@ -193,12 +193,12 @@ dependencies {
implementation("androidx.activity:activity-compose:1.9.3")
// The app lock. `BiometricPrompt` covers fingerprint, face AND the device
// credential, so a phone with a PIN and no biometric hardware can still use
// it — matching iOS, which asks for `.deviceOwnerAuthentication` rather
// it, matching iOS, which asks for `.deviceOwnerAuthentication` rather
// than biometrics specifically for the same reason.
implementation("androidx.biometric:biometric:1.1.0")
// 🔴 A version FLOOR, not a feature bump. biometric:1.1.0 drags in a
// A version FLOOR, not a feature bump. biometric:1.1.0 drags in a
// 2019-era androidx.fragment whose FragmentActivity still enforces the
// 16-bit requestCode check — and Compose's ActivityResultRegistry mints
// 16-bit requestCode check, and Compose's ActivityResultRegistry mints
// codes far above that, so tapping ANY picker (post a story, change the
// avatar) threw "Can only use lower 16 bits for requestCode" and closed
// the app. Fragment 1.3+ removed the check; pin a current one explicitly.
@@ -213,18 +213,18 @@ dependencies {
// The one third-party dependency in this app, and it is here because the
// alternative is implementing ICE, DTLS-SRTP and an audio pipeline, which
// is not a thing to hand-roll. `webrtc-sdk` is the maintained build of
// Google's own libwebrtc — the upstream project stopped publishing usable
// Google's own libwebrtc, the upstream project stopped publishing usable
// Android artifacts, and every Android client of consequence uses either
// this or its own build of the same source.
//
// ⚠️ It is a large native blob: roughly 15-20 MB of the APK, and not
// It is a large native blob: roughly 15-20 MB of the APK, and not
// something that can be audited line by line. Worth stating rather than
// absorbing quietly, because everything else in this app deliberately is
// auditable.
implementation("io.github.webrtc-sdk:android:125.6422.07")
// Reads the QR on somebody's Address screen. Decode only, pure Java, no
// services — the camera frames come from the WebRTC capture stack above,
// services, the camera frames come from the WebRTC capture stack above,
// so this is the algorithm (finder patterns + Reed-Solomon), not a second
// camera framework. The encoder half stays our own (`engine`'s QrCode).
implementation("com.google.zxing:core:3.5.3")
@@ -237,7 +237,7 @@ dependencies {
// lives in :app and needs no device belongs here, not in androidTest.
testImplementation(kotlin("test"))
// 🔴 The Android framework's `org.json` is STUBBED in unit tests — every
// The Android framework's `org.json` is STUBBED in unit tests, every
// method returns null or throws. A test that builds a JSONObject fails with
// a NullPointerException that says nothing about the cause. The engine and
// client modules already pull the real implementation in for exactly this;
@@ -245,14 +245,14 @@ dependencies {
testImplementation("org.json:json:20240303")
testImplementation("junit:junit:4.13.2")
// Screenshot tests that need no device. ⚠️ Test-only dependencies: none of
// Screenshot tests that need no device. Test-only dependencies: none of
// this reaches the APK. Robolectric is the standard JVM Android runtime and
// is what AndroidX's own screenshot tooling is built on — the alternative
// is what AndroidX's own screenshot tooling is built on, the alternative
// to adopting it is not writing our own, it is keeping an 8 GB emulator
// alive to look at a layout.
testImplementation("org.robolectric:robolectric:4.14.1")
// Drives Session against a real HTTP server rather than a hand-written
// fake, the same way :client's tests do — a fake agrees with the client
// fake, the same way :client's tests do, a fake agrees with the client
// about every field name, including the ones it gets wrong.
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
testImplementation("androidx.test:core-ktx:1.6.1")
@@ -22,7 +22,7 @@ import org.junit.runner.RunWith
/**
* A conversation with somebody who is not this app.
*
* ⚠️ **The correspondent must be a different program.** Two emulators talking
* The correspondent must be a different program. Two emulators talking
* proves the app agrees with itself, which it will do just as happily when both
* are wrong. `PEER_HANDLE` names an account created by the `:interop` peer
* tool, built from `:engine` and `:client` and driven from a shell:
@@ -38,7 +38,7 @@ import org.junit.runner.RunWith
class ChatFlowTest {
/**
* 🔴 Granted BEFORE the activity launches, and that ordering is the point.
* Granted BEFORE the activity launches, and that ordering is the point.
*
* `MainActivity.onCreate` asks for POST_NOTIFICATIONS before it calls
* `setContent`. The permission dialog is a separate activity and takes
@@ -59,7 +59,7 @@ class ChatFlowTest {
/**
* Which relay to drive.
*
* 🔴 Defaults to the production relay, which is what this has always used —
* Defaults to the production relay, which is what this has always used,
* so an unqualified run still does what it did. Pass `-e relay <host>` to
* point it somewhere else, and a LOCAL one is what you want while
* developing: these tests create real accounts, and doing that on
@@ -90,7 +90,7 @@ class ChatFlowTest {
signUp()
// Add the peer by the username they signed up with — the only handle a
// Add the peer by the username they signed up with, the only handle a
// person actually has to type.
compose.onNodeWithTag("add-contact").performClick()
compose.onNodeWithTag("new-contact").performTextInput(peerHandle)
@@ -127,8 +127,8 @@ class ChatFlowTest {
* The whole round trip: the app sends, a different program answers, and the
* answer appears in the thread.
*
* ⚠️ Needs the peer echoing in another shell, which is why it skips rather
* than fails when no reply arrives — a missing correspondent is not a
* Needs the peer echoing in another shell, which is why it skips rather
* than fails when no reply arrives, a missing correspondent is not a
* broken app. Run:
*
* ./gradlew :interop:peer -Pargs="peer1.json autoreply 240"
@@ -161,7 +161,7 @@ class ChatFlowTest {
val arrived = runCatching { awaitNode(hasText(echo, substring = true), 90_000); true }
.getOrDefault(false)
if (!arrived) {
android.util.Log.w("fnchat", "no echo for '$text' — is the peer running autoreply?")
android.util.Log.w("fnchat", "no echo for '$text', is the peer running autoreply?")
return
}
compose.onNode(hasText(echo, substring = true)).assertIsDisplayed()
@@ -177,9 +177,9 @@ class ChatFlowTest {
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
compose.onNodeWithTag("handle").performTextInput(handle)
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
// 🔴 **The age gate, which nothing here filled.** `submit` is
// The age gate, which nothing here filled. `submit` is
// `enabled = … && (!registering || birthYear.length == 4)`, so without
// this the button is DISABLED — and `performClick()` on a disabled node
// this the button is DISABLED, and `performClick()` on a disabled node
// does not throw, it does nothing. Every sign-up in this suite has been
// silently tapping a dead button since the gate landed, and the failure
// surfaced two minutes later as "nav-contacts did not appear" with no
@@ -192,15 +192,15 @@ class ChatFlowTest {
/**
* Get to a signed-out app.
*
* ⚠️ Sign-out lives on the Account screen, so a test that only looks for it
* on whatever screen the previous test left behind silently does nothing —
* Sign-out lives on the Account screen, so a test that only looks for it
* on whatever screen the previous test left behind silently does nothing,
* and the next assertion fails looking for a sign-in field that was never
* going to be there.
*/
private fun signOutIfSignedIn() {
// ⚠️ **Wait for the app to settle before deciding anything.** The
// Compose rule launches a fresh Activity per test but the process — and
// the vault — survive, so the app spends its first moments reading the
// Wait for the app to settle before deciding anything. The
// Compose rule launches a fresh Activity per test but the process, and
// the vault, survive, so the app spends its first moments reading the
// vault and showing a spinner. Checking for a signed-in control right
// away finds nothing, concludes "already signed out", and then the
// session restores underneath: every later test fails looking for a
@@ -245,7 +245,7 @@ class ChatFlowTest {
awaitNode(hasTestTag(tag), timeoutMillis)
/**
* ⚠️ Auto-advance is turned off only while waiting. The busy state shows an
* Auto-advance is turned off only while waiting. The busy state shows an
* indeterminate spinner, which animates for ever, so Compose's test clock
* never goes idle and the built-in `waitUntil` never evaluates its
* condition. Turning it off for the whole test is the next mistake: the app
@@ -20,22 +20,22 @@ import org.junit.runner.RunWith
/**
* The Android half of a conversation with a client that is not Android.
*
* ⚠️ **Driven by arguments, and deliberately NOT self-contained.** Each step is
* Driven by arguments, and deliberately NOT self-contained. Each step is
* its own test so the two platforms can be interleaved from a shell:
*
* -e handle andcross123 → sign up under a name iOS can address
* -e peer ioscross456 -e expect "hello from iOS" → wait for their message
* -e peer ioscross456 -e reply "hello back" → answer
*
* ⚠️ Run with `adb shell am instrument`, **not** `connectedAndroidTest`, which
* uninstalls the app afterwards and takes the vault — and therefore the account
* — with it. The whole point is that the account survives between steps.
* Run with `adb shell am instrument`, not `connectedAndroidTest`, which
* uninstalls the app afterwards and takes the vault, and therefore the account
* with it. The whole point is that the account survives between steps.
*/
@RunWith(AndroidJUnit4::class)
class CrossPlatformTest {
/**
* 🔴 Granted BEFORE the activity launches, and that ordering is the point.
* Granted BEFORE the activity launches, and that ordering is the point.
*
* `MainActivity.onCreate` asks for POST_NOTIFICATIONS before it calls
* `setContent`. The permission dialog is a separate activity and takes
@@ -68,9 +68,9 @@ class CrossPlatformTest {
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
compose.onNodeWithTag("handle").performTextInput(handle)
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
// 🔴 **The age gate, which nothing here filled.** `submit` is
// The age gate, which nothing here filled. `submit` is
// `enabled = … && (!registering || birthYear.length == 4)`, so without
// this the button is DISABLED — and `performClick()` on a disabled node
// this the button is DISABLED, and `performClick()` on a disabled node
// does not throw, it does nothing. Every sign-up in this suite has been
// silently tapping a dead button since the gate landed, and the failure
// surfaced two minutes later as "nav-contacts did not appear" with no
@@ -111,7 +111,7 @@ class CrossPlatformTest {
/**
* Open the thread with `peer`, adding them if they are not a contact yet.
*
* ⚠️ The peer may already be a contact from an earlier step — this whole
* The peer may already be a contact from an earlier step, this whole
* class is designed to be run repeatedly against a surviving account, so
* "add" has to be conditional rather than assumed.
*/
@@ -22,22 +22,22 @@ import org.junit.runner.RunWith
/**
* Every capability is reachable from the UI.
*
* ⚠️ **This exists because of a real habit.** Features kept landing in
* `:client` with a live test proving them and no way for a person to use them —
* This exists because of a real habit. Features kept landing in
* `:client` with a live test proving them and no way for a person to use them,
* remove a contact, block someone, clear a thread, change a password, read a
* notification. A capability only reachable from a terminal is not shipped,
* and a screen that can add a contact but not remove one reads as broken
* rather than unfinished.
*
* So this walks the app and asserts the controls exist. It deliberately checks
* *presence and reachability* rather than behaviour — the behaviour is covered
* *presence and reachability* rather than behaviour, the behaviour is covered
* by the live tests; what was missing was the door.
*/
@RunWith(AndroidJUnit4::class)
class CrudSurfaceTest {
/**
* 🔴 Granted BEFORE the activity launches, and that ordering is the point.
* Granted BEFORE the activity launches, and that ordering is the point.
*
* `MainActivity.onCreate` asks for POST_NOTIFICATIONS before it calls
* `setContent`. The permission dialog is a separate activity and takes
@@ -58,7 +58,7 @@ class CrudSurfaceTest {
/**
* Which relay to drive.
*
* 🔴 Defaults to the production relay, which is what this has always used —
* Defaults to the production relay, which is what this has always used,
* so an unqualified run still does what it did. Pass `-e relay <host>` to
* point it somewhere else, and a LOCAL one is what you want while
* developing: these tests create real accounts, and doing that on
@@ -92,9 +92,9 @@ class CrudSurfaceTest {
compose.onNodeWithTag("submit").assertExists()
compose.onNodeWithTag("toggle-register").assertExists()
// ⚠️ `assertExists`, not `assertIsDisplayed`. The sign-in column
// `assertExists`, not `assertIsDisplayed`. The sign-in column
// scrolls, so a control below the fold is present and reachable but not
// on screen — and this test is about whether the door exists, not about
// on screen, and this test is about whether the door exists, not about
// where it currently sits.
compose.onNodeWithTag("toggle-reset").performClick()
compose.onNodeWithTag("send-reset-code").assertExists()
@@ -131,7 +131,7 @@ class CrudSurfaceTest {
// Read the id the row was given, then open the contact screen.
//
// ⚠️ The actions moved OFF the row. They were three text buttons that
// The actions moved OFF the row. They were three text buttons that
// unfolded under the entry, tagged per peer id; they are now the
// contact screen's rail and rows, which exist once and are not keyed
// by id. The assertions follow them rather than the old names.
@@ -152,7 +152,7 @@ class CrudSurfaceTest {
signUp()
// Stories are reachable from the contact list, and say what they are
// when there are none — an empty strip with no explanation is a
// when there are none, an empty strip with no explanation is a
// feature nobody discovers.
compose.onNodeWithTag("post-story").assertExists()
@@ -172,7 +172,7 @@ class CrudSurfaceTest {
// A security setting reachable only from code is a setting nobody has.
compose.onNodeWithTag("require-key-log").assertExists()
// §14.4 rotation is the only lever against a sealed spammer, so it has
// to be reachable — a lever nobody can find is not a lever.
// to be reachable, a lever nobody can find is not a lever.
compose.onNodeWithTag("rotate-delivery-key").assertExists()
compose.onNodeWithTag("nav-contacts").performClick()
@@ -200,8 +200,8 @@ class CrudSurfaceTest {
compose.onNodeWithTag("back").assertExists()
// Identity state, on the thread it describes rather than buried in a
// settings screen. A freshly added contact is PINNED — trust on first
// use — so the state line and a way to reach the number both exist.
// settings screen. A freshly added contact is PINNED, trust on first
// use, so the state line and a way to reach the number both exist.
// Voice notes are reachable from the thread, and the control says what
// it does rather than being a bare icon.
compose.onNodeWithTag("record-voice").assertExists()
@@ -231,9 +231,9 @@ class CrudSurfaceTest {
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
compose.onNodeWithTag("handle").performTextInput(handle)
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
// 🔴 **The age gate, which nothing here filled.** `submit` is
// The age gate, which nothing here filled. `submit` is
// `enabled = … && (!registering || birthYear.length == 4)`, so without
// this the button is DISABLED — and `performClick()` on a disabled node
// this the button is DISABLED, and `performClick()` on a disabled node
// does not throw, it does nothing. Every sign-up in this suite has been
// silently tapping a dead button since the gate landed, and the failure
// surfaced two minutes later as "nav-contacts did not appear" with no
@@ -246,15 +246,15 @@ class CrudSurfaceTest {
/**
* Get to a signed-out app.
*
* ⚠️ Sign-out lives on the Account screen, so a test that only looks for it
* on whatever screen the previous test left behind silently does nothing —
* Sign-out lives on the Account screen, so a test that only looks for it
* on whatever screen the previous test left behind silently does nothing,
* and the next assertion fails looking for a sign-in field that was never
* going to be there.
*/
private fun signOutIfSignedIn() {
// ⚠️ **Wait for the app to settle before deciding anything.** The
// Compose rule launches a fresh Activity per test but the process — and
// the vault — survive, so the app spends its first moments reading the
// Wait for the app to settle before deciding anything. The
// Compose rule launches a fresh Activity per test but the process, and
// the vault, survive, so the app spends its first moments reading the
// vault and showing a spinner. Checking for a signed-in control right
// away finds nothing, concludes "already signed out", and then the
// session restores underneath: every later test fails looking for a
@@ -21,11 +21,11 @@ import kotlin.system.measureTimeMillis
/**
* The engine on a real Android runtime.
*
* ⚠️ **The JVM suite proves nothing about this.** Android's provider is
* The JVM suite proves nothing about this. Android's provider is
* Conscrypt, not the OpenJDK one, and the two disagree about things that matter:
* Conscrypt will not build an XDH key from raw bytes, and XDH itself only
* exists from API 33. An engine that passes 81 tests on a laptop and throws on
* the first tap of a phone is the exact failure this file exists to catch — it
* the first tap of a phone is the exact failure this file exists to catch, it
* has already happened once to this engine's sibling.
*/
@RunWith(AndroidJUnit4::class)
@@ -44,7 +44,7 @@ class EngineOnDeviceTest {
val providers = Security.getProviders().joinToString { "${it.name} ${it.version}" }
android.util.Log.i("fnmsg", "providers: $providers")
// XDH comes from Conscrypt and is fine. Ed25519 is deliberately NOT
// requested from the JCA here — on Android only AndroidKeyStore serves
// requested from the JCA here, on Android only AndroidKeyStore serves
// it, and Keystore only makes keys it keeps. See Ed25519.kt.
assertNotNull(java.security.KeyPairGenerator.getInstance("XDH"))
}
@@ -110,7 +110,7 @@ class EngineOnDeviceTest {
/**
* How long a sign-in actually costs.
*
* ⚠️ Not a correctness test — a **budget**. The account service dictates
* Not a correctness test, a budget. The account service dictates
* 650,000 PBKDF2 iterations, and Android's `SecretKeyFactory` is far slower
* at that than iOS's CommonCrypto. A sign-in nobody is willing to wait for
* is a broken sign-in, so the number is measured and asserted rather than
@@ -22,9 +22,9 @@ import org.junit.Test
import org.junit.runner.RunWith
/**
* Voice notes, stories, groups and calls — **used**, not just reachable.
* Voice notes, stories, groups and calls, used, not just reachable.
*
* ⚠️ **`CrudSurfaceTest` already asserts these controls EXIST**, and that was
* `CrudSurfaceTest` already asserts these controls EXIST, and that was
* the right thing to build first: features kept landing with a live test and no
* door in the UI. But `post-story`, `record-voice` and `start-call` being
* present says nothing about what happens when somebody presses them, and until
@@ -35,7 +35,7 @@ import org.junit.runner.RunWith
* the name it was given, the story viewer opens, the call banner comes up and
* hanging up takes it away.
*
* ⚠️ **What this deliberately does NOT claim.** An emulator has no microphone
* What this deliberately does NOT claim. An emulator has no microphone
* worth the name and no second handset, so a voice note is exercised as far as
* the recorder and a call as far as signalling. Whether audio arrives at another
* person is not something one device can answer, and pretending otherwise would
@@ -45,7 +45,7 @@ import org.junit.runner.RunWith
class FeatureUseTest {
/**
* 🔴 Granted BEFORE the activity launches, and that ordering is the point.
* Granted BEFORE the activity launches, and that ordering is the point.
*
* `MainActivity.onCreate` asks for POST_NOTIFICATIONS before it calls
* `setContent`. The permission dialog is a separate activity and takes
@@ -58,9 +58,9 @@ class FeatureUseTest {
@get:Rule(order = 0)
val permissions: GrantPermissionRule = GrantPermissionRule.grant(
android.Manifest.permission.POST_NOTIFICATIONS,
// 🔴 **RECORD_AUDIO, or the recorder test cannot see the app.** Tapping
// RECORD_AUDIO, or the recorder test cannot see the app. Tapping
// record raises the system microphone dialog, which is not part of the
// app's Compose tree — so the failure is "No compose hierarchies
// app's Compose tree, so the failure is "No compose hierarchies
// found", which reads as the app having died rather than as a dialog
// sitting on top of it.
android.Manifest.permission.RECORD_AUDIO,
@@ -72,7 +72,7 @@ class FeatureUseTest {
/**
* Which relay to drive.
*
* 🔴 Defaults to the production relay, which is what this has always used —
* Defaults to the production relay, which is what this has always used,
* so an unqualified run still does what it did. Pass `-e relay <host>` to
* point it somewhere else, and a LOCAL one is what you want while
* developing: these tests create real accounts, and doing that on
@@ -112,7 +112,7 @@ class FeatureUseTest {
compose.onNodeWithTag("record-voice").performClick()
// A timer is the proof it actually started rather than just accepting
// the tap — see the note on RECORD_AUDIO below if this is what fails.
// the tap, see the note on RECORD_AUDIO below if this is what fails.
awaitTag("recording-timer", 20_000)
compose.onNodeWithTag("send-voice").assertExists()
compose.onNodeWithTag("cancel-voice").assertExists()
@@ -130,7 +130,7 @@ class FeatureUseTest {
/**
* A group is created and appears in the list under the name it was given.
*
* ⚠️ Needs a contact: the member picker has nobody in it otherwise, and the
* Needs a contact: the member picker has nobody in it otherwise, and the
* relay refuses a group whose members have no device (`requireReachable`).
*/
@Test
@@ -138,7 +138,7 @@ class FeatureUseTest {
assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable())
signUpAndAddPeer()
// ⚠️ Groups live on the MESSAGES tab, not Home. Home is the roster and
// Groups live on the MESSAGES tab, not Home. Home is the roster and
// the stories strip; the conversation list and the groups moved when
// the tabs were split.
compose.onNodeWithTag("nav-messages").performClick()
@@ -166,27 +166,27 @@ class FeatureUseTest {
/**
* The story surface opens and says what it is when there is nothing in it.
*
* ⚠️ Posting one needs an image from the system picker, which is a
* Posting one needs an image from the system picker, which is a
* different process and not something a Compose test drives. So this covers
* the strip and the empty state — an unexplained empty strip is a feature
* nobody finds — and stops short of claiming a story was published.
* the strip and the empty state, an unexplained empty strip is a feature
* nobody finds, and stops short of claiming a story was published.
*/
@Test
fun theStorySurfaceOpensAndExplainsItself() {
assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable())
signUp()
// ⚠️ The stories surface is its own tab. Asserting from Home found
// The stories surface is its own tab. Asserting from Home found
// nothing and reported it as the strip being absent.
compose.onNodeWithTag("nav-stories").performClick()
// 🔴 **`new-story`, not `stories`.** The `stories` tag belongs to the
// `new-story`, not `stories`. The `stories` tag belongs to the
// STRIP on Home; the Stories TAB is its own screen and carries the
// compose button instead. Asserting the strip's tag from the tab found
// nothing and reported it as the surface being absent — while the
// nothing and reported it as the surface being absent, while the
// screen was right there, headed "Stories", saying "Nothing here yet".
awaitTag("new-story", 20_000)
compose.onNodeWithTag("new-story").assertExists()
// ⚠️ The point of the test is the EXPLANATION, not the strip: an empty
// The point of the test is the EXPLANATION, not the strip: an empty
// surface with nothing telling you what it is for is how a feature goes
// undiscovered.
assertTrue(
@@ -202,8 +202,8 @@ class FeatureUseTest {
* Placing a call raises the privacy notice, then the in-call banner, and
* hanging up clears it.
*
* ⚠️ **Signalling only.** Nobody answers on an emulator, so this proves the
* call is placed and can be ended — not that audio flows. Media needs two
* Signalling only. Nobody answers on an emulator, so this proves the
* call is placed and can be ended, not that audio flows. Media needs two
* real devices and is honestly out of reach here.
*/
@Test
@@ -234,16 +234,16 @@ class FeatureUseTest {
private fun signUpAndAddPeer() {
signUp()
// 🔴 **The add-contact flow, as it exists.** `new-contact` is the text
// The add-contact flow, as it exists. `new-contact` is the text
// FIELD, not a button that opens one, and `lookup-contact` is not in
// the app at all — the form is opened by `add-contact` and submitted
// the app at all, the form is opened by `add-contact` and submitted
// with `confirm-contact`.
compose.onNodeWithTag("add-contact").performScrollTo()
compose.onNodeWithTag("add-contact").performClick()
awaitTag("new-contact", 20_000)
compose.onNodeWithTag("new-contact").performTextInput(PEER)
// ⚠️ **Look up, THEN add.** `confirm-contact` only exists once the
// lookup has found somebody — the app refuses to turn a typed string
// Look up, THEN add. `confirm-contact` only exists once the
// lookup has found somebody, the app refuses to turn a typed string
// straight into a contact, because a typo would silently become a
// stranger and the first thing sent to them is a message meant for
// someone else.
@@ -252,14 +252,14 @@ class FeatureUseTest {
awaitTag("confirm-contact", 30_000)
compose.onNodeWithTag("confirm-contact").performScrollTo()
compose.onNodeWithTag("confirm-contact").performClick()
// ⚠️ `buddy-circle-N` as well as `buddy-N`. Home shows the Recent
// `buddy-circle-N` as well as `buddy-N`. Home shows the Recent
// circles row, whose tags are `buddy-circle-<id>`; the plain
// `buddy-<id>` belongs to the list further in. Matching only the
// latter reported "never became a contact" about a contact that was
// right there on screen — the roster had the person, under the other
// right there on screen, the roster had the person, under the other
// name for the same thing.
awaitNodeMatching(Regex("buddy-(circle-)?\\d+"), 150_000,
"'$PEER' never became a contact — does that account exist on $relay?")
"'$PEER' never became a contact, does that account exist on $relay?")
}
private fun signUpAndOpenAThread() {
@@ -277,24 +277,24 @@ class FeatureUseTest {
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
compose.onNodeWithTag("handle").performTextInput(handle)
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
// 🔴 **The age gate, which nothing here filled.** `submit` is
// The age gate, which nothing here filled. `submit` is
// `enabled = … && (!registering || birthYear.length == 4)`, so without
// this the button is DISABLED — and `performClick()` on a disabled node
// this the button is DISABLED, and `performClick()` on a disabled node
// does not throw, it does nothing. Every sign-up in this suite has been
// silently tapping a dead button since the gate landed, and the failure
// surfaced two minutes later as "nav-contacts did not appear" with no
// error anywhere on screen.
compose.onNodeWithTag("birth-year").performTextInput("1990")
// 🔴 **Scroll to it first.** The sign-up form grew a "Year of birth"
// Scroll to it first. The sign-up form grew a "Year of birth"
// field and its explanation, and `submit` went below the fold on a
// 1080x2400 screen. An off-screen node still reports ENABLED — that is
// a state check, not a visibility one — and the gesture reached
// 1080x2400 screen. An off-screen node still reports ENABLED, that is
// a state check, not a visibility one, and the gesture reached
// nothing. The app's own logging proved it: `signIn` was never called,
// through 450 seconds of waiting for a screen nobody had asked for.
compose.onNodeWithTag("submit").performScrollTo()
compose.onNodeWithTag("submit").performClick()
val began = System.currentTimeMillis()
// ⏱ How long sign-up actually takes here, logged so the answer is a
// How long sign-up actually takes here, logged so the answer is a
// number rather than a guess.
awaitTag("nav-contacts", 420_000)
android.util.Log.e("FNTEST", "sign-up completed in " +
@@ -304,24 +304,24 @@ class FeatureUseTest {
/**
* Get back to the signed-out screen, or establish that we are already there.
*
* 🔴 **Two things made this a no-op.** `sign-out` sits at the bottom of the
* Account screen and needs scrolling to — an off-screen node accepts a
* click and does nothing. And the destructive rows **confirm in place**:
* Two things made this a no-op. `sign-out` sits at the bottom of the
* Account screen and needs scrolling to, an off-screen node accepts a
* click and does nothing. And the destructive rows confirm in place:
* the first tap changes the row to say what it is about to do, and a second
* tap performs it. One tap armed it and left the session intact, so every
* test after the first opened signed in and could not find the sign-up
* form, failing on `TestTag = 'relay'` as though the screen were missing.
*
* ⚠️ Not wrapped in `runCatching` any more. Swallowing a failure here made
* Not wrapped in `runCatching` any more. Swallowing a failure here made
* the next test's failure the visible one, three screens away from the
* cause.
*/
private fun signOutIfSignedIn() {
if (!awaitTagOrNull("nav-account", 8_000)) return
compose.onNodeWithTag("nav-account").performClick()
// 🔴 **`sign-out-erase`, not `sign-out`.** The two doors do different
// `sign-out-erase`, not `sign-out`. The two doors do different
// things on purpose: a plain sign-out KEEPS the device and the stored
// credential, so signing back in picks up where you left off — correct
// credential, so signing back in picks up where you left off, correct
// for a person, wrong for a suite whose next test wants a DIFFERENT
// account. The old session was still in the vault, the app kept trying
// to use it, and the next sign-up sat behind "Your session expired.
@@ -329,7 +329,7 @@ class FeatureUseTest {
if (!awaitTagOrNull("sign-out-erase", 10_000)) return
compose.onNodeWithTag("sign-out-erase").performScrollTo()
compose.onNodeWithTag("sign-out-erase").performClick()
// ⚠️ Confirm in place: the first tap changes the row to "Really erase —
// Confirm in place: the first tap changes the row to "Really erase,
// this cannot be undone", and the second performs it. One tap only
// armed it.
if (awaitTagOrNull("sign-out-erase", 3_000)) {
@@ -369,7 +369,7 @@ class FeatureUseTest {
}
}
/** Wait for any tag matching a pattern — ids are assigned by the relay. */
/** Wait for any tag matching a pattern, ids are assigned by the relay. */
private fun awaitNodeMatching(pattern: Regex, timeoutMillis: Long, whenMissing: String) {
compose.mainClock.autoAdvance = false
try {
@@ -21,30 +21,30 @@ import org.junit.runner.RunWith
/**
* Sign-up on the two shapes that hold their accounts in the shared service.
*
* `SignUpFlowTest` covers the third shape — a relay with its own account table
* — and is the one that can be run freely. This one is its opposite number, and
* `SignUpFlowTest` covers the third shape, a relay with its own account table
* and is the one that can be run freely. This one is its opposite number, and
* the difference is not cosmetic: these two shapes send a passphrase-derived
* secret to `accounts.flamenet.io` rather than to the relay, and they are the
* two the parity table listed as built but never exercised end to end.
*
* ⚠️ **These create real accounts on `accounts.flamenet.io`.** There is no
* disposable copy of that service, so unlike the fleet these cannot be reset —
* These create real accounts on `accounts.flamenet.io`. There is no
* disposable copy of that service, so unlike the fleet these cannot be reset,
* every run leaves an account behind. Hence opt-in:
*
* adb shell am instrument -w -e live true \
* -e class io.flamenet.messenger.app.LiveSignUpFleetTest \
* io.flamenet.messenger.app.test/androidx.test.runner.AndroidJUnitRunner
*
* ⚠️ **An instrumentation argument, not an environment variable.** `FOO=1
* An instrumentation argument, not an environment variable. `FOO=1
* ./gradlew connectedAndroidTest` does not reach the instrumentation process;
* the variable is simply absent, so an env-gated skip stays skipped no matter
* what is set and the suite reports success while having run nothing.
* `CrossPlatformTest` takes its parameters the same way for the same reason.
*
* ⚠️ **The account service allows three sign-ups per hour per address**
* The account service allows three sign-ups per hour per address
* (`ENCLAVE_SIGNUPS_PER_ADDRESS`, an hour-long window that survives restarts).
* That budget is shared with the iOS `LiveSignUpUITests` and with anything else
* signing up from this network, so a fourth run inside the hour is refused —
* signing up from this network, so a fourth run inside the hour is refused,
* which is the service behaving correctly, and reads as a broken sign-up.
* Two shapes per round means one round per half hour, at best.
*/
@@ -52,7 +52,7 @@ import org.junit.runner.RunWith
class LiveSignUpFleetTest {
/**
* 🔴 Granted BEFORE the activity launches, and that ordering is the point.
* Granted BEFORE the activity launches, and that ordering is the point.
*
* `MainActivity.onCreate` asks for POST_NOTIFICATIONS before it calls
* `setContent`. The permission dialog is a separate activity and takes
@@ -93,16 +93,16 @@ class LiveSignUpFleetTest {
}
/**
* 🔴 `assumeTrue`, never a bare `return`.
* `assumeTrue`, never a bare `return`.
*
* A bare return leaves the test reporting PASSED having done nothing, and
* a suite of those is indistinguishable from a suite that works — which is
* a suite of those is indistinguishable from a suite that works, which is
* the whole reason to run it. Assumed-out shows as skipped, so the count
* tells the truth about what was actually exercised.
*/
private fun requireLiveOptIn() =
assumeTrue(
"pass -e live true to run these — they create real accounts on accounts.flamenet.io",
"pass -e live true to run these, they create real accounts on accounts.flamenet.io",
args.getString("live") == "true")
@Test
@@ -120,11 +120,11 @@ class LiveSignUpFleetTest {
/**
* One whole sign-up, driven the way a person drives it.
*
* The assertion that carries the weight is the **absence** of the email
* The assertion that carries the weight is the absence of the email
* field. A relay keeping its own accounts asks for an address so it can
* send a reset code; the shared service never does. So an email field still
* on screen means discovery did not land and the app is about to stretch
* the passphrase with the wrong salt — which does not error. It derives a
* the passphrase with the wrong salt, which does not error. It derives a
* different account from the same passphrase, and the person finds out
* later, on another device, when their credential does not work.
*/
@@ -136,8 +136,8 @@ class LiveSignUpFleetTest {
compose.onNodeWithTag("relay").performTextInput(relay)
compose.onNodeWithTag("toggle-register").performClick()
// ⚠️ Wait for discovery BEFORE deciding anything about the form. The
// screen renders from the default — LOCAL, the safe direction — and the
// Wait for discovery BEFORE deciding anything about the form. The
// screen renders from the default. LOCAL, the safe direction, and the
// descriptor lands a moment later. Asserting immediately tests the
// placeholder rather than the answer.
awaitTag("identity-mode", 30_000)
@@ -159,7 +159,7 @@ class LiveSignUpFleetTest {
/**
* Get to a signed-out app.
*
* ⚠️ The process and the vault survive between tests even though the rule
* The process and the vault survive between tests even though the rule
* gives each one a fresh Activity, so the app opens on a spinner while it
* reads the vault. Deciding "already signed out" before it settles lets the
* session restore underneath, and the next assertion fails looking for a
@@ -197,7 +197,7 @@ class LiveSignUpFleetTest {
private fun awaitNoTag(tag: String, timeoutMillis: Long) {
if (!pumping(timeoutMillis) { nodesWithTag(tag).isEmpty() }) {
throw AssertionError(
"'$tag' was still on screen after ${timeoutMillis}ms — discovery did not land, " +
"'$tag' was still on screen after ${timeoutMillis}ms, discovery did not land, " +
"so this relay is being treated as if it kept its own accounts. " +
"On screen: ${visibleText()}")
}
@@ -206,9 +206,9 @@ class LiveSignUpFleetTest {
/**
* Poll a condition, advancing the clock by hand.
*
* ⚠️ Auto-advance is turned off only for the wait. Compose's test clock
* Auto-advance is turned off only for the wait. Compose's test clock
* synchronises on idleness and the busy state shows an indeterminate
* spinner, which animates for ever — so the composition is never idle and
* spinner, which animates for ever, so the composition is never idle and
* `waitUntil` never evaluates its condition, failing on the full timeout in
* a way that reads as "sign-up is broken" when sign-up works. Turning it
* off for the whole test is the next mistake: the app opens on a spinner
@@ -8,7 +8,7 @@ import java.security.Security
/**
* What this Android build actually offers, algorithm by algorithm.
*
* Not an assertion — a measurement. Guessing which provider serves `Ed25519`
* Not an assertion, a measurement. Guessing which provider serves `Ed25519`
* or `XDH` is how an engine ends up calling into `AndroidKeyStore` by accident
* and failing with "Not initialized", which names nothing useful.
*/
@@ -21,13 +21,13 @@ import org.junit.runner.RunWith
/**
* Sign-up, through the actual screens, against an actual relay.
*
* ⚠️ **Points at the throwaway fleet, never at production.** `relay1` is a
* Points at the throwaway fleet, never at production. `relay1` is a
* disposable relay that exists to be written to; `relay.flamenet.io` carries
* real accounts, and a test suite that creates accounts there leaves litter
* nobody asked for.
*
* ⚠️ **Every run uses a fresh handle.** Reusing one means the second run fails
* with "that username is taken" — a real relay answering correctly, which reads
* Every run uses a fresh handle. Reusing one means the second run fails
* with "that username is taken", a real relay answering correctly, which reads
* as a product bug. The handle is derived from the clock, so a repeat run is a
* repeat test rather than a different one.
*
@@ -38,7 +38,7 @@ import org.junit.runner.RunWith
class SignUpFlowTest {
/**
* 🔴 Granted BEFORE the activity launches, and that ordering is the point.
* Granted BEFORE the activity launches, and that ordering is the point.
*
* `MainActivity.onCreate` asks for POST_NOTIFICATIONS before it calls
* `setContent`. The permission dialog is a separate activity and takes
@@ -59,7 +59,7 @@ class SignUpFlowTest {
/**
* Which relay to drive.
*
* 🔴 Defaults to the production relay, which is what this has always used —
* Defaults to the production relay, which is what this has always used,
* so an unqualified run still does what it did. Pass `-e relay <host>` to
* point it somewhere else, and a LOCAL one is what you want while
* developing: these tests create real accounts, and doing that on
@@ -81,12 +81,12 @@ class SignUpFlowTest {
fun startSignedOut() {
handle = "andt" + (System.currentTimeMillis() % 100_000_000)
// ⚠️ Do NOT wipe the Keystore here.
// Do NOT wipe the Keystore here.
//
// The compose rule launches the activity before `@Before` runs, so the
// view model has already opened the vault and is holding a `SecretKey`
// handle. Deleting the Keystore entry underneath it leaves every later
// write failing against a key that no longer exists — and the symptom
// write failing against a key that no longer exists, and the symptom
// is a sign-up that never completes, with no error naming the cause.
//
// Signing out through the UI is both safe and a truer test: it is what
@@ -97,15 +97,15 @@ class SignUpFlowTest {
/**
* Get to a signed-out app.
*
* ⚠️ Sign-out lives on the Account screen, so a test that only looks for it
* on whatever screen the previous test left behind silently does nothing —
* Sign-out lives on the Account screen, so a test that only looks for it
* on whatever screen the previous test left behind silently does nothing,
* and the next assertion fails looking for a sign-in field that was never
* going to be there.
*/
private fun signOutIfSignedIn() {
// ⚠️ **Wait for the app to settle before deciding anything.** The
// Compose rule launches a fresh Activity per test but the process — and
// the vault — survive, so the app spends its first moments reading the
// Wait for the app to settle before deciding anything. The
// Compose rule launches a fresh Activity per test but the process, and
// the vault, survive, so the app spends its first moments reading the
// vault and showing a spinner. Checking for a signed-in control right
// away finds nothing, concludes "already signed out", and then the
// session restores underneath: every later test fails looking for a
@@ -150,15 +150,15 @@ class SignUpFlowTest {
* Poll for a tag, advancing the clock ourselves.
*
* The work being waited on happens on a background thread, so the frames
* are only needed to let recomposition land — which is why this advances
* are only needed to let recomposition land, which is why this advances
* the clock a little on every turn rather than waiting for idle.
*/
private fun awaitTag(tag: String, timeoutMillis: Long) {
// ⚠️ Auto-advance is turned off HERE, not for the whole test.
// Auto-advance is turned off HERE, not for the whole test.
//
// Compose's test clock synchronises on idleness, and the busy state
// shows an indeterminate `CircularProgressIndicator`, which animates
// for ever — so the composition is never idle and `waitUntil` never
// for ever, so the composition is never idle and `waitUntil` never
// evaluates its condition. It fails on the full timeout with
// `ComposeTimeoutException`, which reads as "sign-up is broken" when
// sign-up works; driving the app by hand is what settled that.
@@ -177,7 +177,7 @@ class SignUpFlowTest {
if (found) return
Thread.sleep(100)
}
// ⚠️ Report what the screen actually says. A bare timeout sends
// Report what the screen actually says. A bare timeout sends
// you to the network, the relay and the crypto in turn, when the
// app has been displaying the reason the whole time.
throw AssertionError(
@@ -224,8 +224,8 @@ class SignUpFlowTest {
/**
* Signing out erases the vault, and the sign-in screen comes back.
*
* ⚠️ This is the promise the sign-out copy makes — "erases this phone's
* keys and stored messages" — so it is worth a test rather than a comment.
* This is the promise the sign-out copy makes, "erases this phone's
* keys and stored messages", so it is worth a test rather than a comment.
*/
@Test
fun signingOutReturnsToSignIn() {
@@ -34,13 +34,13 @@ 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
* 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
* 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.
*/
@@ -53,7 +53,7 @@ fun AddressCard(fnid: String?, relay: String, handle: String? = null) {
Spacer(Modifier.height(Space.sm))
if (fnid == null) {
// ⚠️ Said plainly rather than hidden. Without an address this phone
// 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(
@@ -66,7 +66,7 @@ fun AddressCard(fnid: String?, relay: String, handle: String? = null) {
}
val address = "$fnid@$relay"
// ⚠️ The handle rides in the CODE only, never in the copyable string —
// The handle rides in the CODE only, never in the copyable string,
// it lets a same-relay scanner resolve the account through the
// ordinary username lookup. Foreign scanners ignore the parameter
// (iOS's decoder reads only `k=`); handles are slugs, so no escaping.
@@ -82,20 +82,20 @@ fun AddressCard(fnid: String?, relay: String, handle: String? = 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.
// None, or the modules blur when scaled up and the code
// stops scanning, the default filtering is smooth.
filterQuality = FilterQuality.None,
contentScale = ContentScale.FillBounds,
// ⚠️ 140, not 180. At 180 the code was the largest thing on
// the screen and pushed everything else below the fold —
// 140, not 180. At 180 the code was the largest thing on
// the screen and pushed everything else below the fold,
// and a QR only has to be big enough for a phone camera a
// foot away, which 140dp at this density is.
modifier = Modifier.size(140.dp).testTag("address-qr"),
)
Spacer(Modifier.height(Space.sm))
}
// 🔴 **Broken at the `@`, not wherever the line runs out.** As one
// string it wrapped mid-hostname — "…@rela / y.flamenet.io" — and a
// Broken at the `@`, not wherever the line runs out. As one
// string it wrapped mid-hostname, "…@rela / y.flamenet.io", and a
// relay name split across a line break is the kind of thing
// somebody retypes wrong. The fnid is a derived key and wraps
// anywhere without harm; the host must not.
@@ -126,7 +126,7 @@ fun AddressCard(fnid: String?, relay: String, handle: String? = null) {
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 "
+ "with the same passphrase and this stays the same, which is what "
+ "lets it outlive any one relay.",
style = MaterialTheme.typography.bodySmall,
)
@@ -141,7 +141,7 @@ private fun copy(context: Context, label: String, text: String) {
/**
* 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
* 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.
*/
@@ -24,7 +24,7 @@ import androidx.compose.ui.unit.sp
/**
* A fingerprint, a face, or the device PIN in front of the app.
*
* 🔴 **Android had none of this and iOS has had it for a while.** The sealed
* Android had none of this and iOS has had it for a while. The sealed
* history file protects a phone that is off or locked; it does nothing about
* the phone somebody just handed to a friend. On iOS the app asks for Face ID;
* on Android anyone holding an unlocked handset read every conversation. Two
@@ -35,14 +35,14 @@ import androidx.compose.ui.unit.sp
* implying more:** somebody who picks up your unlocked phone. That is the
* threat people actually have, and it is worth something.
*
* **What it does not defend against:** anyone with the device and the tools to
* What it does not defend against: anyone with the device and the tools to
* read its storage. This is a check in the app's own process. The vault and the
* ratchet keys are protected by the Android keystore, not by this switch, and
* turning it on re-encrypts nothing. An app lock that claimed otherwise would
* be selling a screen as if it were a key.
*
* ⚠️ `DEVICE_CREDENTIAL` alongside the biometrics on purpose. A phone with a
* PIN and no fingerprint reader should still be able to use this — the same
* `DEVICE_CREDENTIAL` alongside the biometrics on purpose. A phone with a
* PIN and no fingerprint reader should still be able to use this, the same
* reason iOS asks for `deviceOwnerAuthentication` rather than biometrics
* specifically. Locking people out of their own messages because their hardware
* is older is not a security win.
@@ -62,7 +62,7 @@ class AppLock(private val context: Context) {
/**
* Failed attempts before the first wait.
*
* ⚠️ Three, not one. A mistyped PIN or a fingerprint that did not read
* Three, not one. A mistyped PIN or a fingerprint that did not read
* is ordinary, and making the first slip cost a wait would punish the
* owner far more often than anybody else.
*/
@@ -71,9 +71,9 @@ class AppLock(private val context: Context) {
/**
* The longest wait this will ever impose.
*
* 🔴 **Capped, and it must be.** An unbounded back-off locks somebody
* Capped, and it must be. An unbounded back-off locks somebody
* out of their own messages permanently, which is a worse outcome than
* the one it defends against — there is no reset, no support desk, and
* the one it defends against, there is no reset, no support desk, and
* the data is on their own phone.
*/
const val MAXIMUM_DELAY_MS = 300_000L
@@ -84,7 +84,7 @@ class AppLock(private val context: Context) {
* Doubles from 30 seconds and stops at five minutes. The shape matters
* less than the two ends: nothing for an ordinary slip, and a ceiling.
*
* ⚠️ Doubling is done by shifting a Long rather than `Math.pow`, so a
* Doubling is done by shifting a Long rather than `Math.pow`, so a
* large failure count cannot overflow a Double into infinity on the way
* to being capped. Twenty consecutive failures is not a realistic
* number and it should still return five minutes.
@@ -104,8 +104,8 @@ class AppLock(private val context: Context) {
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
/**
* ⚠️ Ordinary SharedPreferences, NOT the vault. This records a preference,
* not a secret — and putting it in the encrypted store would mean the
* Ordinary SharedPreferences, NOT the vault. This records a preference,
* not a secret, and putting it in the encrypted store would mean the
* setting could not be read until after sign-in, which is exactly when the
* lock needs to already be up.
*/
@@ -126,7 +126,7 @@ class AppLock(private val context: Context) {
/**
* Whether this device can do it at all.
*
* ⚠️ Checked before the setting is offered. A toggle that turns on and then
* Checked before the setting is offered. A toggle that turns on and then
* cannot authenticate locks somebody out of their own messages with no way
* back, which is worse than not having the feature.
*/
@@ -137,7 +137,7 @@ class AppLock(private val context: Context) {
/**
* When the app was last sent to the background. Null means it has not been.
*
* ⚠️ The grace period exists so that picking a photo, answering a call, or
* The grace period exists so that picking a photo, answering a call, or
* being bounced out by the system camera does not demand a fingerprint on
* the way back. A lock with no grace is one people switch off.
*/
@@ -155,12 +155,12 @@ class AppLock(private val context: Context) {
// ---- Backing off (ios#41) -------------------------------------------
//
// 🔴 **Android had the lock and none of the back-off.** iOS has counted
// Android had the lock and none of the back-off. iOS has counted
// failed unlocks and imposed a growing wait since ios#41; here every
// attempt was free, so somebody holding an unlocked phone could work
// through PINs as fast as the prompt would reappear. Two platforms
// disagreeing about a security guarantee is how one of them ends up being
// the one that leaks — the same argument that put the lock here at all.
// the one that leaks, the same argument that put the lock here at all.
/** Consecutive failures so far. */
val failures: Int get() = prefs.getInt(FAILURES, 0)
@@ -168,7 +168,7 @@ class AppLock(private val context: Context) {
/**
* When the next attempt is allowed, or null if one is allowed now.
*
* ⚠️ **Wall-clock, and somebody who can change the device clock can skip
* **Wall-clock, and somebody who can change the device clock can skip
* the wait.** Said rather than hidden: this slows a person holding the
* phone, and it is not a defence against somebody with the device and the
* patience to work at it. What actually protects the messages is the
@@ -186,8 +186,8 @@ class AppLock(private val context: Context) {
}
/**
* 🔴 Only a genuine wrong answer counts. Dismissing the prompt is not an
* attempt — counting a cancel would let the owner back themselves into a
* Only a genuine wrong answer counts. Dismissing the prompt is not an
* attempt, counting a cancel would let the owner back themselves into a
* wait by changing their mind twice. See the error handling in
* [authenticate], which is where that distinction is actually made.
*/
@@ -202,7 +202,7 @@ class AppLock(private val context: Context) {
/**
* Forget the failures. On a successful unlock, and when the feature is
* switched off — leaving a wait behind on a disabled lock would strand
* switched off, leaving a wait behind on a disabled lock would strand
* somebody behind a screen that is no longer supposed to exist.
*/
fun clearFailures() {
@@ -224,8 +224,8 @@ class AppLock(private val context: Context) {
/**
* Ask.
*
* ⚠️ Requires a `FragmentActivity`. `BiometricPrompt` hosts a fragment, and
* a plain `ComponentActivity` silently cannot show it — the callback never
* Requires a `FragmentActivity`. `BiometricPrompt` hosts a fragment, and
* a plain `ComponentActivity` silently cannot show it, the callback never
* fires and the lock screen sits there looking broken.
*/
fun authenticate(
@@ -233,7 +233,7 @@ class AppLock(private val context: Context) {
onSuccess: () -> Unit,
onFailure: (String) -> Unit,
) {
// 🔴 Refuse BEFORE showing the prompt. Letting the system sheet appear
// Refuse BEFORE showing the prompt. Letting the system sheet appear
// and then discarding the answer would teach somebody that attempts are
// free, which is the opposite of the point.
waitMessage()?.let { onFailure(it); return }
@@ -250,7 +250,7 @@ class AppLock(private val context: Context) {
}
/**
* ⚠️ **The one callback that means a wrong answer.**
* The one callback that means a wrong answer.
* `onAuthenticationFailed` fires for a fingerprint that did not
* match; `onAuthenticationError` fires for cancels, lockouts and
* hardware problems. Only this moves the counter, which is why
@@ -261,14 +261,14 @@ class AppLock(private val context: Context) {
}
override fun onAuthenticationError(code: Int, message: CharSequence) {
// ⚠️ A cancel is not a failure. Reporting "authentication
// A cancel is not a failure. Reporting "authentication
// failed" when somebody tapped away is how a lock screen
// teaches people it is broken.
if (code == BiometricPrompt.ERROR_USER_CANCELED ||
code == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
code == BiometricPrompt.ERROR_CANCELED
) return
// ⚠️ The platform's own lockout is not counted on top of
// The platform's own lockout is not counted on top of
// ours. Two back-offs stacking would produce a wait nobody
// can predict from either rule.
onFailure(message.toString())
@@ -289,7 +289,7 @@ class AppLock(private val context: Context) {
/**
* What is on screen while the app is locked.
*
* 🔴 **Deliberately says nothing.** No sender, no preview, no unread count —
* Deliberately says nothing. No sender, no preview, no unread count,
* the point of the lock is that whoever is holding the phone learns nothing,
* and a lock screen reporting "3 new messages from Dana" has given away the two
* facts that usually matter most.
@@ -25,16 +25,16 @@ import kotlinx.coroutines.withContext
/**
* Telling a hand-installed app that a newer one exists.
*
* 🔴 **This app is not in a store, so nothing else will ever say this.** A
* This app is not in a store, so nothing else will ever say this. A
* person who installs the APK and never visits the site again keeps running
* that build forever, including through every fix that came after it. The
* network check lives in [UpdateCheck] and explains why it asks the user's own
* relay rather than an address of ours; this file is the part that decides how
* often to ask and how loudly to say so.
*
* ⚠️ **Nobody already installed will see this.** Every build before this one
* Nobody already installed will see this. Every build before this one
* has no update check in it, so the first prompt anybody gets is for the build
* *after* the one that introduces this. That is not a bug to fix later — it is
* *after* the one that introduces this. That is not a bug to fix later, it is
* inherent, and it means the existing install base still has to be reached the
* way it always was, by telling them.
*/
@@ -57,7 +57,7 @@ object AppUpdate {
/**
* What is actually installed, asked of the package manager.
*
* ⚠️ Not `BuildConfig.VERSION_CODE`. It would need a build feature turned
* Not `BuildConfig.VERSION_CODE`. It would need a build feature turned
* on, and more to the point it is a constant compiled into the APK: it
* describes what was *built*, and this needs to describe what is *running*.
* They are the same today and the package manager cannot drift.
@@ -80,8 +80,8 @@ object AppUpdate {
/**
* Stop offering this particular build.
*
* ⚠️ Recorded per version, not as a "don't ask me again" flag. Somebody who
* declines 0.7 today should still be told about 0.8 next month — a single
* Recorded per version, not as a "don't ask me again" flag. Somebody who
* declines 0.7 today should still be told about 0.8 next month, a single
* boolean would silence the feature permanently on the first dismissal,
* which is how update prompts end up being useless.
*/
@@ -104,7 +104,7 @@ object AppUpdate {
/**
* Where releases are published when the relay does not publish them.
*
* 🔴 **Only ever reached because somebody pressed the button.** The
* Only ever reached because somebody pressed the button. The
* automatic check asks the user's own relay and stops there, so an app that
* is signed in to somebody else's server never contacts us on a timer.
* Pressing "Check for updates" is a different thing: it is a question the
@@ -125,9 +125,9 @@ object AppUpdate {
/**
* The check behind the button: this relay first, then the official site.
*
* ⚠️ Ordered, not raced. The relay is asked first so somebody running their
* Ordered, not raced. The relay is asked first so somebody running their
* own gets their own answer, and the site is only reached for if that
* produced nothing at all — a relay that publishes an OLDER build than we
* produced nothing at all, a relay that publishes an OLDER build than we
* do is still the answer for the person who chose that relay.
*/
suspend fun checkNow(context: Context, baseUrl: String?): CheckResult =
@@ -146,14 +146,14 @@ object AppUpdate {
CheckResult.Unknown
}
/** Just the hostname — a full URL in a sentence reads as an error. */
/** Just the hostname, a full URL in a sentence reads as an error. */
private fun hostLabel(url: String): String =
runCatching { java.net.URI(url).host }.getOrNull() ?: url
/**
* Ask the relay, off the main thread, no more often than the interval.
*
* ⚠️ The relay ONLY. The fallback to our site belongs to [checkNow], which
* The relay ONLY. The fallback to our site belongs to [checkNow], which
* runs because somebody asked; putting it here would make every install
* check in with us on a timer, which is the thing this product says it does
* not do.
@@ -174,7 +174,7 @@ object AppUpdate {
/**
* The check the whole screen hangs off, run once per composition.
*
* ⚠️ Keyed on the relay so that signing into a different one re-asks. The
* Keyed on the relay so that signing into a different one re-asks. The
* answer is that relay's, not a global fact.
*/
@Composable
@@ -189,8 +189,8 @@ object AppUpdate {
/**
* The prompt itself.
*
* ⚠️ A dialog, and only for a version not yet declined. Everything else about
* updates lives quietly in Account — an app that interrupts you on every launch
* A dialog, and only for a version not yet declined. Everything else about
* updates lives quietly in Account, an app that interrupts you on every launch
* to talk about itself trains people to dismiss it without reading, and then
* the one update that mattered gets dismissed too.
*/
@@ -8,13 +8,13 @@ import androidx.core.app.NotificationManagerCompat
/**
* The Decline button on a ringing call notification.
*
* ⚠️ A broadcast, not an activity. Declining must not open the app — the whole
* A broadcast, not an activity. Declining must not open the app, the whole
* point of answering from the lock screen is that the phone stays locked for
* the choice the user did NOT make.
*
* 🔴 Clears the parked offer as well as the notification. The notification is
* Clears the parked offer as well as the notification. The notification is
* `ongoing`, so it does not dismiss itself, and an offer left parked would be
* answered the next time the app opened — ringing for a call declined an hour
* answered the next time the app opened, ringing for a call declined an hour
* ago.
*/
class CallActionReceiver : BroadcastReceiver() {
@@ -27,21 +27,21 @@ import java.util.UUID
/**
* Voice calls over WebRTC (spec §8.10).
*
* 🔴 **Peer to peer whenever the network allows it.** ICE tries a direct path
* first — same network, then each side's address as seen through NAT — and
* Peer to peer whenever the network allows it. ICE tries a direct path
* first, same network, then each side's address as seen through NAT, and
* only falls back to relaying through TURN when both ends sit behind NAT that
* refuses a direct path. The relay mints TURN credentials and never carries
* media; when it carries anything it carries DTLS-SRTP ciphertext between two
* peers who authenticated each other through the ratchet.
*
* ⚠️ **A direct call shows the other person your IP address.** That is what
* A direct call shows the other person your IP address. That is what
* "peer to peer" means and it is not a bug, but it is a disclosure, and the
* app says so before the first call rather than after. Someone who does not
* want it needs a relay, and relaying requires a TURN server to exist.
*
* ⚠️ **Signalling rides the ratchet, not the relay.** The offer and answer are
* ordinary content envelopes, so the SDP — which names codecs, and every
* candidate address — is end-to-end encrypted like any other message. A relay
* Signalling rides the ratchet, not the relay. The offer and answer are
* ordinary content envelopes, so the SDP, which names codecs, and every
* candidate address, is end-to-end encrypted like any other message. A relay
* that could read signalling would learn the addresses of both parties even on
* a call it never carries.
*/
@@ -73,7 +73,7 @@ class CallManager(
// -- Video (§6.8v) -------------------------------------------------------
// One EGL context for the factory's codecs, the capturer and every
// renderer — WebRTC textures only travel between things that share it.
// renderer. WebRTC textures only travel between things that share it.
val eglBase: EglBase by lazy { EglBase.create() }
private var videoCapturer: VideoCapturer? = null
private var videoHelper: SurfaceTextureHelper? = null
@@ -88,12 +88,12 @@ class CallManager(
private val main = android.os.Handler(android.os.Looper.getMainLooper())
/**
* Runs once, on the main thread, when ICE gathering completes — this is
* Runs once, on the main thread, when ICE gathering completes, this is
* where the offer or answer is actually sent.
*
* 🔴 **The SDP from `onCreateSuccess` has ZERO candidates in it.** With
* The SDP from `onCreateSuccess` has ZERO candidates in it. With
* trickle off, gathering starts at `setLocalDescription` and the
* candidates land in the *local description*, not in callbacks — so
* candidates land in the *local description*, not in callbacks, so
* sending `sdp.description` the moment the offer was created shipped an
* SDP that named no address at all. The other end had nothing to connect
* to, ICE had no pairs to check, and every call sat in "Connecting…"
@@ -115,8 +115,8 @@ class CallManager(
/**
* Gives up a call that was answered but whose media never connected.
* Without it, an answer whose ICE cannot find a path — no TURN, hostile
* NAT — left BOTH screens sitting in "Connecting…" until someone forced
* Without it, an answer whose ICE cannot find a path, no TURN, hostile
* NAT, left BOTH screens sitting in "Connecting…" until someone forced
* the app closed.
*/
private var connectGuard: Runnable? = null
@@ -136,25 +136,25 @@ class CallManager(
val guard = Runnable { deliverGatheredSdp() }
gatherGuard = guard
main.postDelayed(guard, GATHER_TIMEOUT_MS)
// Gathering can already be done — tiny SDPs on a fast network — and
// Gathering can already be done, tiny SDPs on a fast network, and
// then COMPLETE fired before this registration and nothing else will.
if (connection?.iceGatheringState() == PeerConnection.IceGatheringState.COMPLETE) {
deliverGatheredSdp()
}
}
/** Main thread only. Idempotent — guard timeout and COMPLETE can both fire. */
/** Main thread only. Idempotent, guard timeout and COMPLETE can both fire. */
private fun deliverGatheredSdp() {
val send = onGathered ?: return
onGathered = null
gatherGuard?.let { main.removeCallbacks(it) }
gatherGuard = null
// 🔴 **This `?: return` sent nobody anything, silently.** After the
// 8s gather timeout the local description can still be null — because
// This `?: return` sent nobody anything, silently. After the
// 8s gather timeout the local description can still be null, because
// setLocalDescription had not finished, or had FAILED and said nothing
// (see SimpleSdpObserver). The call then dies here: no offer, no
// envelope on the relay, no ring on the other phone, and no error.
// Verified against the database — zero envelopes had ever been created
// Verified against the database, zero envelopes had ever been created
// between two accounts that had "placed" several calls.
val sdp = connection?.localDescription?.description ?: run {
android.util.Log.w("fnmsg-call", "NOT SENDING the offer/answer: no localDescription after " +
@@ -170,10 +170,10 @@ class CallManager(
/**
* ICE servers, P2P-first.
*
* ⚠️ A relay with no TURN configured answers 503, and that is a supported
* A relay with no TURN configured answers 503, and that is a supported
* deployment rather than an error: STUN alone still gets a direct call
* through most NATs. What it cannot do is rescue the minority of networks
* that refuse a direct path — those calls simply fail to connect, and
* that refuse a direct path, those calls simply fail to connect, and
* saying so is better than pretending a relay exists.
*/
private fun iceServers(turn: JSONObject?): List<PeerConnection.IceServer> {
@@ -228,7 +228,7 @@ class CallManager(
}
val observer = object : PeerConnection.Observer {
override fun onIceCandidate(candidate: IceCandidate?) {
// 🔴 The candidate TYPE is the whole diagnosis. `host` only
// The candidate TYPE is the whole diagnosis. `host` only
// means we never reached STUN/TURN and cannot cross a NAT;
// `relay` means TURN answered and the call can connect even
// when a direct path is impossible.
@@ -238,10 +238,10 @@ class CallManager(
}
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>?) = Unit
override fun onSignalingChange(state: PeerConnection.SignalingState?) = Unit
// 🔴 **Nothing may throw out of here, and nothing may call back
// **Nothing may throw out of here, and nothing may call back
// into WebRTC from here.** This runs on WebRTC's signaling thread,
// and its JNI asserts RTC_CHECK(!env->ExceptionCheck()) on the way
// out — so ANY escaping Kotlin exception aborts the process:
// out, so ANY escaping Kotlin exception aborts the process:
// SIGABRT, no AndroidRuntime trace, nothing to catch.
override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) {
android.util.Log.i("fnmsg-call", "ice connection -> $newState")
@@ -249,10 +249,10 @@ class CallManager(
when (newState) {
PeerConnection.IceConnectionState.CONNECTED,
PeerConnection.IceConnectionState.COMPLETED -> state = State.ACTIVE
// ⚠️ CLOSED is what our OWN close() produces. Treating it as
// CLOSED is what our OWN close() produces. Treating it as
// a reason to hang up re-entered hangUp from inside this
// callback, which then did a blocking network send AND
// disposed the connection we are currently inside — and
// disposed the connection we are currently inside, and
// killed the app every single time somebody pressed Hang up.
PeerConnection.IceConnectionState.CLOSED -> Unit
PeerConnection.IceConnectionState.FAILED -> hangUp(notifyPeer = true)
@@ -264,7 +264,7 @@ class CallManager(
override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) {
android.util.Log.i("fnmsg-call", "ice gathering -> $newState")
// Hop to the main thread rather than touching the connection
// from here — same JNI rule as above: no WebRTC calls, and no
// from here, same JNI rule as above: no WebRTC calls, and no
// exceptions, from inside a WebRTC callback.
if (newState == PeerConnection.IceGatheringState.COMPLETE) {
main.post { runCatching { deliverGatheredSdp() } }
@@ -294,8 +294,8 @@ class CallManager(
audioSource = source
audioTrack = track
if (video) {
// ⚠️ Failure here downgrades to voice rather than killing the
// call — an emulator without a camera, or a denied permission
// Failure here downgrades to voice rather than killing the
// call, an emulator without a camera, or a denied permission
// mid-flow, should still connect and say something.
runCatching {
val cams = Camera2Enumerator(context)
@@ -332,14 +332,14 @@ class CallManager(
/** Place a call. The offer is gathered, then sent as one encrypted envelope. */
fun call(peerId: Long, turn: JSONObject?, video: Boolean = false) {
// Supervision: enforced where the call would start, not where the
// button is drawn — every entry point hits the same wall.
// Backstop only — the view model refuses first, with the message.
// button is drawn, every entry point hits the same wall.
// Backstop only, the view model refuses first, with the message.
if (ParentalControls.callsDisabled(context)) return
if (state != State.IDLE && state != State.ENDED) return
val callId = UUID.randomUUID().toString()
// 🔴 `current` BEFORE the state change. The state setter notifies the
// `current` BEFORE the state change. The state setter notifies the
// observer, and the observer reads `current` to decide whether there
// is a call to draw — DIALLING with a null `current` cleared the
// is a call to draw. DIALLING with a null `current` cleared the
// banner the UI had just put up, and the caller's screen showed
// nothing until a second tap redrew it (while this guard ate the tap).
current = Active(callId, peerId, incoming = false, video = video)
@@ -352,7 +352,7 @@ class CallManager(
pc.createOffer(object : SimpleSdpObserver() {
override fun onCreateSuccess(sdp: SessionDescription) {
pc.setLocalDescription(SimpleSdpObserver("offer:setLocal"), sdp)
// The complete SDP — with candidates — exists only after
// The complete SDP, with candidates, exists only after
// gathering; see onGathered. DIALLING holds until the answer
// arrives, so the caller's screen says what is actually true.
main.post {
@@ -363,8 +363,8 @@ class CallManager(
}
}
}, MediaConstraints())
// 🔴 Give up out loud. Without this an unanswered call sat in
// "Calling…" forever — and the offer waiting in the callee's mailbox
// Give up out loud. Without this an unanswered call sat in
// "Calling…" forever, and the offer waiting in the callee's mailbox
// would ring long after this end had stopped listening.
val guard = Runnable {
if (state == State.DIALLING) hangUp(notifyPeer = true, reason = "timeout")
@@ -386,7 +386,7 @@ class CallManager(
"call signal in: ${incoming.action} from $fromUser state=$state")
when (incoming.action) {
Content.CallSignal.OFFER -> {
// Supervision: a device with calls off does not ring — the
// Supervision: a device with calls off does not ring, the
// caller is answered busy, like any other unavailable state.
if (ParentalControls.callsDisabled(context)) {
runCatching {
@@ -395,7 +395,7 @@ class CallManager(
}
return
}
// 🔴 An offer older than the caller's own patience is not a
// An offer older than the caller's own patience is not a
// call. The caller gives up after DIAL_TIMEOUT_MS; one that
// aged in the mailbox while this app was closed would ring for
// a call nobody is on the other end of, and answering it
@@ -406,7 +406,7 @@ class CallManager(
onStaleOffer?.invoke(fromUser, sentAt)
return
}
// ⚠️ One call at a time. A second offer while busy is declined
// One call at a time. A second offer while busy is declined
// rather than silently dropped, so the caller hears something.
if (state != State.IDLE && state != State.ENDED) {
signal(fromUser, Content.CallSignal(
@@ -420,7 +420,7 @@ class CallManager(
}
Content.CallSignal.ANSWER -> {
val sdp = incoming.sdp ?: return
// An answer for some earlier call — a retry raced a hang-up —
// An answer for some earlier call, a retry raced a hang-up,
// must not be installed into this one's connection.
if (incoming.callId != current?.callId) return
dialGuard?.let { main.removeCallbacks(it) }
@@ -442,7 +442,7 @@ class CallManager(
/** Accept the call that is ringing. */
/**
* Build and send the answer. Split out so it can only be reached from
* `setRemoteDescription`'s success callback — the ordering IS the fix.
* `setRemoteDescription`'s success callback, the ordering IS the fix.
*/
private fun answerNow(
pc: PeerConnection,
@@ -476,16 +476,16 @@ class CallManager(
return
}
current = Active(offer.callId, fromUser, incoming = true, video = offer.video)
// 🔴 **`createAnswer` waits for the remote description to be APPLIED.**
// `createAnswer` waits for the remote description to be APPLIED.
// These two were fired back to back: `setRemoteDescription` is
// asynchronous, and WebRTC cannot answer an offer it has not finished
// reading. When it lost that race `createAnswer` failed,
// `setLocalDescription` never ran, and the send path found no local
// description and gave up — "NOT SENDING the offer/answer ... the other
// description and gave up, "NOT SENDING the offer/answer ... the other
// side will never see this call". Accepting a call did nothing, the
// caller rang on until it timed out, and neither end said why.
//
// ⚠️ It is a RACE, which is what made it look intermittent: on a quiet
// It is a RACE, which is what made it look intermittent: on a quiet
// device the description often lands first and the call connects, so
// the same build both works and fails depending on load.
pc.setRemoteDescription(
@@ -505,7 +505,7 @@ class CallManager(
signal(fromUser, Content.CallSignal(
Content.CallSignal.END, offer.callId, reason = "declined"))
// ENDED while `current` still names the caller, so the observer can
// file the declined call — see the ordering note in hangUpInner.
// file the declined call, see the ordering note in hangUpInner.
state = State.ENDED
current = null
state = State.IDLE
@@ -514,12 +514,12 @@ class CallManager(
/**
* End the call and release everything.
*
* ⚠️ The microphone is held by the audio source, not by the connection.
* The microphone is held by the audio source, not by the connection.
* Closing one without disposing the other leaves the mic live with no call
* attached — the worst possible failure for this particular feature.
* attached, the worst possible failure for this particular feature.
*/
/**
* ⚠️ **Re-entrancy-safe on purpose.** Tearing a connection down makes
* Re-entrancy-safe on purpose. Tearing a connection down makes
* WebRTC fire state callbacks, and a callback that calls back in here
* would dispose the very object it is running inside.
*/
@@ -546,7 +546,7 @@ class CallManager(
val active = current
if (notifyPeer && active != null) {
// Best effort. Telling the other end is worth doing and is not
// worth failing the teardown over — the relay can be rate limiting
// worth failing the teardown over, the relay can be rate limiting
// us, or simply unreachable.
runCatching {
signal(active.peerId,
@@ -572,7 +572,7 @@ class CallManager(
remoteVideoTrack = null
main.post { runCatching { onVideoTracksChanged?.invoke() } }
pendingOffer = null
// 🔴 ENDED fires while `current` still says who the call was with —
// ENDED fires while `current` still says who the call was with,
// the observer files the call log entry from it, and nulling `current`
// first (as this used to) meant NO call, answered or missed, was ever
// recorded: by the time the state changed there was nobody to file.
@@ -610,9 +610,9 @@ class CallManager(
/**
* The offer the background service received, parked for the UI to answer.
*
* 🔴 **The service could ring but nobody could pick up.** The service and the
* The service could ring but nobody could pick up. The service and the
* ViewModel each poll the same mailbox with their OWN session, and whichever
* polls first consumes the envelope — so an offer that arrived while the app
* polls first consumes the envelope, so an offer that arrived while the app
* was backgrounded was decrypted by the service, turned into a notification,
* and its SDP thrown away. Tapping the notification opened an app that had
* never seen the offer and had nothing to accept. Both run in the same
@@ -629,7 +629,7 @@ object PendingIncomingCall {
if (sentAt > 0) sentAt else System.currentTimeMillis())
}
/** The caller hung up or the call was answered elsewhere — stop offering it. */
/** The caller hung up or the call was answered elsewhere, stop offering it. */
fun clear(callId: String) {
if (parked?.signal?.callId == callId) parked = null
}
@@ -637,8 +637,8 @@ object PendingIncomingCall {
/**
* Drop whatever is parked, whichever call it is.
*
* ⚠️ For Decline from the lock screen, where the id is not to hand. An
* offer left parked would be answered the next time the app opened —
* For Decline from the lock screen, where the id is not to hand. An
* offer left parked would be answered the next time the app opened,
* ringing for a call the user turned down an hour ago.
*/
fun clearAll() { parked = null }
@@ -647,7 +647,7 @@ object PendingIncomingCall {
* Whether something is parked that is already too old to answer.
*
* Distinct from [take] on purpose: `take` consumes, and the ring has to be
* able to ASK without consuming — a stale check that swallowed a live offer
* able to ASK without consuming, a stale check that swallowed a live offer
* would drop a call that was still ringing.
*/
fun isStale(now: Long = System.currentTimeMillis()): Boolean {
@@ -666,14 +666,14 @@ object PendingIncomingCall {
/** Most SDP callbacks have nothing to say; this stops four stubs per call site. */
/**
* 🔴 **This discarded every SDP failure, and that is why calls were undebuggable.**
* This discarded every SDP failure, and that is why calls were undebuggable.
* `createOffer` or `setLocalDescription` could fail for any reason and nothing
* anywhere recorded it — the offer then simply never went out, the callee never
* anywhere recorded it, the offer then simply never went out, the callee never
* rang, and the database showed no envelope had ever been created. Four hours of
* "the call failed" with no cause, because the two callbacks that knew returned
* `Unit`.
*
* ⚠️ A `label` so the log says WHICH step failed. "setLocalDescription failed"
* A `label` so the log says WHICH step failed. "setLocalDescription failed"
* and "createAnswer failed" send you to different places.
*/
private open class SimpleSdpObserver(private val label: String = "sdp") : SdpObserver {
@@ -33,19 +33,19 @@ import androidx.compose.ui.unit.sp
/**
* A call, as a screen.
*
* 🔴 **Android had no call screen at all — it had a banner.** A line of text
* Android had no call screen at all, it had a banner. A line of text
* and two `TextButton`s stacked above whatever screen you happened to be on,
* so the most attention-demanding thing the app ever does looked like a
* notice. iOS has had a full screen throughout; the kit draws one; this is the
* parity gap that mattered most, and the one a person is most likely to be
* fumbling with under time pressure.
*
* 🔴 **Plain values, no `CallManager`.** Same split iOS uses: presentation that
* Plain values, no `CallManager`. Same split iOS uses: presentation that
* takes a name and a status can be put in front of the screenshot harness; a
* state machine cannot, and a call screen nobody can look at is a call screen
* nobody has checked.
*
* ⚠️ The kit animates a 400pt ring. A static ring pair stands in and says so —
* The kit animates a 400pt ring. A static ring pair stands in and says so,
* an animation that never resolves is worse than none.
*/
@Composable
@@ -62,7 +62,7 @@ fun CallScreenBody(
Column(
modifier = Modifier
.fillMaxSize()
// ⚠️ Back to the theme's own background: the gradient this used to
// Back to the theme's own background: the gradient this used to
// avoid painting over went with the purple theme (reverted
// 2026-09-08), so there is nothing left to clash with.
.background(MaterialTheme.colorScheme.background)
@@ -72,9 +72,9 @@ fun CallScreenBody(
) {
Spacer(Modifier.height(Space.xxl))
// ⚠️ No rings. A pair of translucent `primary` circles sat behind the
// No rings. A pair of translucent `primary` circles sat behind the
// avatar as a stand-in for the kit's animation, and against the
// gradient they read as banding — two hard edges around the one thing
// gradient they read as banding, two hard edges around the one thing
// on screen. The avatar carries the centre on its own.
Box(contentAlignment = Alignment.Center) {
Box(
@@ -169,8 +169,8 @@ private fun CallButton(action: CallAction, diameter: androidx.compose.ui.unit.Dp
/**
* Map the live call onto the screen above.
*
* ⚠️ Returns null when there is no call, so the caller can fall through to the
* ordinary tabs. The privacy prompt stays a banner deliberately — it is a
* Returns null when there is no call, so the caller can fall through to the
* ordinary tabs. The privacy prompt stays a banner deliberately, it is a
* question asked BEFORE a call exists, and a full screen for it would look
* like a call that is already happening.
*/
@@ -242,9 +242,9 @@ fun LiveCallScreen(model: MessengerViewModel, phase: MessengerViewModel.Phase.Si
/**
* The video layout: their camera full-screen, yours in a corner card, the
* usual actions along the bottom — the kit's 08 Video Call artboard.
* usual actions along the bottom, the kit's 08 Video Call artboard.
*
* ⚠️ `videoTick` exists to re-run this when tracks appear: the track objects
* `videoTick` exists to re-run this when tracks appear: the track objects
* live on the manager, not in Compose state, so nothing else would recompose.
*/
@Composable
@@ -296,7 +296,7 @@ private fun VideoCallBody(
}
/**
* One WebRTC renderer. Init and release travel with the composable — a
* One WebRTC renderer. Init and release travel with the composable, a
* SurfaceViewRenderer that outlives its sink leaks the EGL surface, and one
* released while still sinked crashes in native code.
*/
@@ -329,7 +329,7 @@ private fun VideoSurface(
* A "start a video call" action that asks for the camera first.
*
* Returns a function of the peer id. The permission launcher must be created
* during composition, so this cannot be a plain method on the model — and
* during composition, so this cannot be a plain method on the model, and
* asking at the moment of the tap is the only time the question makes sense
* to the person being asked.
*/
@@ -344,7 +344,7 @@ fun rememberVideoCall(model: MessengerViewModel): (Long) -> Unit {
) { granted ->
val peer = pending.value
pending.value = null
// Denied still calls — as voice-grade video (their picture, no ours is
// Denied still calls, as voice-grade video (their picture, no ours is
// wrong; the manager simply has no camera to add, so it degrades).
if (peer != null && granted) model.requestCall(peer, video = true)
}
@@ -28,7 +28,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Calls placed and received — the kit's `08 Calls-Listing`.
* Calls placed and received, the kit's `08 Calls-Listing`.
*
* Measured out of `item/call/White`: a 48dp avatar, the name at 16pt, and a
* second line that is a 12dp direction icon followed by a 12pt time. The kit
@@ -36,8 +36,8 @@ import androidx.compose.ui.unit.sp
* here, because there is no video path in either CallManager and a control
* that does nothing is worse than a missing one.
*
* 🔴 **This screen needed a call log to exist first.** Nothing recorded calls
* anywhere — there was a live call UI and a "missed call" notification from the
* This screen needed a call log to exist first. Nothing recorded calls
* anywhere, there was a live call UI and a "missed call" notification from the
* relay, and no history at all, so "who rang me yesterday" had no answer. The
* log lives in the vault with everything else: who somebody called and when is
* precisely the metadata this app exists to keep off a server, and encrypting
@@ -65,7 +65,7 @@ fun ColumnScope.CallsScreen(
modifier = Modifier.weight(1f),
)
// The way IN. Every other path to a call runs through an open
// conversation — this tab's own empty state used to admit as much.
// conversation, this tab's own empty state used to admit as much.
Icon(
KitIcon.Call,
contentDescription = "New call",
@@ -83,7 +83,7 @@ fun ColumnScope.CallsScreen(
title = { Text("New call") },
text = {
if (phase.buddies.isEmpty()) {
Text("Add a contact first — calls go to people on your list.")
Text("Add a contact first, calls go to people on your list.")
} else {
Column {
phase.buddies.forEach { buddy ->
@@ -133,9 +133,9 @@ fun ColumnScope.CallsScreen(
}
if (phase.calls.isEmpty()) {
// ⚠️ Says what would appear here. An empty screen with no explanation
// Says what would appear here. An empty screen with no explanation
// is a feature nobody discovers, and this one has no other entry point
// — calls start from a conversation.
// calls start from a conversation.
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.xxl),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -150,7 +150,7 @@ fun ColumnScope.CallsScreen(
Text("No calls yet", fontSize = 16.sp, fontWeight = FontWeight.Medium)
Text(
"Calls you place and receive appear here. Start one from a "
+ "conversation — they go directly between the two phones, "
+ "conversation, they go directly between the two phones, "
+ "so this list is the only record of them and it is on "
+ "this device.",
fontSize = 14.sp,
@@ -189,7 +189,7 @@ fun ColumnScope.CallsScreen(
overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically) {
// 🔴 The direction icon the kit puts before the time,
// The direction icon the kit puts before the time,
// and the one thing that makes a missed call findable:
// an incoming call that never connected is the row
// somebody is actually looking for.
@@ -213,7 +213,7 @@ fun ColumnScope.CallsScreen(
)
}
}
// Ring them back — voice, or camera-first.
// Ring them back, voice, or camera-first.
Box(
modifier = Modifier
.size(Kit.icon + Space.md)
File diff suppressed because it is too large Load Diff
@@ -39,13 +39,13 @@ import io.flamenet.client.MessengerApi
* One contact, as the kit draws it: a cover, a name plate, and a rail of
* actions floating at the seam between them.
*
* 🔴 **Android had no contact screen at all.** Everything you could do to a
* person was an inline row that unfolded under their list entry — Block, Clear
* Android had no contact screen at all. Everything you could do to a
* person was an inline row that unfolded under their list entry. Block, Clear
* chat, Remove, three text buttons in a line, with no room for anything that
* needed explaining and no place to put a safety number. iOS has the kit's
* version; this is the same screen.
*
* ⚠️ The rail carries only actions that do something. The kit's has a
* The rail carries only actions that do something. The kit's has a
* "personal page" icon; there is no such page here, and a fourth control that
* opens nothing is worse than a rail of three.
*/
@@ -90,7 +90,7 @@ fun ColumnScope.ContactDetail(
Box(modifier = Modifier.fillMaxWidth()) {
Column {
// The cover. A photo in the kit; we hold no avatars, so it is this
// person's initials on a wash of the accent — colourless, but at
// person's initials on a wash of the accent, colourless, but at
// least theirs, rather than a stock face standing in for them.
Box(
modifier = Modifier
@@ -100,7 +100,7 @@ fun ColumnScope.ContactDetail(
contentAlignment = Alignment.Center,
) {
// Their picture if they have sent us one; their initials if
// not. ⚠️ Initials are the normal case, not a placeholder — a
// not. Initials are the normal case, not a placeholder, a
// picture only exists once somebody has set one AND messaged
// you, so most covers will be letters and that is fine.
val picture = phase.avatars[buddy.userId]
@@ -128,7 +128,7 @@ fun ColumnScope.ContactDetail(
) {
Column(modifier = Modifier.weight(1f)) {
Text(name, fontSize = 20.sp, fontWeight = FontWeight.Bold, maxLines = 1)
// ⚠️ Presence goes on THIS line, not the trailing edge —
// Presence goes on THIS line, not the trailing edge,
// the rail floats over the plate's right-hand third, so
// anything put there is behind it.
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -153,7 +153,7 @@ fun ColumnScope.ContactDetail(
}
}
// The rail, overlapping the seam on purpose — that is what stops the
// The rail, overlapping the seam on purpose, that is what stops the
// cover reading as a dead banner.
Column(
modifier = Modifier
@@ -185,7 +185,7 @@ fun ColumnScope.ContactDetail(
SectionBandPublic()
Column(Modifier.padding(vertical = Space.sm)) {
DetailRow("Username", buddy.handle.ifBlank { "—" })
DetailRow("Username", buddy.handle.ifBlank { ", " })
DetailRow("User ID", buddy.userId.toString())
DetailRow("Status", buddy.presence.ifBlank { "unknown" })
}
@@ -198,11 +198,11 @@ fun ColumnScope.ContactDetail(
tag = "contact-clear",
) { model.clearHistory(io.flamenet.e2e.Vault.threadForUser(buddy.userId)) }
// ⚠️ Confirms in place rather than in a dialog. A dialog is dismissed
// Confirms in place rather than in a dialog. A dialog is dismissed
// reflexively; a row that changes to say what it is about to do is
// read. Same posture as the Account screen's two destructive rows.
DetailRow(
if (confirmingRemove) "Really remove — this cannot be undone" else "Remove contact",
if (confirmingRemove) "Really remove, this cannot be undone" else "Remove contact",
"Takes them off your list. It does not block them.",
tag = "contact-remove",
danger = true,
@@ -23,7 +23,7 @@ import androidx.compose.ui.unit.sp
/**
* How this phone draws things.
*
* 🔴 **Named for what they do.** iOS shipped "Show online status" on a Privacy
* Named for what they do. iOS shipped "Show online status" on a Privacy
* & Security screen, where every other messenger uses those words for the
* reciprocal thing; somebody switching it off to disappear would have been
* wrong, because it only hides other people's dots from their own phone. It was
@@ -76,11 +76,11 @@ fun ColumnScope.DisplaySection() {
}
Spacer(Modifier.height(Space.xs))
// 🔴 Says the thing the label alone cannot: this hides them from you, not
// Says the thing the label alone cannot: this hides them from you, not
// you from them.
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 "
+ "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.",
fontSize = 12.sp,
@@ -5,15 +5,15 @@ import android.content.Context
/**
* How this phone draws things. Preferences, not privacy.
*
* 🔴 **None of these change what anybody else sees.** "Show whether contacts
* None of these change what anybody else sees. "Show whether contacts
* are online" hides the dot on THIS screen; the relay still tells your contacts
* when you are online, exactly as before. iOS shipped the same three settings
* with names — "Show online status", "Show last seen" — that every other
* with names, "Show online status", "Show last seen", that every other
* messenger uses for the reciprocal thing, on a screen called Privacy &
* Security, and somebody switching them off to disappear would have been wrong.
* They were renamed on 2026-09-06; these are named that way from the start.
*
* ⚠️ **There is no last-seen setting here, because Android does not show last
* **There is no last-seen setting here, because Android does not show last
* seen at all.** iOS does. That is a missing feature rather than a missing
* toggle, and adding a switch for something the app never draws would be the
* exact decoration the parity scrub exists to catch.
@@ -40,8 +40,8 @@ class DisplaySettings(context: Context) {
/**
* Light, dark, or whatever the phone is set to.
*
* ⚠️ Stored as a string and clamped on read. An unrecognised value — from a
* future build, or a corrupted preference — reads as `system`, which is
* Stored as a string and clamped on read. An unrecognised value, from a
* future build, or a corrupted preference, reads as `system`, which is
* what somebody who never touched this setting has.
*/
var appearance: Appearance
@@ -63,12 +63,12 @@ class DisplaySettings(context: Context) {
/**
* The message font size, in sp.
*
* 🔴 Pure and lifted out so it can be asserted, for the same reason
* Pure and lifted out so it can be asserted, for the same reason
* `NotificationSettings.messageText` is: a size computed inline in a
* `Text(...)` is a number nothing can test.
*/
/**
* ⚠️ The base is **16**, not a number of my choosing: the kit specifies
* The base is 16, not a number of my choosing: the kit specifies
* 16sp for a message body ("Body Text 2"), and `ChatScreens` says so at
* the point it draws one. A setting that quietly restyled every message
* when it was switched OFF would be a worse bug than the one it fixes.
@@ -42,22 +42,22 @@ import java.util.UUID
/**
* Files, on this phone.
*
* 🔴 **Local-first. Nothing here talks to a server.** Files are encrypted into
* Local-first. Nothing here talks to a server. Files are encrypted into
* a vault in this app's own container, and the only thing that ever leaves the
* device is a transfer somebody asks for — where the relay is a courier holding
* device is a transfer somebody asks for, where the relay is a courier holding
* ciphertext until the other device collects it, and then not.
*
* ⚠️ **What that changed, and the words this screen must not reuse.** When the
* What that changed, and the words this screen must not reuse. When the
* rows lived in Postgres, read-down was a database predicate: the server would
* not return a row above your clearance whatever the client asked. There is no
* database now. Labels still gate decryption completely — no label key, no
* plaintext — but "what you're cleared for" now means *the keys this phone
* database now. Labels still gate decryption completely, no label key, no
* plaintext, but "what you're cleared for" now means *the keys this phone
* holds*, not *what a server will show you*. Those are different claims and only
* one of them is still true, so the screen says the true one.
*
* 🔴 It asks for a passphrase, and that is not friction to be optimised away.
* It asks for a passphrase, and that is not friction to be optimised away.
* Messenger deliberately keeps no copy of one, and the Files identity is
* protected with it — which is what makes a borrowed, unlocked phone still not
* protected with it, which is what makes a borrowed, unlocked phone still not
* open somebody's files.
*/
@Composable
@@ -104,8 +104,8 @@ private fun ColumnScope.FilesMessages(
// ---- Setting up ----------------------------------------------------------
/**
* 🔴 **The one-way door is named before it is walked through, not after.**
* Forgetting this passphrase does not lock somebody out temporarily — the label
* The one-way door is named before it is walked through, not after.
* Forgetting this passphrase does not lock somebody out temporarily, the label
* keys are derived from the identity it protects and exist nowhere else, so
* every vaulted file becomes permanently unreadable. A screen that discovers
* that for somebody afterwards has failed at the only moment it mattered.
@@ -118,27 +118,27 @@ private fun ColumnScope.FilesSetUp(
var passphrase by remember { mutableStateOf("") }
var again by remember { mutableStateOf("") }
// 🔴 **"A passphrase of their own" told people to invent a second secret,
// **"A passphrase of their own" told people to invent a second secret,
// and iOS never did.** iOS says "your passphrase" and takes whatever is
// typed, so a person naturally uses their Flamenet one and it simply
// works. Android's copy sent them off to make up something else, then
// asked for it again weeks later — and forgetting it is the one-way door
// asked for it again weeks later, and forgetting it is the one-way door
// described above this function.
//
// ⚠️ **Nothing about the derivation changes.** This is still a free-choice
// Nothing about the derivation changes. This is still a free-choice
// string protecting the identity record, so anyone who already set a
// different one keeps opening their files with it. Making the account
// passphrase the ACTUAL key would be a migration with permanent data loss
// on the far side of any mistake, and it is not what this fixes.
Text(
"Use your Flamenet passphrase — the one you sign in with. Files on "
"Use your Flamenet passphrase, the one you sign in with. Files on "
+ "this phone are encrypted with it, and Flamenet keeps no copy, so "
+ "nobody can open them by picking up your unlocked phone.",
fontSize = 14.sp, color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.sm))
Text(
"If you forget it, the files are gone for good — there's no reset, "
"If you forget it, the files are gone for good, there's no reset, "
+ "because there's nothing anywhere that could do one.",
fontSize = 13.sp, color = Palette.Warning,
)
@@ -157,7 +157,7 @@ private fun ColumnScope.FilesSetUp(
label = { Text("Type it again") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
// ⚠️ Confirmed rather than trusted. Everywhere else a typo means one
// Confirmed rather than trusted. Everywhere else a typo means one
// failed sign-in; here it means a vault nobody can ever open, and the
// person would not find out until the files were already in it.
isError = again.isNotEmpty() && again != passphrase,
@@ -180,7 +180,7 @@ private fun ColumnScope.FilesUnlock(
) {
var passphrase by remember { mutableStateOf("") }
// ⚠️ Not "your Files passphrase". That name implied a second secret even
// Not "your Files passphrase". That name implied a second secret even
// for somebody who had used their Flamenet one, and left them hunting for
// a passphrase they never set.
Text("Enter your Flamenet passphrase to open them.",
@@ -215,9 +215,9 @@ private fun ColumnScope.FilesBrowser(
var namingFolder by remember { mutableStateOf(false) }
/**
* 🔴 `OpenDocument`, never `GetContent`. `GetContent` returns a URI whose
* `OpenDocument`, never `GetContent`. `GetContent` returns a URI whose
* read grant dies with this process, so a linked file works all session and
* is broken tomorrow — and whoever added it watched it work.
* is broken tomorrow, and whoever added it watched it work.
*/
val pick = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri ?: return@rememberLauncherForActivityResult
@@ -229,15 +229,15 @@ private fun ColumnScope.FilesBrowser(
Spacer(Modifier.height(Space.sm))
FilesBreadcrumb(model, files)
// ⚠️ Folders show in BOTH sections. A folder has no storage of its own — it
// is a place, not a file — and hiding them from the linked section would
// Folders show in BOTH sections. A folder has no storage of its own, it
// is a place, not a file, and hiding them from the linked section would
// make anything filed inside one unreachable from there.
val rows = files.entries.filter { it.isFolder || it.storage == files.section }
if (rows.isEmpty()) {
val empty = if (files.section == LocalVault.Storage.VAULT) {
"Nothing in the vault here yet. Anything you add is encrypted on this phone."
} else {
"No linked files here. A link points at a file where it already is — " +
"No linked files here. A link points at a file where it already is, " +
"nothing is copied, and nothing is encrypted until you send it."
}
Spacer(Modifier.height(Space.md))
@@ -305,9 +305,9 @@ private fun ColumnScope.FilesBrowser(
private data class PendingFile(val uri: android.net.Uri, val name: String)
/**
* ⚠️ **Two sections, not one mixed list.** The vault and the links are not
* equally protected — one is ciphertext in this app's container, the other is
* somebody's own file with a note attached — and a list that interleaved them
* Two sections, not one mixed list. The vault and the links are not
* equally protected, one is ciphertext in this app's container, the other is
* somebody's own file with a note attached, and a list that interleaved them
* would be a list where "encrypted" is invisible.
*/
@Composable
@@ -380,9 +380,9 @@ private fun ColumnScope.FileRow(
}
Column(Modifier.weight(1f)) {
Text(
// 🔴 A row this phone cannot read is still SHOWN, with its
// A row this phone cannot read is still SHOWN, with its
// classification. Hiding it would leave somebody unable to see
// that anything is there at all — and the label is not a secret;
// that anything is there at all, and the label is not a secret;
// it has to be readable on a row you cannot open.
text = entry.name ?: "Encrypted for someone else",
fontSize = 15.sp,
@@ -425,7 +425,7 @@ private fun bytes(count: Int): String = when {
// ---- Sheets --------------------------------------------------------------
/**
* 🔴 **The choice between copying and linking is made here, with both consequences
* **The choice between copying and linking is made here, with both consequences
* written out**, rather than by a default somebody discovers later. They are not
* two ways of doing one thing: one produces ciphertext this app owns, the other
* produces a pointer at a file it does not.
@@ -474,7 +474,7 @@ private fun ColumnScope.AddFileSheet(
Spacer(Modifier.height(Space.md))
Text(
"Or link to it where it is. Nothing is copied and nothing is "
+ "encrypted until you send it — until then the classification "
+ "encrypted until you send it, until then the classification "
+ "is a note, not a lock.",
fontSize = 12.sp, color = Palette.SecondaryText,
)
@@ -517,10 +517,10 @@ private fun ColumnScope.FolderNameSheet(
Spacer(Modifier.height(Space.sm))
LevelPicker(level) { level = it }
Spacer(Modifier.height(Space.xs))
// ⚠️ Said here because the tree deliberately allows the opposite of what
// Said here because the tree deliberately allows the opposite of what
// people assume: a folder's level does not constrain what goes in it.
Text(
"A folder's level doesn't limit what you put inside it — each file "
"A folder's level doesn't limit what you put inside it, each file "
+ "keeps its own.",
fontSize = 12.sp, color = Palette.SecondaryText,
)
@@ -560,7 +560,7 @@ private fun ColumnScope.LevelPicker(selected: Short, onPick: (Short) -> Unit) {
/**
* What can be attached to a message.
*
* ⚠️ Two kinds, and they are not variations of one thing: a photo comes from
* Two kinds, and they are not variations of one thing: a photo comes from
* the gallery and is re-encoded for sending; a document comes from the vault
* and carries a classification with it.
*/
@@ -592,13 +592,13 @@ fun ColumnScope.AttachPanel(
/**
* Choose a document from the vault, from inside a conversation.
*
* 🔴 **Sending is a downgrade, and the sheet says so before the tap.** The
* Sending is a downgrade, and the sheet says so before the tap. The
* content key travels in the same envelope as the message, so a classification
* does not stop the recipient opening it — it goes so their phone files it the
* does not stop the recipient opening it, it goes so their phone files it the
* same way. Every word here has to carry that, because the alternative is a
* person believing "Restricted" did something on the way.
*
* ⚠️ **Never asks for the Files passphrase.** A conversation that prompted for
* Never asks for the Files passphrase. A conversation that prompted for
* one would be a conversation that has started collecting one, and Files goes
* out of its way not to hold it. Locked says so and points at the Files screen.
*/
@@ -628,7 +628,7 @@ fun ColumnScope.PickFileToSendSheet(
if (!unlocked) {
Text(
"Files is locked. Open Files from your account and unlock it, "
+ "then come back — your passphrase isn't asked for here.",
+ "then come back, your passphrase isn't asked for here.",
fontSize = 13.sp, color = Palette.SecondaryText,
)
} else if (entries.isEmpty()) {
@@ -637,18 +637,18 @@ fun ColumnScope.PickFileToSendSheet(
Text(
"Whoever you send this to will be able to open it. The "
+ "classification travels with the file so it's filed the "
+ "same way on their phone — it doesn't stop them reading it.",
+ "same way on their phone, it doesn't stop them reading it.",
fontSize = 12.sp, color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.xs))
// 🔴 **The photo button strips location; this one does not
// **The photo button strips location; this one does not
// (android#17).** A picture attached from the picker is re-encoded
// through ImagePrep and video through MediaScrub, so no metadata
// container survives. `Session.sendFile` passes the vault's bytes
// straight through, EXIF and all — correct for "send this file",
// straight through, EXIF and all, correct for "send this file",
// and dangerous only because nothing said which path you were on.
Text(
"A file is sent exactly as it is — including anything inside "
"A file is sent exactly as it is, including anything inside "
+ "it, like where a photo was taken. The camera button "
+ "leaves that out; this doesn't.",
fontSize = 12.sp, color = Palette.SecondaryText,
@@ -34,7 +34,7 @@ import java.util.Locale
/**
* Export and import of encrypted history backups (D4).
*
* The relay keeps no history, so a new phone starts empty — this is the
* The relay keeps no history, so a new phone starts empty, this is the
* remedy, and the passphrase is deliberately the only key: the app stores
* nothing that could open the file, so neither can anyone who takes the file.
*/
@@ -59,7 +59,7 @@ fun HistoryBackupSection(model: MessengerViewModel) {
context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) }
?: error("nothing to write to")
}.fold(
onSuccess = { "Backup saved. Keep the passphrase — it is the only key." },
onSuccess = { "Backup saved. Keep the passphrase, it is the only key." },
onFailure = { "Couldn't write the file: ${it.message}" },
)
}
@@ -70,7 +70,7 @@ fun HistoryBackupSection(model: MessengerViewModel) {
Text("History backup", style = MaterialTheme.typography.titleMedium)
Text(
"Your history lives only on this phone — the server keeps none, so a "
"Your history lives only on this phone, the server keeps none, so a "
+ "new phone starts empty. An export is sealed with a passphrase you "
+ "choose and can be imported on another Android phone signed into "
+ "this account. Lose the passphrase and the backup cannot be "
@@ -87,7 +87,7 @@ fun HistoryBackupSection(model: MessengerViewModel) {
) { Text("Export…") }
Spacer(Modifier.width(Space.sm))
OutlinedButton(
// SAF filters on MIME, and a custom extension has none — offer
// SAF filters on MIME, and a custom extension has none, offer
// everything and let the crypto refuse what isn't a backup.
onClick = { openLauncher.launch(arrayOf("*/*")) },
enabled = !working,
@@ -104,7 +104,7 @@ fun HistoryBackupSection(model: MessengerViewModel) {
PassphraseDialog(
title = "Export history",
explainer = "This passphrase is the only key to the backup. It isn't "
+ "stored anywhere — a forgotten passphrase means the backup can "
+ "stored anywhere, a forgotten passphrase means the backup can "
+ "never be opened.",
requireRepeat = true,
busy = working,
@@ -129,7 +129,7 @@ fun HistoryBackupSection(model: MessengerViewModel) {
pendingImport?.let { uri ->
PassphraseDialog(
title = "Import history",
explainer = "Anything already on this phone stays — the backup is "
explainer = "Anything already on this phone stays, the backup is "
+ "merged in, never a replacement.",
requireRepeat = false,
busy = working,
@@ -152,7 +152,7 @@ fun HistoryBackupSection(model: MessengerViewModel) {
status = outcome.fold(
onSuccess = { counts ->
if (counts.messages + counts.stories == 0)
"Backup opened — everything in it was already here."
"Backup opened, everything in it was already here."
else
"Imported ${counts.messages} message${if (counts.messages == 1) "" else "s"}" +
" and ${counts.stories} " +
@@ -7,15 +7,15 @@ import java.io.ByteArrayOutputStream
/**
* Downscale and re-encode a picked photo before it is encrypted and uploaded.
*
* 🔴 **This app did none of this.** The picker read the file and handed the
* ORIGINAL bytes straight to `sendImage`, so a modern phone camera — 12 to 50
* megapixels, often 5-15 MB of JPEG — went at the relay's blob cap directly.
* This app did none of this. The picker read the file and handed the
* ORIGINAL bytes straight to `sendImage`, so a modern phone camera, 12 to 50
* megapixels, often 5-15 MB of JPEG, went at the relay's blob cap directly.
* Over the cap the upload is refused and the photo simply fails to send; under
* it, the recipient downloads tens of megabytes nobody asked for. iOS has
* downscaled since it shipped, so the two platforms disagreed about what
* sending a photo even means.
*
* ⚠️ There is a hard ceiling and it is the server's, so "full quality" cannot
* There is a hard ceiling and it is the server's, so "full quality" cannot
* mean "whatever the camera produced". It means *as good as will fit*: start at
* the requested quality and step down until the bytes are under budget. A photo
* that fails to send is worse than a photo that is slightly softer.
@@ -87,16 +87,16 @@ object ImagePrep {
/**
* Decode bytes that came from somebody else, bounded.
*
* 🔴 **The render paths were calling `decodeByteArray` with no options at
* all.** Sending was careful — bounds first, then a sampled decode — but
* **The render paths were calling `decodeByteArray` with no options at
* all.** Sending was careful, bounds first, then a sampled decode, but
* the two places that draw an image RECEIVED from a peer decoded at full
* size. Inbound attachments are capped at 12 MiB, and 12 MiB of cleverly
* chosen JPEG is a few hundred megabytes of ARGB_8888 once expanded. A
* contact could send one image and crash the app every time the thread or
* the story was opened — a conversation wedged from the outside, with no
* the story was opened, a conversation wedged from the outside, with no
* way for the victim to get past it.
*
* ⚠️ Bounds are read first so an enormous picture is never fully decoded
* Bounds are read first so an enormous picture is never fully decoded
* just to discover how big it is. That is the whole point; a size check on
* the byte count alone does not bound the decoded bitmap.
*/
@@ -12,18 +12,18 @@ 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
* 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,
* 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
* 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
* 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.
*/
@@ -36,7 +36,7 @@ object KeystoreVault {
/**
* Is there a session worth receiving for?
*
* ⚠️ Both halves, matching `restore()`: a device with no credential cannot
* Both halves, matching `restore()`: a device with no credential cannot
* reach the relay, and a credential with no identity cannot be addressed.
* Either alone is a half-finished sign-in, not something to run a
* foreground service for.
@@ -51,8 +51,8 @@ object KeystoreVault {
/**
* 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
* 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.
@@ -89,7 +89,7 @@ object KeystoreVault {
/**
* Records as files under `filesDir`, which is app-private storage.
*
* ⚠️ Record names contain a device id and are used as filenames, so they
* 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.
@@ -23,7 +23,7 @@ import androidx.compose.ui.unit.sp
/**
* The kit's buttons.
*
* 🔴 **Because almost every action in this app was a bare `TextButton`** — an
* Because almost every action in this app was a bare `TextButton`, an
* orange word with nothing around it. Back, Call, Safety number, Record a
* voice note, Mark all read, Look up, Leave, and the four presence states were
* all the same undifferentiated orange text, so the app read as a page of
@@ -31,12 +31,12 @@ import androidx.compose.ui.unit.sp
* it is the absence of one.
*
* The kit ships three: a filled primary, an outlined secondary and a grey
* tertiary. All 64 tall at radius 32 — fully rounded — with a 14 medium label
* tertiary. All 64 tall at radius 32, fully rounded, with a 14 medium label
* and 16 of horizontal padding. See `DESIGN.md`.
*
* ⚠️ Height 64 is the kit's, and it is deliberately large. Where a control has
* Height 64 is the kit's, and it is deliberately large. Where a control has
* to sit inline next to text, use [KitChip] rather than shrinking one of these
* — a 64pt button squeezed to 32 is neither the kit's button nor a chip.
* a 64pt button squeezed to 32 is neither the kit's button nor a chip.
*/
object KitButton {
val height = 64.dp
@@ -93,11 +93,11 @@ fun KitSecondaryButton(
}
/**
* A small pill for something that sits in a row of its peers — a presence
* A small pill for something that sits in a row of its peers, a presence
* state, a filter. Selected fills with the primary; the rest are the kit's
* neutral surface.
*
* ⚠️ The kit's own small pill (its "On" badge) is 28 tall; 36 here so a finger
* The kit's own small pill (its "On" badge) is 28 tall; 36 here so a finger
* can hit it, which the kit's badge does not have to survive.
*/
@Composable
@@ -108,8 +108,8 @@ fun KitChip(
modifier: Modifier = Modifier,
tag: String? = null,
/**
* ⚠️ Optional, and a real vector when present. The voice-note chip used to
* carry its picture as an emoji inside `text` — "🎤 Voice note" — which
* Optional, and a real vector when present. The voice-note chip used to
* carry its picture as an emoji inside `text`, "Voice note", which
* renders in whatever the system emoji font is, at the text baseline, in
* full colour. Beside a drawn monochrome icon it reads as a sticker.
*/
@@ -44,8 +44,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
/**
* The app's icon vocabulary, in one place.
*
* 🔴 **Why this file exists.** Every icon in this app used to be a text
* character or an emoji — `+`, `→`, `‹`, `⋯`, `☎️`, `🎤`, `⊞`. It was reported
* Why this file exists. Every icon in this app used to be a text
* character or an emoji, `+`, `→`, `‹`, `⋯`, ``, ``, `⊞`. It was reported
* three times as "bare", "disorganized" and "a bunch of text links", and each
* time I adjusted spacing and type instead, because from the code the rows
* looked structurally right. They were: the kit's Messages screen and ours
@@ -55,11 +55,11 @@ import androidx.compose.ui.graphics.vector.ImageVector
* run. A `Text("→")` in a circle is not a send button, it is an arrow
* character, and no amount of layout work fixes that.
*
* ⚠️ `material-icons-extended` was already a dependency the whole time, and
* the app had **zero** `Icon(` calls and **zero** `material.icons` imports. The
* `material-icons-extended` was already a dependency the whole time, and
* the app had zero `Icon(` calls and zero `material.icons` imports. The
* fix was never blocked on anything; it was blocked on looking at a render.
*
* Naming is by role, not by picture — `Kit.Send` rather than `Kit.PaperPlane` —
* Naming is by role, not by picture, `Kit.Send` rather than `Kit.PaperPlane`,
* so swapping the drawing later is one edit here and nothing at the call sites.
*/
object KitIcon {
@@ -71,12 +71,12 @@ object KitIcon {
val Search: ImageVector = Icons.Outlined.Search
// Tab bar. The active tab is the filled variant of the same shape, which
// is how the kit distinguishes it — not a different icon.
// is how the kit distinguishes it, not a different icon.
val Chats: ImageVector = Icons.Outlined.ChatBubbleOutline
val ChatsActive: ImageVector = Icons.Filled.ChatBubble
// A circle, matching the iOS pill's `circle.dashed` — the two bars used to
// A circle, matching the iOS pill's `circle.dashed`, the two bars used to
// disagree (a camera here), and the same tab deserves the same glyph.
/** The Home tab — roster, stories strip, and the Recent circles. */
/** The Home tab, roster, stories strip, and the Recent circles. */
val Home: ImageVector = Icons.Outlined.Home
val Stories: ImageVector = Icons.Outlined.Circle
@@ -63,9 +63,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
/**
* ⚠️ `FragmentActivity`, not `ComponentActivity`, and that is not cosmetic:
* `FragmentActivity`, not `ComponentActivity`, and that is not cosmetic:
* `BiometricPrompt` hosts a fragment, so on a plain `ComponentActivity` it
* silently cannot show — the callback never fires and the lock screen sits
* silently cannot show, the callback never fires and the lock screen sits
* there looking broken. `FragmentActivity` extends `ComponentActivity`, so
* `setContent` and everything else is unaffected.
*/
@@ -73,22 +73,22 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 🔴 **Screenshots off, and out of the recents thumbnail.**
// Screenshots off, and out of the recents thumbnail.
//
// The second half is the one that matters and the one nobody thinks
// about: without FLAG_SECURE, Android captures whatever is on screen
// into the recent-apps preview and WRITES IT TO DISK. An open
// conversation therefore exists as a plaintext bitmap outside every
// protection this app has — the ratchet, the sealed history file, the
// ThisDeviceOnly key — none of which cover a screenshot the OS took of
// protection this app has, the ratchet, the sealed history file, the
// ThisDeviceOnly key, none of which cover a screenshot the OS took of
// the rendered thread.
//
// ⚠️ iOS has had the equivalent since the app lock landed: it obscures
// iOS has had the equivalent since the app lock landed: it obscures
// the switcher snapshot on `scenePhase`. Android had nothing at all.
// Two platforms disagreeing about a security guarantee is how one of
// them ends up being the one that leaks.
//
// ⚠️ It also blocks screen recording and casting, which is the point,
// It also blocks screen recording and casting, which is the point,
// and is not something to make optional: a setting here is a setting
// people leave off and then get surprised by.
window.setFlags(
@@ -96,8 +96,8 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
android.view.WindowManager.LayoutParams.FLAG_SECURE,
)
// ⚠️ Runtime permission on API 33+, which is this app's minSdk. Without
// it the service runs and every alert is dropped silently — no error,
// Runtime permission on API 33+, which is this app's minSdk. Without
// it the service runs and every alert is dropped silently, no error,
// no banner, and nothing on screen to say why. Ask once here rather
// than discovering it as "notifications do not work".
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
@@ -110,9 +110,9 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
setContent {
MessengerTheme {
Surface(modifier = Modifier.fillMaxSize()) {
// 🔴 **The gate is around Root, not inside it.** Drawn over
// 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
// frame, and a frame is all a screenshot or a shoulder
// needs. Locked means not composed.
var locked by remember { mutableStateOf(lock.enabled) }
@@ -126,7 +126,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
)
},
)
// ⚠️ Asked once on arrival too, so the first thing after
// Asked once on arrival too, so the first thing after
// launch is the prompt rather than a wall somebody has
// to tap to discover.
LaunchedEffect(Unit) {
@@ -158,10 +158,10 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
}
}
// 🔴 **The handover: exactly one poller at a time.** The ViewModel polls
// The handover: exactly one poller at a time. The ViewModel polls
// while the app is on screen; the service polls while it is not. Two
// pollers would race for the same envelopes and double-notify, so this
// pair has to stay symmetrical — if one side moves, move the other.
// pair has to stay symmetrical, if one side moves, move the other.
override fun onStart() {
super.onStart()
MessengerService.stop(this)
@@ -169,9 +169,9 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
override fun onStop() {
super.onStop()
// ⚠️ Only when there is a session to receive for. Starting it signed
// Only when there is a session to receive for. Starting it signed
// out puts a permanent notice in the shade for an app doing nothing,
// and the service stops itself — but not before the user has seen it.
// and the service stops itself, but not before the user has seen it.
if (KeystoreVault.hasSession(applicationContext)) MessengerService.start(this)
}
}
@@ -199,23 +199,23 @@ private fun Root(model: MessengerViewModel = viewModel()) {
}
// NOT scrollable. A `LazyColumn` inside a vertically scrolling
// Column is measured with infinite height and crashes outright —
// Column is measured with infinite height and crashes outright,
// the lists below manage their own scrolling.
is MessengerViewModel.Phase.SignedIn ->
Column(modifier = frame, verticalArrangement = Arrangement.spacedBy(12.dp)) {
// 🔴 **Above the screen switch, not inside one branch.** An
// Above the screen switch, not inside one branch. An
// incoming call has to be visible whatever screen somebody
// is on, and this used to live inside SignedIn — which the
// is on, and this used to live inside SignedIn, which the
// chat screen REPLACES. So a call arriving while you were
// reading a thread showed nothing at all.
//
// Worse, the same banner renders the first-call privacy
// prompt, and the only Call button is IN a chat. Tapping it
// set the prompt state, nothing was composed to show it,
// and the prompt gates placing the call — so calling could
// and the prompt gates placing the call, so calling could
// not be started at all. The button looked simply dead.
// 🔴 **A live call takes the screen.** It used to be a
// banner above whatever you were on — a line of text and
// A live call takes the screen. It used to be a
// banner above whatever you were on, a line of text and
// two text buttons for the single most attention-demanding
// thing this app does. The privacy prompt stays a banner
// deliberately: it is a question asked BEFORE a call
@@ -223,10 +223,10 @@ private fun Root(model: MessengerViewModel = viewModel()) {
// already happening.
CallBanner(model, current)
StoryComposerDialog(model, current)
// 🔴 **Here, above the screen switch, for the same reason
// **Here, above the screen switch, for the same reason
// the call banner is.** Nothing installs this app but the
// person holding the phone, so the one moment they can be
// told a fix exists is while they have it open — whatever
// told a fix exists is while they have it open, whatever
// screen that happens to be.
UpdateAvailableDialog(model.site?.baseUrl)
if (LiveCallScreen(model, current)) return@Column
@@ -236,7 +236,7 @@ private fun Root(model: MessengerViewModel = viewModel()) {
ChatScreen(model, chat, current.error, current.buddies, current.avatars,
myUserId = current.userId, groups = current.groups)
} else {
// 🔴 **The content area is weighted, and the tab bar is
// **The content area is weighted, and the tab bar is
// not.** These used to be plain siblings, so the tab bar
// sat immediately under whatever the screen had drawn:
// near the TOP of an empty Alerts tab, and pushed clean
@@ -245,33 +245,33 @@ private fun Root(model: MessengerViewModel = viewModel()) {
// sign-out explanation was unreadable and there was no
// way back to another tab.
//
// 🔴 **Every screen scrolls, Contacts included.**
// Every screen scrolls, Contacts included.
//
// This used to exempt Contacts, because "a lazy list
// inside a vertically scrolling Column is measured with
// infinite height and crashes outright". True — but
// infinite height and crashes outright". True, but
// only for an UNBOUNDED lazy list, and the buddy list
// has carried `heightIn(max = 320.dp)` since it was put
// in its own card. The exemption outlived its reason.
//
// ⚠️ What it cost: Groups sits below the buddy list on
// this screen, so opening **New group** pushed the
// member checkboxes and the **Create group** button past
// What it cost: Groups sits below the buddy list on
// this screen, so opening New group pushed the
// member checkboxes and the Create group button past
// the bottom of a screen that could not scroll. The
// name field was reachable and nothing else was — you
// name field was reachable and nothing else was, you
// could type a group name and then only Cancel.
// Measured on a 1080×2400 emulator: the field landed at
// y≈1879, the button past the tab bar at y≈2280, and a
// `uiautomator dump` found no scrollable container and
// no "Create group" node at all.
//
// ⚠️ Before exempting a screen here again, check what is
// Before exempting a screen here again, check what is
// BELOW the lazy list, not just the list itself. And
// keep the buddy list bounded — remove that height and
// keep the buddy list bounded, remove that height and
// this does crash.
// ⚠️ Tighter than it was. Every section already carries
// Tighter than it was. Every section already carries
// its own padding, so a 12dp gap between them on top of
// that left the list airy in a way the kit is not — its
// that left the list airy in a way the kit is not, its
// rows sit on a 72dp pitch with nothing extra between
// the groups of them.
Column(
@@ -290,9 +290,9 @@ private fun Root(model: MessengerViewModel = viewModel()) {
StoriesFeed(model, current)
MessengerViewModel.Phase.Screen.WALLET ->
WalletScreen(model, current)
// 🔴 Its own destination, not a section of the
// Its own destination, not a section of the
// wallet. iOS put them together once because
// they share an unlock — true, and an
// they share an unlock, true, and an
// implementation detail. What it produced was
// somebody pressing "wallet" and arriving at a
// file list.
@@ -326,13 +326,13 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
// Only a relay keeping its own accounts needs an address for reset mail.
val needsEmail = registering && site?.accountProvider == SiteConfig.AccountProvider.LOCAL
// 🔴 **The kit's Welcome screen, which is a masthead and not a form
// **The kit's Welcome screen, which is a masthead and not a form
// heading.** A logo mark, a 40pt greeting, and one line saying what this
// is — centred, with room above it. This was a 24pt product name jammed
// is, centred, with room above it. This was a 24pt product name jammed
// against a Server field, which is the layout of a login dialog rather
// than the first thing anyone sees of the app. See `DESIGN.md`.
//
// ⚠️ The kit's Welcome has no fields on it at all — it is a splash before
// The kit's Welcome has no fields on it at all, it is a splash before
// a phone number is asked for. Flamenet does not ask for a phone number
// and has nothing to put on a second screen, so the masthead and the form
// share one. The typography and the centring are the kit's; the fields
@@ -393,7 +393,7 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
modifier = Modifier.fillMaxWidth().testTag("handle"),
)
// Only a relay keeping its own accounts asks for this — it is how the
// 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.
@@ -438,7 +438,7 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
modifier = Modifier.fillMaxWidth().testTag("birth-year"),
)
Text(
"Checked on this phone to confirm you're old enough, then forgotten — never sent anywhere.",
"Checked on this phone to confirm you're old enough, then forgotten, never sent anywhere.",
style = MaterialTheme.typography.bodySmall,
)
ageError?.let {
@@ -451,16 +451,16 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("error"))
}
// ⚠️ 240 wide and centred, which is the kit's primary button on this
// screen — not a full-width slab. A button that spans the phone reads as
// 240 wide and centred, which is the kit's primary button on this
// screen, not a full-width slab. A button that spans the phone reads as
// the bottom of a form; one that does not reads as a choice.
Spacer(Modifier.height(Space.md))
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Button(
onClick = {
if (registering) {
// The most conservative reading — a birthday not yet
// passed this year — because "maybe old enough" isn't.
// The most conservative reading, a birthday not yet
// passed this year, because "maybe old enough" isn't.
val year = birthYear.toIntOrNull() ?: 0
val age = java.util.Calendar.getInstance().get(java.util.Calendar.YEAR) - year
if (year < 1900 || age < 14) {
@@ -509,7 +509,7 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
/**
* Password reset, in place rather than on another screen.
*
* ⚠️ The relay answers a reset request identically whatever the truth is —
* The relay answers a reset request identically whatever the truth is,
* unknown handle, no address on file, an account that authenticates elsewhere.
* So the copy here says what was *done*, never what was *found*. "No such
* account" would undo that at the last step.
@@ -563,8 +563,8 @@ private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String)
/**
* What the bar contains, as data rather than as a shape only Compose can see.
*
* 🔴 **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
* 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
@@ -577,7 +577,7 @@ object NavDestinations {
/**
* The bar, in order.
*
* ⚠️ **Order matches iOS**, which puts Files and Wallet between Stories and
* 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.
*
@@ -601,10 +601,10 @@ object NavDestinations {
* 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
* 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
* 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.
@@ -615,9 +615,9 @@ private fun BottomNav(
phase: MessengerViewModel.Phase.SignedIn,
) {
val unreadNotes = phase.notifications.count { !it.read }
// 🔴 **A floating pill, not a full-width strip.** The kit's tab bar is a
// A floating pill, not a full-width strip. The kit's tab bar is a
// 328x50 rounded rectangle inset from both edges, sitting inside an 84-tall
// band — it reads as a control resting on the page rather than as the
// band, it reads as a control resting on the page rather than as the
// bottom of the window. This is the single most recognisable thing about
// the design and the app had a plain row of text buttons.
Row(
@@ -634,10 +634,10 @@ private fun BottomNav(
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
// 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
// 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)
@@ -663,14 +663,14 @@ private fun BottomNav(
/**
* One tab: a drawn icon, and a count when there is one.
*
* 🔴 **No label.** The kit's bar is four icons and nothing else — the artboard
* No label. The kit's bar is four icons and nothing else, the artboard
* is literally named "Bottom Bar/Without Label". This was four `TextButton`s
* reading "Contacts Stories Alerts Account", which is a row of links, and
* the counts were parenthesised into those labels: "Contacts (3)".
*
* ⚠️ The label survives as the accessibility name. Dropping the text from the
* The label survives as the accessibility name. Dropping the text from the
* screen must not drop it from VoiceOver/TalkBack, or the bar becomes four
* unnamed buttons — the version of this that shipped as icons-only with no
* unnamed buttons, the version of this that shipped as icons-only with no
* `contentDescription` would be strictly worse than the text it replaced.
*/
@Composable
@@ -689,10 +689,10 @@ private fun RowScope.NavButton(
modifier = Modifier
.weight(1f)
.height(Kit.pillHeight)
// 🔴 `navigateTo`, not `showScreen`. Wallet and Files each have to
// `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`.
// loading and no error, see the note on `navigateTo`.
.clickable { model.navigateTo(screen) }
.semantics {
contentDescription =
@@ -722,8 +722,8 @@ private fun RowScope.NavButton(
}
/**
* ⚠️ Internal rather than private ONLY so the JVM screenshot suite can render
* it. `MainActivity` is its one real caller — the alternative was a screenshot
* Internal rather than private ONLY so the JVM screenshot suite can render
* it. `MainActivity` is its one real caller, the alternative was a screenshot
* harness that could photograph the app's parts and never a whole screen.
*/
@Composable
@@ -739,11 +739,11 @@ internal fun ColumnScope.SignedIn(
val askCamera = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
) { _ ->
// Open either way — the dialog explains itself when access is refused.
// Open either way, the dialog explains itself when access is refused.
scanning = true
}
// ⚠️ The contact screen REPLACES the list rather than sitting under it,
// The contact screen REPLACES the list rather than sitting under it,
// the same way an open chat does. A detail view stacked below a list is
// two screens fighting over one scroll.
val profile = openProfile?.let { id -> phase.buddies.firstOrNull { it.userId == id } }
@@ -752,9 +752,9 @@ internal fun ColumnScope.SignedIn(
return
}
// 🔴 **Home is two things now, on purpose: your people, and their
// **Home is two things now, on purpose: your people, and their
// stories.** The conversation list, the groups and the search moved to
// the Messages tab — this screen is the kit's social front page: a
// the Messages tab, this screen is the kit's social front page: a
// horizontal row of contacts, then everyone's stories. Managing the
// roster stays here (the Add action, and a long-press on a circle for
// the contact's profile).
@@ -768,7 +768,7 @@ internal fun ColumnScope.SignedIn(
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
// Scan their Address screen — iOS's home has the same button. A scan
// Scan their Address screen, iOS's home has the same button. A scan
// fills the Add-contact field and stops; nothing is added unlooked-at.
BarAction(
icon = KitIcon.Scan,
@@ -795,7 +795,7 @@ internal fun ColumnScope.SignedIn(
onFound = { raw ->
scanning = false
parseScannedAddress(raw)?.let { card ->
// 🔴 **Bind by the fnid, never the handle (audit LOW).**
// Bind by the fnid, never the handle (audit LOW).
// The fnid is derived from the account key and certifies
// itself; a handle is a name the RELAY maps to an account,
// so resolving a scanned code by handle handed the relay
@@ -848,7 +848,7 @@ internal fun ColumnScope.SignedIn(
/**
* One icon-sized action on the trailing edge of the bar.
*
* ⚠️ Glyphs rather than vector assets, because this app ships no icon set and
* Glyphs rather than vector assets, because this app ships no icon set and
* pulling one in for four buttons is a dependency for decoration. They are
* sized and spaced like the kit's 24dp icons and carry the real label for
* anyone who cannot see them.
@@ -869,7 +869,7 @@ private fun BarAction(
.testTag(tag),
contentAlignment = Alignment.Center,
) {
// 🔴 An `Icon`, not a glyph in a text run. See `KitIcons.kt` — the bar
// An `Icon`, not a glyph in a text run. See `KitIcons.kt`, the bar
// used to draw "⊞" and "+" as characters, which is most of why this
// screen read as text links rather than as chrome.
Icon(
@@ -898,9 +898,9 @@ 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.",
)
// 🔴 `discardUnreadableVault`, NOT `signOut`. `signOut()` defaults to
// `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
// 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")) {
@@ -10,8 +10,8 @@ import java.nio.ByteBuffer
/**
* Strip everything from a video except the pictures and the sound.
*
* 🔴 **A clip from the camera roll says where it was shot.** Android writes the
* capture location into the MP4 container — the `©xyz` / `location` atom — and
* A clip from the camera roll says where it was shot. Android writes the
* capture location into the MP4 container, the `©xyz` / `location` atom, and
* the story path used to hand the picked file over byte for byte, so posting a
* video told every viewer the coordinates of wherever the person happened to
* be. Nothing on screen suggested it and no setting controlled it. Photos were
@@ -19,13 +19,13 @@ import java.nio.ByteBuffer
* bitmap and `Bitmap.compress` writes no EXIF; video had no equivalent step at
* all.
*
* ⚠️ **A remux, not a transcode.** The samples are copied across untouched, so
* there is no quality loss and no codec work — the container is rebuilt and the
* A remux, not a transcode. The samples are copied across untouched, so
* there is no quality loss and no codec work, the container is rebuilt and the
* metadata simply is not carried over. That is cheap enough to do on every
* post, which matters: a scrub people wait for is a scrub somebody eventually
* makes optional.
*
* ⚠️ Rotation IS carried over, deliberately. It says which way up the phone
* Rotation IS carried over, deliberately. It says which way up the phone
* was, not where it was, and dropping it plays every portrait clip on its side.
*/
object MediaScrub {
@@ -34,7 +34,7 @@ object MediaScrub {
* @param source the picked video's bytes.
* @param rotationDegrees from `METADATA_KEY_VIDEO_ROTATION`, re-applied to
* the rebuilt file.
* @return scrubbed bytes, or null if the file could not be rebuilt — the
* @return scrubbed bytes, or null if the file could not be rebuilt, the
* caller must then refuse the post rather than fall back to the original.
*/
fun videoWithoutMetadata(
@@ -54,8 +54,8 @@ object MediaScrub {
extractor.setDataSource(input.absolutePath)
muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
// Audio and video only. Anything else in the file — timed text, a
// sensor track, whatever a particular camera app decided to add —
// Audio and video only. Anything else in the file, timed text, a
// sensor track, whatever a particular camera app decided to add,
// is exactly the kind of passenger this exists to leave behind.
val trackMap = HashMap<Int, Int>()
var bufferSize = DEFAULT_BUFFER
@@ -87,7 +87,7 @@ object MediaScrub {
info.offset = 0
info.size = size
info.presentationTimeUs = extractor.sampleTime
// ⚠️ Mapped rather than assigned across. The extractor's
// Mapped rather than assigned across. The extractor's
// flags and the codec's are different vocabularies that
// happen to agree on the value of "sync sample" today.
info.flags =
@@ -5,8 +5,8 @@ import android.content.Context
/**
* How photos are prepared before they are sent.
*
* ⚠️ Ordinary SharedPreferences, not the vault. This records a preference, not
* a secret — and the picker needs to read it on a path that must not depend on
* Ordinary SharedPreferences, not the vault. This records a preference, not
* a secret, and the picker needs to read it on a path that must not depend on
* the encrypted store being open.
*/
class MediaSettings(context: Context) {
@@ -16,8 +16,8 @@ class MediaSettings(context: Context) {
/**
* Send photos at the highest quality that will still fit.
*
* ⚠️ Not "uncompressed". The relay refuses a blob over its cap, so this
* raises the ceiling and [ImagePrep] steps down until the bytes fit — a
* Not "uncompressed". The relay refuses a blob over its cap, so this
* raises the ceiling and [ImagePrep] steps down until the bytes fit, a
* photo that failed to send would be worse than a slightly softer one.
*/
var fullQuality: Boolean
@@ -27,17 +27,17 @@ class MediaSettings(context: Context) {
/**
* How long a story posted from this phone stays visible, in hours.
*
* 🔴 **A day was the only answer the app had.** The lifetime was a
* A day was the only answer the app had. The lifetime was a
* constant, so the one thing people actually want to change about a story
* — how long it lasts — was the one thing they could not.
* how long it lasts, was the one thing they could not.
*
* ⚠️ The choice is the SENDER's and rides inside the sealed envelope
* The choice is the SENDER's and rides inside the sealed envelope
* (`exp`, §6.6), so it does not ask the relay to keep a promise: every copy
* carries the expiry its poster chose.
*
* ⚠️ Clamped to the offered set on read. A value from a future build, or a
* Clamped to the offered set on read. A value from a future build, or a
* corrupted preference, must not post a story that expires before anybody
* sees it — which is what a raw 0 would do.
* sees it, which is what a raw 0 would do.
*/
var storyLifetimeHours: Int
get() {
@@ -52,9 +52,9 @@ class MediaSettings(context: Context) {
/**
* Keep your own stories after they expire, in a private archive (ios#54).
*
* 🔴 **Off unless switched on, and an absent value reads as false.** This
* Off unless switched on, and an absent value reads as false. This
* keeps something the interface told everybody would disappear, so it
* cannot be the quiet default — and it only ever holds stories this
* cannot be the quiet default, and it only ever holds stories this
* account posted.
*/
var storyArchiveEnabled: Boolean
@@ -65,16 +65,16 @@ class MediaSettings(context: Context) {
* Refuse to send unless the relay can prove the recipient's keys appear in
* the log it publishes.
*
* 🔴 **This lives here because it was not stored anywhere at all.** It was
* This lives here because it was not stored anywhere at all. It was
* a plain in-memory field on the trust object, set from the switch and
* never written down, so it reverted to off on every launch. A person who
* turned it on was protected until they next closed the app, and nothing
* told them it had lapsed — the switch simply read off again, and a
* told them it had lapsed, the switch simply read off again, and a
* setting that quietly disarms itself is worse than one that was never
* offered. iOS has always persisted this in UserDefaults; Android had
* nothing.
*
* ⚠️ Still off by default, deliberately. A relay older than the
* Still off by default, deliberately. A relay older than the
* transparency log has no log to prove anything against, so enforcing it
* everywhere would refuse every peer on those deployments.
*/
@@ -85,9 +85,9 @@ class MediaSettings(context: Context) {
/**
* Generate a preview card when sending a link.
*
* ⚠️ Fetching happens on THIS device before the message goes, so the site
* Fetching happens on THIS device before the message goes, so the site
* learns somebody is about to share that URL, from this address. The
* recipient never fetches anything (§6.12) — this end does, and that is
* recipient never fetches anything (§6.12), this end does, and that is
* what the setting is for.
*/
var linkPreviews: Boolean
@@ -100,7 +100,7 @@ class MediaSettings(context: Context) {
private const val FULL_QUALITY = "full_quality"
private const val STORY_LIFETIME_HOURS = "stories.lifetimeHours"
const val STORY_LIFETIME_DEFAULT = 24
/** ⚠️ Same set as iOS, so the two apps offer the same thing. */
/** Same set as iOS, so the two apps offer the same thing. */
val STORY_LIFETIME_CHOICES = listOf(1, 6, 24, 72, 168)
private const val LINK_PREVIEWS = "link_previews"
}
@@ -108,11 +108,11 @@ class MediaSettings(context: Context) {
/**
* Refuse to send at all when the send cannot be sealed (§14.5, ios#16).
*
* 🔴 **Off by default, and that is the honest default rather than the lazy
* **Off by default, and that is the honest default rather than the lazy
* one.** An unsealed send is still end-to-end encrypted; what leaks is that
* this account sent something to that one. Refusing turns a metadata leak
* into an undelivered message, which for most people is the worse outcome
* — so the default degrades and SAYS SO.
* so the default degrades and SAYS SO.
*/
var requireSealedSending: Boolean
get() = prefs.getBoolean("privacy.requireSealed", false)
@@ -38,8 +38,8 @@ import io.flamenet.e2e.Vault
* The kit's `07 Messages-Listing` as its own tab: large title, search box,
* the Recent circles row, then a flat list of conversations.
*
* 🔴 **Conversations, not contacts.** Home lists everybody and manages the
* roster; this screen is the inbox — people you talk to, unread first. Same
* Conversations, not contacts. Home lists everybody and manages the
* roster; this screen is the inbox, people you talk to, unread first. Same
* split iOS ships, so the two apps stay one product.
*/
@Composable
@@ -56,13 +56,13 @@ fun ColumnScope.MessagesScreen(
val askCamera = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
) { _ ->
// Open either way — the dialog explains itself when access is refused.
// Open either way, the dialog explains itself when access is refused.
scanning = true
}
// Starting a group sits on the Groups heading and a chat on the Chats
// heading; the bar keeps the two ways a NEW person gets in — scan their
// code, or add them by name — the same pair the Home bar carries.
// heading; the bar keeps the two ways a NEW person gets in, scan their
// code, or add them by name, the same pair the Home bar carries.
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -132,8 +132,8 @@ fun ColumnScope.MessagesScreen(
)
Spacer(Modifier.height(Space.md))
// ⚠️ Above the conversation list. Somebody searching for a word they
// remember is looking for the message, not the person — name matches are
// Above the conversation list. Somebody searching for a word they
// remember is looking for the message, not the person, name matches are
// already covered by the list underneath.
MessageHits(model, query.value)
@@ -142,8 +142,8 @@ fun ColumnScope.MessagesScreen(
Divider()
Spacer(Modifier.height(Space.sm))
// The kit's listing holds every conversation — groups included.
// ⚠️ No label of our own above the groups: GroupsSection draws its own
// The kit's listing holds every conversation, groups included.
// No label of our own above the groups: GroupsSection draws its own
// (and only when there is something to head). A second one here printed
// "GROUPS" twice.
GroupsSection(model, phase, query.value, creatingGroup.value) { creatingGroup.value = it }
@@ -162,7 +162,7 @@ fun ColumnScope.MessagesScreen(
0.8f, androidx.compose.ui.unit.TextUnitType.Sp),
modifier = Modifier.weight(1f),
)
// The way in, beside the label — exactly as New group sits beside its
// The way in, beside the label, exactly as New group sits beside its
// heading in the bar above.
androidx.compose.material3.Icon(
KitIcon.NewChat,
@@ -180,7 +180,7 @@ fun ColumnScope.MessagesScreen(
title = { Text("New chat") },
text = {
if (phase.buddies.isEmpty()) {
Text("Add a contact first — on Home, with their username or address.")
Text("Add a contact first, on Home, with their username or address.")
} else {
Column {
phase.buddies.forEach { buddy ->
@@ -238,7 +238,7 @@ fun ColumnScope.MessagesScreen(
if (shown.isEmpty()) {
Text(
if (phase.buddies.isEmpty()) {
"No conversations yet. Add someone on Home — everyone you add is a tap away."
"No conversations yet. Add someone on Home, everyone you add is a tap away."
} else {
"Nothing matches that search."
},
@@ -304,8 +304,8 @@ private fun UnreadDot() {
/**
* What a search found inside conversations, rather than in their names.
*
* ⚠️ Debounced. Each keystroke would otherwise decrypt every thread on the
* device — the cost the iPhone app does not pay, because it keeps the whole
* Debounced. Each keystroke would otherwise decrypt every thread on the
* device, the cost the iPhone app does not pay, because it keeps the whole
* history in memory.
*/
@Composable
@@ -25,24 +25,24 @@ import kotlinx.coroutines.launch
/**
* Keeps receiving while the app is not on screen.
*
* 🔴 **This is the only way to be alerted without a third party.** iOS has one
* This is the only way to be alerted without a third party. iOS has one
* mechanism for waking a suspended app and it is Apple's; Android does not have
* that limit — a foreground service may hold a connection with nobody in the
* that limit, a foreground service may hold a connection with nobody in the
* middle. That asymmetry is the whole reason this class exists, and it is worth
* being honest about on the privacy page rather than letting people assume the
* two platforms behave alike.
*
* ⚠️ **The persistent notification is not optional and not a bug.** Android
* The persistent notification is not optional and not a bug. Android
* requires a visible notice for a foreground service. Users will ask to remove
* it; removing it means the OS kills the connection. It is the price of not
* routing wake-ups through Google.
*
* ⚠️ **Exactly one poller at a time.** The ViewModel polls while the app is on
* screen and this service polls while it is not — started from `onStop`,
* Exactly one poller at a time. The ViewModel polls while the app is on
* screen and this service polls while it is not, started from `onStop`,
* stopped from `onStart`. Two pollers would race for the same envelopes and
* double-notify. If that handover ever changes, check both ends.
*
* ⚠️ Some OEMs (Xiaomi, Huawei, Samsung on aggressive battery settings) kill
* Some OEMs (Xiaomi, Huawei, Samsung on aggressive battery settings) kill
* foreground services anyway. Nothing here can prevent that; the poll resumes
* when the app is next opened, and no message is lost because the relay holds
* it until the cursor advances.
@@ -61,7 +61,7 @@ class MessengerService : Service() {
/** Broadcast action for the notification's Decline button. */
const val ACTION_DECLINE = "io.flamenet.messenger.DECLINE_CALL"
/** ⚠️ Matches the relay's own hold, so a poll returns as it expires. */
/** Matches the relay's own hold, so a poll returns as it expires. */
private const val WAIT_SECONDS = 20
fun start(context: Context) {
@@ -86,7 +86,7 @@ class MessengerService : Service() {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// ⚠️ startForeground within a few seconds of onStartCommand or Android
// startForeground within a few seconds of onStartCommand or Android
// kills the process with a ForegroundServiceDidNotStartInTimeException.
// Do it before any I/O, not after the session is built.
startInForeground()
@@ -130,8 +130,8 @@ class MessengerService : Service() {
/**
* Rebuild the session from the vault rather than sharing the ViewModel's.
*
* ⚠️ The Activity may already be gone by the time this runs — that is the
* situation the service exists for — so there is nothing to share. The
* The Activity may already be gone by the time this runs, that is the
* situation the service exists for, so there is nothing to share. The
* vault is the durable half and both sides read it the same way.
*/
private fun openSession(): Session? = runCatching {
@@ -154,19 +154,19 @@ class MessengerService : Service() {
private suspend fun run() {
val s = openSession() ?: run {
// Nothing to receive for — signed out, or the vault is unreadable.
// Nothing to receive for, signed out, or the vault is unreadable.
// Stop rather than sit in the notification shade doing nothing.
stopSelf()
return
}
session = s
// ⚠️ The constant, not the literal: the wire value is "call-offer",
// The constant, not the literal: the wire value is "call-offer",
// and a bare "offer" matches nothing and rings for no call ever.
s.onCallSignal = { fromUser, signal, sentAt ->
when (signal.action) {
io.flamenet.e2e.Content.CallSignal.OFFER -> {
// 🔴 Park the offer BEFORE notifying. This session consumed
// the envelope, so the SDP in hand is the only copy — the
// Park the offer BEFORE notifying. This session consumed
// the envelope, so the SDP in hand is the only copy, the
// UI answers from PendingIncomingCall or not at all.
// Offers old enough that the caller gave up are notified
// as missed, not parked as answerable.
@@ -180,7 +180,7 @@ class MessengerService : Service() {
}
io.flamenet.e2e.Content.CallSignal.END -> {
// The caller hung up before anyone opened the app. An
// offer left parked would ring for a dead call — and so
// offer left parked would ring for a dead call, and so
// would the notification, which is now `ongoing` and will
// NOT go away on its own.
PendingIncomingCall.clear(signal.callId)
@@ -193,19 +193,19 @@ class MessengerService : Service() {
while (true) {
val touched = runCatching { s.receive() }.getOrDefault(emptySet())
if (touched.isNotEmpty()) notifyMessages(touched.size)
// 🔴 Reply reminders fire from THIS loop, after `receive()`, and
// Reply reminders fire from THIS loop, after `receive()`, and
// the order matters: an inbound message clears the reminder inside
// `Session.record`, so a reply that has just landed cancels its own
// reminder before it can go off. Checking first would fire a
// reminder for a conversation that was answered a moment ago —
// reminder for a conversation that was answered a moment ago,
// which is the one thing that would teach people to ignore them.
fireDueReplyReminders(s)
// 🔴 An unanswered ring has to stop ringing. The notification is
// An unanswered ring has to stop ringing. The notification is
// `ongoing`, so nothing dismisses it on its own: without this a
// call nobody picked up leaves a permanent "incoming call" on the
// lock screen, with an Answer button for a call that is long dead.
expireStaleRing()
// ⚠️ A short gap, not a poll interval: `receive()` is held open by
// A short gap, not a poll interval: `receive()` is held open by
// the relay for WAIT_SECONDS, so this only paces a relay that
// answers immediately.
delay(500)
@@ -215,8 +215,8 @@ class MessengerService : Service() {
/**
* A caller's name, from the buddy list.
*
* ⚠️ Cached for the life of the service and refreshed only on a miss. This
* runs on a ringing phone — the user has seconds — so a name lookup must
* Cached for the life of the service and refreshed only on a miss. This
* runs on a ringing phone, the user has seconds, so a name lookup must
* not wait on the network when it already knows the answer.
*/
private var names: Map<Long, String> = emptyMap()
@@ -232,7 +232,7 @@ class MessengerService : Service() {
private fun notifyMessages(threads: Int, from: String? = null) {
val settings = NotificationSettings(this)
// 🔴 The master switch and the per-kind one, both checked before a
// The master switch and the per-kind one, both checked before a
// notification is built rather than after. A notification suppressed
// late still woke the screen.
if (!settings.enabled || !settings.newMessages) return
@@ -240,9 +240,9 @@ class MessengerService : Service() {
val open = PendingIntent.getActivity(
this, 1, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
// 🔴 **Still no sender and no preview unless it was asked for.** The
// Still no sender and no preview unless it was asked for. The
// notification is drawn on this device from locally decrypted
// plaintext, so it *could* always have carried both — but a lock screen
// plaintext, so it *could* always have carried both, but a lock screen
// is a shoulder-surfing surface, and the product's claim is that a
// message is for the two people in it. `NotificationSettings` decides;
// its default is the count-only behaviour that was here before, so
@@ -255,7 +255,7 @@ class MessengerService : Service() {
/**
* Notify about conversations nobody answered, and forget them.
*
* ⚠️ Cleared as it fires. A reminder is a one-shot: leaving the row would
* Cleared as it fires. A reminder is a one-shot: leaving the row would
* re-notify on the next cycle, half a second later, for ever.
*/
private fun fireDueReplyReminders(s: Session) {
@@ -269,9 +269,9 @@ class MessengerService : Service() {
// who you are waiting on is as sensitive as what they said.
val settings = NotificationSettings(this)
if (!settings.enabled || !settings.newMessages) return
// ⚠️ Only a one-to-one thread can be named. `threadForUser` writes
// Only a one-to-one thread can be named. `threadForUser` writes
// `user:<id>`, and groups and cross-relay peers use their own prefixes
// — so anything else falls through to the count, which is the safe
// so anything else falls through to the count, which is the safe
// direction: a wrong name on a lock screen is worse than no name.
val only = due.keys.singleOrNull()
?.removePrefix("user:")?.takeIf { it.all(Char::isDigit) }
@@ -284,21 +284,21 @@ class MessengerService : Service() {
/**
* Ring, in a way that can be answered without opening the app first.
*
* 🔴 **A high-priority notification is a banner, not a ringing phone.** The
* A high-priority notification is a banner, not a ringing phone. The
* previous version was one: on a locked device it showed a line of text and
* the only way to act on it was to unlock, find the app, and open it — by
* the only way to act on it was to unlock, find the app, and open it, by
* which time the caller has given up. A call is a live event with seconds
* on the clock, so it needs a full-screen intent, which is what wakes a
* locked screen into something answerable.
*
* ⚠️ `CallStyle` and `CATEGORY_CALL` are not decoration. Android 14 grants
* `CallStyle` and `CATEGORY_CALL` are not decoration. Android 14 grants
* USE_FULL_SCREEN_INTENT at install only to apps that ring like this;
* without them the permission is refused and the call silently degrades to
* the banner it used to be.
*
* ⚠️ The Answer button hands off through the SAME parked offer the app uses
* on launch. The SDP in hand is the only copy — this session consumed the
* envelope — so a second, separate answer path would be a second way to
* The Answer button hands off through the SAME parked offer the app uses
* on launch. The SDP in hand is the only copy, this session consumed the
* envelope, so a second, separate answer path would be a second way to
* lose a call.
*/
private fun notifyCall(from: String) {
@@ -331,7 +331,7 @@ class MessengerService : Service() {
}
}
/** Stop ringing — the call was declined, or it ended on its own. */
/** Stop ringing, the call was declined, or it ended on its own. */
private fun clearCallNotification() {
runCatching { androidx.core.app.NotificationManagerCompat.from(this).cancel(3) }
}
@@ -339,8 +339,8 @@ class MessengerService : Service() {
/**
* Stop ringing for an offer nobody answered in time.
*
* ⚠️ `PendingIncomingCall.take()` already refuses to hand over an offer
* older than the dial timeout, so the offer itself expires correctly — but
* `PendingIncomingCall.take()` already refuses to hand over an offer
* older than the dial timeout, so the offer itself expires correctly, but
* the notification is a separate object and expires by nothing at all.
* Left alone it sits on the lock screen offering to answer a dead call.
*/
File diff suppressed because it is too large Load Diff
@@ -7,20 +7,20 @@ 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
* **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
* 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
* 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
* 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.
*
@@ -48,11 +48,11 @@ object MetadataScrub {
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,
// 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.
// 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.
@@ -69,12 +69,12 @@ object MetadataScrub {
* 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
* 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
* 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.
@@ -31,15 +31,15 @@ import io.flamenet.obfs4.Obfs4Stats
/**
* Reaching Flamenet on a network that blocks it.
*
* 🔴 **This screen exists because a bad line used to be silent.** A line that
* would not parse became "no bridge", and "no bridge" is a direct connection —
* This screen exists because a bad line used to be silent. A line that
* would not parse became "no bridge", and "no bridge" is a direct connection,
* so somebody who pasted a bridge *because* direct does not work would have
* been put straight back on the path they were escaping, with the setting still
* looking configured. Nothing is stored here that could not be used, and what
* is shown afterwards is whether a handshake has actually completed, not what
* is written in preferences.
*
* ⚠️ **Every word has to stay honest about what this protects against.** A
* Every word has to stay honest about what this protects against. A
* bridge hides traffic from whoever is between the phone and the bridge. The
* bridge then proxies to the relay, which sees exactly what it saw before.
*/
@@ -66,8 +66,8 @@ fun ColumnScope.NetworkSection(
Text("Network", style = MaterialTheme.typography.titleMedium)
// 🔴 Reads the handshake counter and the bridge in force, never the stored
// line. The two disagree exactly when it matters — a saved bridge that is
// Reads the handshake counter and the bridge in force, never the stored
// line. The two disagree exactly when it matters, a saved bridge that is
// not there leaves the app connecting directly, and only this says so.
Text(
statusLine(),
@@ -76,9 +76,9 @@ fun ColumnScope.NetworkSection(
modifier = Modifier.testTag("obfs4-status"),
)
// 🔴 **Offers from contacts, and the reason they are a list and not a
// **Offers from contacts, and the reason they are a list and not a
// setting.** A bridge line routes every relay connection through a machine
// somebody else chose. Nothing applies one on arrival — a contact, or
// somebody else chose. Nothing applies one on arrival, a contact, or
// whoever has taken their account, could otherwise move this phone's
// traffic onto a host they control. They could not read it, but they would
// learn who is online and when, and could cut them off. Accepting is a
@@ -94,7 +94,7 @@ fun ColumnScope.NetworkSection(
fontSize = 14.sp,
modifier = Modifier.testTag("obfs4-offer-${offer.fromUser}"),
)
// ⚠️ The line is shown, not hidden behind "Accept". Somebody may
// The line is shown, not hidden behind "Accept". Somebody may
// want to check the fingerprint against another source before
// routing their traffic through it, and they cannot check what
// they cannot see.
@@ -157,7 +157,7 @@ fun ColumnScope.NetworkSection(
modifier = Modifier.fillMaxWidth().testTag("obfs4-bridge-line"),
)
// ⚠️ Under the field and always in the same place, so it reads as feedback
// Under the field and always in the same place, so it reads as feedback
// on what was typed rather than as a warning from somewhere else.
when (parsed) {
is BridgeLineResult.Bad -> Text(
@@ -190,7 +190,7 @@ fun ColumnScope.NetworkSection(
KitPrimaryButton(
text = "Save this bridge",
onClick = {
// ⚠️ Goes through `save`, which parses again and stores only what it
// Goes through `save`, which parses again and stores only what it
// could use. The button being enabled is a convenience; the refusal
// lives one layer down, where nothing can route around it.
if (settings.save(draft) is BridgeLineResult.Ok) {
@@ -218,13 +218,13 @@ fun ColumnScope.NetworkSection(
)
}
// 🔴 **The other half of receiving one.** A bridge is only useful while
// The other half of receiving one. A bridge is only useful while
// whoever wants to block it does not have the line, and every public way of
// handing one out is somewhere a censor can go and collect them. Passing it
// to somebody you already talk to is the one route that does not scale for
// them: enumerating bridges becomes infiltrating friendships one at a time.
//
// ⚠️ Only when a bridge is actually configured. "Share" on a screen with
// Only when a bridge is actually configured. "Share" on a screen with
// nothing set would send an empty line and put an un-actionable row on
// somebody else's settings screen.
if (stored != null && buddies.isNotEmpty()) {
@@ -254,7 +254,7 @@ fun ColumnScope.NetworkSection(
model?.shareBridge(buddy.userId)
sharedWith = sharedWith + buddy.userId
},
// ⚠️ Not disabled after sending. An operator rotates a
// Not disabled after sending. An operator rotates a
// bridge and the same person needs the new line; a button
// that never re-arms would make that impossible from here.
tag = "obfs4-share-${buddy.userId}",
@@ -267,7 +267,7 @@ fun ColumnScope.NetworkSection(
Text(
"Paste the line a bridge provider gave you. It's one long line that starts with " +
"obfs4 — copy all of it, including the part after cert=.",
"obfs4, copy all of it, including the part after cert=.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
@@ -282,7 +282,7 @@ private fun parsedOk(line: String) = BridgeLine.parse(line) is BridgeLineResult.
/**
* What is actually happening, as opposed to what is stored.
*
* 🔴 **Lifted out of the Composable so it can be asserted.** A string built
* Lifted out of the Composable so it can be asserted. A string built
* inline in a `Text(...)` is a string nothing can test, which is how a screen
* ends up quietly claiming protection it does not have. The same lesson the
* `registeredLine` note records, applied before it bites rather than after.
@@ -296,28 +296,28 @@ internal fun statusLine(
"everywhere."
handshakes > 0 ->
"Going through ${bridge.host}:${bridge.port}. Traffic to your server is wrapped so " +
"that whoever carries it can't tell what it is — your server still sees what it " +
"that whoever carries it can't tell what it is, your server still sees what it " +
"always did."
// ⚠️ Not "protected". A saved bridge that has never completed a handshake
// Not "protected". A saved bridge that has never completed a handshake
// has carried nothing, and saying otherwise is the exact claim this screen
// was built to stop making.
else ->
"Bridge saved: ${bridge.host}:${bridge.port}. Nothing has gone through it yet — the " +
"Bridge saved: ${bridge.host}:${bridge.port}. Nothing has gone through it yet, the " +
"app will try the next time it goes online."
}
/**
* 🔴 **Says what it does not protect, in the same breath as what it does.** The
* Says what it does not protect, in the same breath as what it does. The
* failure mode for this feature is somebody believing it hides them from their
* server, behaving accordingly, and being wrong.
*/
private const val EXPLANATION =
"Some networks block Flamenet outright, or recognise it and slow it down. A bridge is a " +
"machine somewhere else that your app talks to instead, in a way that doesn't look " +
"like anything in particular — not like Flamenet, and not like a video call or a web " +
"like anything in particular, not like Flamenet, and not like a video call or a web " +
"page either. The bridge passes your traffic on to your server.\n\n" +
"Two things worth being clear about. Your server sees exactly what it saw before, so " +
"this doesn't hide anything from whoever runs it. And whoever runs the bridge can see " +
"that you're connecting to them, though not what you're saying — your messages stay " +
"that you're connecting to them, though not what you're saying, your messages stay " +
"end-to-end encrypted the whole way, as they always are.\n\n" +
"If Flamenet already works where you are, you don't need this."
@@ -23,7 +23,7 @@ import androidx.compose.ui.unit.sp
/**
* What this phone's notifications are allowed to say.
*
* 🔴 **Android had none of this.** iOS has offered these since it shipped, and
* Android had none of this. iOS has offered these since it shipped, and
* the parity scrub on 2026-09-06 found eight preferences on one phone and not
* the other. `MessengerService` had already written down what was missing, at
* the only place it could: "Count only, until there is a setting for it."
@@ -68,15 +68,15 @@ fun ColumnScope.NotificationSection() {
Spacer(Modifier.height(Space.xs))
// 🔴 Says what turning it on costs, and does not pretend the default is
// Says what turning it on costs, and does not pretend the default is
// merely cautious. Who is writing to you is, on some phones, the more
// sensitive of the two facts.
Text(
if (previews)
"Your lock screen will name whoever wrote to you. Anyone who can see your "
+ "phone can see that — which is sometimes more revealing than the message."
+ "phone can see that, which is sometimes more revealing than the message."
else
"Notifications say how many messages arrived and nothing else — no name, no "
"Notifications say how many messages arrived and nothing else, no name, no "
+ "words. Calls still ring.",
fontSize = 12.sp,
color = Palette.SecondaryText,
@@ -5,9 +5,9 @@ import android.content.Context
/**
* What a notification is allowed to say, and whether to post one at all.
*
* 🔴 **A lock screen is a shoulder-surfing surface.** This app draws its
* A lock screen is a shoulder-surfing surface. This app draws its
* notifications on the phone, from plaintext it has already decrypted, so it
* *could* put the sender and the message on the lock screen — and for most of
* *could* put the sender and the message on the lock screen, and for most of
* this app's life it deliberately did not, because the product's whole claim is
* that a message is for the two people in it. `MessengerService` says so at the
* only place it could have: "Count only, until there is a setting for it."
@@ -16,7 +16,7 @@ import android.content.Context
* a count, no name, no words. Somebody who wants their phone to say who is
* writing can ask for it; nobody gets it by upgrading.
*
* ⚠️ Same keys and same defaults as iOS, so the two phones behave alike for one
* Same keys and same defaults as iOS, so the two phones behave alike for one
* person. iOS reads these through `SettingsKeys`; the names differ because the
* platforms store differently, and the DEFAULTS are what must not drift.
*/
@@ -29,7 +29,7 @@ class NotificationSettings(context: Context) {
get() = prefs.getBoolean(ENABLED, true)
set(value) = prefs.edit().putBoolean(ENABLED, value).apply()
/** Notify about arriving messages. Calls ring regardless — see [enabled]. */
/** Notify about arriving messages. Calls ring regardless, see [enabled]. */
var newMessages: Boolean
get() = prefs.getBoolean(NEW_MESSAGES, true)
set(value) = prefs.edit().putBoolean(NEW_MESSAGES, value).apply()
@@ -37,7 +37,7 @@ class NotificationSettings(context: Context) {
/**
* Put the sender's name, and a hint of the message, on the lock screen.
*
* 🔴 **Off by default, and that is the honest default rather than the
* **Off by default, and that is the honest default rather than the
* cautious one.** Turning it on is a real choice with a real cost: anyone
* who can see the phone can see who is writing to you, which on some
* phones is a more sensitive fact than what they said. The people for whom
@@ -56,13 +56,13 @@ class NotificationSettings(context: Context) {
/**
* The text for a message notification, given how many threads are waiting.
*
* 🔴 **Lifted out of the service so it can be asserted.** A string built
* Lifted out of the service so it can be asserted. A string built
* inline inside a `NotificationCompat.Builder` chain is a string nothing
* can test, and the thing being decided here is whether a name reaches a
* lock screen. `registeredLine` records what that costs — a template that
* lock screen. `registeredLine` records what that costs, a template that
* shipped rendering its own source code, invisible to the compiler.
*
* ⚠️ `from` is only consulted when previews are on. A caller that passes a
* `from` is only consulted when previews are on. A caller that passes a
* name is not asking for it to be shown; this decides that.
*/
fun messageText(threads: Int, from: String?): String =
@@ -74,10 +74,10 @@ class NotificationSettings(context: Context) {
companion object {
/**
* 🔴 **Pure, and takes the preference as an argument, so a test can
* **Pure, and takes the preference as an argument, so a test can
* call THIS rather than a copy of it.** The first version of the test
* beside this file reimplemented the rules against a fake and passed
* perfectly while asserting nothing about the code that ships — the
* perfectly while asserting nothing about the code that ships, the
* same shape as two identically-wrong engines agreeing. A `Context` is
* not available in a JVM unit test, so the decision had to leave the
* class that needs one.
@@ -8,18 +8,18 @@ import io.flamenet.obfs4.BridgeLineResult
/**
* The bridge line this phone uses, if any.
*
* 🔴 **Nothing is stored that [BridgeLine.parse] rejected.** A line that will
* not parse becomes "no bridge", and "no bridge" is a direct connection — so
* Nothing is stored that [BridgeLine.parse] rejected. A line that will
* not parse becomes "no bridge", and "no bridge" is a direct connection, so
* storing one would put the person who pasted it back on the exact path they
* were trying to leave, while the screen went on showing their line. The screen
* refuses it instead and says which part is wrong.
*
* ⚠️ Ordinary SharedPreferences, not the vault. A bridge line is not a secret:
* Ordinary SharedPreferences, not the vault. A bridge line is not a secret:
* operators publish them, and it has to be readable on a path that runs before
* the encrypted store is open — the app needs its transport before it can sign
* the encrypted store is open, the app needs its transport before it can sign
* in.
*
* ⚠️ **This hides traffic from the NETWORK, not from us.** The bridge proxies
* This hides traffic from the NETWORK, not from us. The bridge proxies
* to the relay, which still sees everything it saw before. No copy anywhere may
* imply otherwise.
*/
@@ -30,7 +30,7 @@ class Obfs4Settings(context: Context) {
/**
* The stored line, exactly as it was pasted.
*
* ⚠️ Kept as the operator's own line rather than as parsed fields, so it can
* Kept as the operator's own line rather than as parsed fields, so it can
* be shown back unchanged. A round trip through separate fields loses the
* fingerprint and makes a typo unrecoverable.
*/
@@ -45,7 +45,7 @@ class Obfs4Settings(context: Context) {
/**
* Stores a line and starts using it, or reports why it cannot be used.
*
* 🔴 The only way in. A setter that took any string would be the silent
* The only way in. A setter that took any string would be the silent
* fallback all over again.
*/
fun save(line: String): BridgeLineResult {
@@ -67,13 +67,13 @@ class Obfs4Settings(context: Context) {
/**
* Puts the stored line into force. Called once at startup.
*
* ⚠️ **Before any client is built.** The default HTTP client consults
* Before any client is built. The default HTTP client consults
* [Obfs4Routing] per socket rather than at construction, so a late call
* still takes effect — but a request that went out first would have gone
* still takes effect, but a request that went out first would have gone
* out directly, and on a network that blocks the relay that is a failed
* launch rather than a slow one.
*
* ⚠️ A stored line that no longer parses is discarded rather than kept.
* A stored line that no longer parses is discarded rather than kept.
* Leaving it would show a bridge on the settings screen that nothing could
* use.
*/
@@ -91,10 +91,10 @@ class Obfs4Settings(context: Context) {
/**
* Accept a bridge a contact offered.
*
* 🔴 **The only path from an offer to a route, and it takes a person.**
* The only path from an offer to a route, and it takes a person.
* The transport layer stores offers and applies none of them; this is
* called from a button. Everything else about an offer — that it arrived,
* who sent it, that it parses — changes nothing about where this phone's
* called from a button. Everything else about an offer, that it arrived,
* who sent it, that it parses, changes nothing about where this phone's
* traffic goes.
*
* Returns the same result [save] does, so a line that arrived broken is
@@ -8,18 +8,18 @@ import java.security.SecureRandom
* Device-side supervision a parent configures on a child's phone.
*
* The honest shape for an E2E messenger: the server cannot read messages, so
* there is nothing server-side to moderate — what a parent CAN govern is what
* there is nothing server-side to moderate, what a parent CAN govern is what
* this device is allowed to do. A PIN the parent sets guards three switches:
* adding new contacts, the stories surface, and calls.
*
* **Threat model, stated plainly:** this restrains a child using a phone the
* Threat model, stated plainly: this restrains a child using a phone the
* parent configured. It is enforced in this app's own UI and stored on the
* device; it is not a defence against someone who can install apps freely or
* clear this one's data — that boundary belongs to Family Link, which parents
* clear this one's data, that boundary belongs to Family Link, which parents
* should use alongside it.
*
* The PIN is stored as a salted SHA-256 hash, never in the clear, and none of
* this — the PIN, the toggles, or the fact supervision is on — ever leaves
* this, the PIN, the toggles, or the fact supervision is on, ever leaves
* the device. The server learns nothing.
*/
object ParentalControls {
@@ -54,7 +54,7 @@ object ParentalControls {
prefs(context).edit()
.putString(PIN_SALT, salt.joinToString("") { "%02x".format(it) })
.putString(PIN_HASH, hash(pin, salt))
// The gates default ON — a parent turning this on wants
// The gates default ON, a parent turning this on wants
// supervision, and each is individually releasable afterwards.
.putBoolean(CONTACTS_GATED, true)
.putBoolean(STORIES_HIDDEN, true)
@@ -25,7 +25,7 @@ import androidx.compose.ui.unit.sp
/**
* Parental controls on the Account screen: a parent's PIN guarding what this
* device may do. Opening the switches requires the PIN — they would be
* device may do. Opening the switches requires the PIN, they would be
* decoration if the person they restrain could flip them.
*/
@Composable
@@ -48,7 +48,7 @@ fun ParentalControlsSection() {
Text(
"A parent sets a PIN on this phone. While supervision is on, "
+ "adding contacts asks for the PIN, and stories and calls can "
+ "be switched off — all enforced on this device. Nothing about "
+ "be switched off, all enforced on this device. Nothing about "
+ "supervision is sent to any server. Use it alongside Family "
+ "Link, which governs the phone itself.",
fontSize = 12.sp,
@@ -113,7 +113,7 @@ fun ParentalControlsSection() {
GateRow("Hide stories", ParentalControls.STORIES_HIDDEN) { tick++ }
GateRow("Turn off calls", ParentalControls.CALLS_DISABLED) { tick++ }
Text(
"With calls off, this phone neither places calls nor rings — a "
"With calls off, this phone neither places calls nor rings, a "
+ "caller is answered busy.",
fontSize = 12.sp, color = Palette.SecondaryText,
)
@@ -37,8 +37,8 @@ import androidx.compose.ui.unit.sp
/**
* Stickers, from the shipped pack.
*
* ⚠️ Tapping one SENDS it. A sticker is a whole message — there is nothing to
* add it to and nothing further to say — which is the opposite of the emoji
* Tapping one SENDS it. A sticker is a whole message, there is nothing to
* add it to and nothing further to say, which is the opposite of the emoji
* panel below, and the reason these are two controls rather than one.
*/
@Composable
@@ -87,25 +87,25 @@ fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) {
/**
* Emoji, straight into the draft.
*
* 🔴 **Not a protocol feature.** An emoji is a character: it goes into the text
* Not a protocol feature. An emoji is a character: it goes into the text
* of an ordinary message and is indistinguishable from any other character once
* sent. Nothing on the receiving end needs to understand it, which is exactly
* what makes it different from a sticker.
*
* ⚠️ The panel stays open on tap. Somebody picking three in a row expects three
* The panel stays open on tap. Somebody picking three in a row expects three
* characters in the box, and closing on each tap would make that impossible.
*
* 🔴 **Recents and grouping, matching iOS.** This was a flat grid while iOS
* kept a recents row and grouped the set the way people think about them —
* Recents and grouping, matching iOS. This was a flat grid while iOS
* kept a recents row and grouped the set the way people think about them,
* found when a duplicate iOS picker nearly shadowed the better one. Two
* platforms offering the same emoji through different furniture is the drift
* nobody files a bug about and everybody notices. The mechanics mirror the
* iOS file: recents are the last twelve picked, most recent first, in ordinary
* SharedPreferences — which emoji somebody reaches for is not a secret worth a
* SharedPreferences, which emoji somebody reaches for is not a secret worth a
* vault entry, and the vault cannot be read before sign-in anyway.
*
* ⚠️ Still a curated set, not all of Unicode. A full picker is search, skin
* tones and category tabs — a real feature the system keyboard already ships.
* Still a curated set, not all of Unicode. A full picker is search, skin
* tones and category tabs, a real feature the system keyboard already ships.
*/
@Composable
fun ColumnScope.EmojiPicker(onPick: (String) -> Unit) {
@@ -171,7 +171,7 @@ private fun EmojiSection(title: String, emoji: List<String>, onPick: (String) ->
}
/**
* The same set as iOS, grouped the same way — kept in step on purpose.
* The same set as iOS, grouped the same way, kept in step on purpose.
* If one platform's list changes, change the other in the same sitting.
*/
private val EMOJI_GROUPS = listOf(
@@ -10,21 +10,21 @@ import io.flamenet.client.LocalVault
* Files that stay where their owner put them, reached through the Storage
* Access Framework.
*
* This is the Android half of [LocalVault.ReferenceResolver] — the piece the
* This is the Android half of [LocalVault.ReferenceResolver], the piece the
* vault deliberately does not know about, so that the vault itself can be
* tested on a laptop with no emulator.
*
* 🔴 **The permission must be persisted or the row is broken tomorrow.**
* The permission must be persisted or the row is broken tomorrow.
* `ActivityResultContracts.GetContent` hands back a URI whose read grant dies
* with the process. A reference made from one works perfectly for the rest of
* the session and then fails on the next launch — the worst shape of bug, since
* the session and then fails on the next launch, the worst shape of bug, since
* whoever added the file watched it work. `OpenDocument` plus
* [takePersistablePermission] is the combination that survives a reboot, and it
* is why the picker for this screen is not the picker the chat composer uses.
*
* ⚠️ **A persisted grant is still revocable.** The owner can move the file,
* A persisted grant is still revocable. The owner can move the file,
* delete it, or clear the app's access from Settings, and the row is then
* pointing at nothing. That is allowed — it is their file — so every path here
* pointing at nothing. That is allowed, it is their file, so every path here
* reports it rather than throwing something a screen cannot phrase.
*/
class SafReferences(private val context: Context) : LocalVault.ReferenceResolver {
@@ -33,9 +33,9 @@ class SafReferences(private val context: Context) : LocalVault.ReferenceResolver
* Keep read access across relaunches.
*
* Returns false when the provider refuses to make the grant persistable,
* which some do. 🔴 The caller must NOT file a reference in that case: a row
* which some do. The caller must NOT file a reference in that case: a row
* that cannot be reopened is worse than a picker that says "this file can't
* be linked to — copy it in instead", because the first one looks like it
* be linked to, copy it in instead", because the first one looks like it
* worked.
*/
fun takePersistablePermission(uri: Uri): Boolean = try {
@@ -54,17 +54,17 @@ class SafReferences(private val context: Context) : LocalVault.ReferenceResolver
}
/**
* ⚠️ **Opening the stream, not asking the provider whether the file
* **Opening the stream, not asking the provider whether the file
* exists.** `DocumentsContract` will happily describe a document that can no
* longer be read — a cloud provider with the account signed out is the
* everyday case — and a row that says it is fine and then fails on tap is
* longer be read, a cloud provider with the account signed out is the
* everyday case, and a row that says it is fine and then fails on tap is
* the thing this whole distinction exists to avoid.
*/
override fun exists(bookmark: String): Boolean = try {
context.contentResolver.openInputStream(Uri.parse(bookmark))?.use { true } ?: false
} catch (e: Exception) {
// Deliberately broad. A provider that has gone away throws whatever it
// likes — SecurityException, FileNotFoundException, IllegalArgument,
// likes. SecurityException, FileNotFoundException, IllegalArgument,
// and on at least one vendor's provider a plain NullPointerException.
// Every one of them means the same thing to a person: it isn't there.
false
@@ -73,12 +73,12 @@ class SafReferences(private val context: Context) : LocalVault.ReferenceResolver
override fun read(bookmark: String): ByteArray =
context.contentResolver.openInputStream(Uri.parse(bookmark))?.use { it.readBytes() }
?: throw LocalVault.VaultException(
"That file isn't where it was — it may have been moved or deleted.")
"That file isn't where it was, it may have been moved or deleted.")
/**
* ⚠️ Zero when the provider will not say, which is a real answer and not a
* Zero when the provider will not say, which is a real answer and not a
* failure. `OpenableColumns.SIZE` is optional, and several providers return
* null for it — the row then shows no size rather than a wrong one.
* null for it, the row then shows no size rather than a wrong one.
*/
override fun size(bookmark: String): Int = try {
context.contentResolver.query(
@@ -20,14 +20,14 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
/**
* Point the camera at somebody's address — the other half of [AddressCard].
* Point the camera at somebody's address, the other half of [AddressCard].
*
* 🔴 **Nothing here trusts what it read.** A QR code is a string an arbitrary
* stranger controls, so every scan goes through [parseScannedAddress] — the
* Nothing here trusts what it read. A QR code is a string an arbitrary
* stranger controls, so every scan goes through [parseScannedAddress], the
* same refusals as iOS's `ContactCard.decode`: a relay is a hostname, never a
* URL, and a code that is not an address is ignored rather than shown as one.
*
* ⚠️ The camera pipeline is WebRTC's own — [org.webrtc.Camera2Capturer] into a
* The camera pipeline is WebRTC's own, [org.webrtc.Camera2Capturer] into a
* [org.webrtc.SurfaceViewRenderer], exactly the capture stack video calls
* already ship. The only new code is handing each frame's Y plane to the QR
* decoder; pulling in a second camera framework to look for a square would be
@@ -39,7 +39,7 @@ internal data class ScannedAddress(val fnid: String, val relay: String, val hand
/**
* `flamenet:<fnid>@<relay>[?u=<handle>&k=…]`, or the bare `fnid@relay`
* somebody retypes. Refuses anything else rather than guessing — an address
* somebody retypes. Refuses anything else rather than guessing, an address
* that is nearly right addresses somebody else.
*/
internal fun parseScannedAddress(text: String): ScannedAddress? {
@@ -78,7 +78,7 @@ fun ScanAddressDialog(onFound: (String) -> Unit, onDismiss: () -> Unit) {
) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
if (!granted) {
// ⚠️ Says what to do, not just that it failed. Once refused,
// Says what to do, not just that it failed. Once refused,
// only Android settings can change the answer.
Column(modifier = Modifier.align(Alignment.Center).padding(24.dp)) {
Text(
File diff suppressed because it is too large Load Diff
@@ -8,10 +8,10 @@ import java.net.URI
* 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
* - 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
* - 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(
@@ -19,8 +19,8 @@ data class SiteConfig(
/**
* What the relay said about itself, or null until it has answered.
*
* ⚠️ **A constructor property on purpose.** It used to be a `var` in the
* class body, which left it out of the generated `equals()` — and
* A constructor property on purpose. It used to be a `var` in the
* class body, which left it out of the generated `equals()`, and
* `MutableStateFlow` conflates on equality, so writing back a config that
* differed ONLY by its descriptor was silently dropped. The relay was
* asked, it answered, and the answer went nowhere: the sign-in screen sat
@@ -32,10 +32,10 @@ data class SiteConfig(
/**
* The probe was made and came back with nothing.
*
* 🔴 **Without this, "still asking" and "asked and could not reach it" are
* **Without this, "still asking" and "asked and could not reach it" are
* the same state.** `discovered` is null in both, so a relay that never
* answers leaves "Checking what this server uses…" on screen forever and
* the form permanently unusable — no error, no retry, nothing to act on.
* the form permanently unusable, no error, no retry, nothing to act on.
* Seen 2026-09-05 pointing the app at a plain-HTTP relay, which Android
* blocks outright from API 28: the request never left, and the screen said
* it was still thinking about it.
@@ -47,7 +47,7 @@ data class SiteConfig(
/** The shared FlameNet account service. One credential across products. */
SHARED,
/** The relay's own account table — what keeps self-hosting one service, not two. */
/** The relay's own account table, what keeps self-hosting one service, not two. */
LOCAL,
}
@@ -57,8 +57,8 @@ data class SiteConfig(
/**
* 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,
* 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? {
@@ -81,7 +81,7 @@ data class SiteConfig(
/**
* Where this relay's accounts live.
*
* ⚠️ **Asked of the relay, except for the relays that are ours.** For
* Asked of the relay, except for the relays that are ours. For
* flamenet.io and its aliases the account service is pinned in the client
* and what they advertise is ignored, so compromising one cannot redirect
* its users' derived secrets. Every other relay is believed: the user typed
@@ -108,7 +108,7 @@ data class SiteConfig(
val identityDescription: String
get() = when {
probeFailed ->
"Couldn't reach that server. Check the address — and note it has " +
"Couldn't reach that server. Check the address, and note it has " +
"to be https, not http."
discovered == null -> "Checking what this server uses…"
accountProvider == AccountProvider.SHARED ->
@@ -6,14 +6,14 @@ import org.json.JSONObject
/**
* The shipped sticker set, read from the same manifest the iOS app reads.
*
* 🔴 **One manifest, both clients.** `Resources/stickers/flamenet.core.json` is
* One manifest, both clients. `Resources/stickers/flamenet.core.json` is
* copied into Android's assets; neither side hard-codes the list. The only
* protocol bug this project has actually shipped was a client and a server
* disagreeing about a field name with nothing to check it against, and a
* sticker set defined twice is the same shape of mistake waiting to happen.
*
* ⚠️ **An unknown id resolves to the fallback, never to nothing.** A pack the
* receiver does not have — an older build, a pack added later — must still draw
* An unknown id resolves to the fallback, never to nothing. A pack the
* receiver does not have, an older build, a pack added later, must still draw
* something. A sticker that renders as an empty bubble is worse than one that
* renders as the wrong face.
*/
@@ -36,8 +36,8 @@ object StickerPack {
/**
* What to draw for a reference.
*
* Returns the fallback for anything unrecognised — a different pack, an id
* from a newer build — because the alternative is a blank bubble.
* Returns the fallback for anything unrecognised, a different pack, an id
* from a newer build, because the alternative is a blank bubble.
*/
fun glyph(context: Context, pack: String, id: String): String {
ensure(context)
@@ -53,8 +53,8 @@ object StickerPack {
private fun ensure(context: Context) {
if (loaded != null) return
// ⚠️ Failure is empty, not a crash. A missing or malformed asset should
// cost the picker, not the app — and the fallback above keeps every
// Failure is empty, not a crash. A missing or malformed asset should
// cost the picker, not the app, and the fallback above keeps every
// sticker somebody has already been sent renderable regardless.
val text = runCatching {
context.assets.open("stickers/$CORE.json").bufferedReader().use { it.readText() }
@@ -45,14 +45,14 @@ import androidx.compose.runtime.setValue
/**
* Stories as a feed you scroll, which is the kit's Timeline.
*
* 🔴 **A screen, not a strip.** Stories were a horizontal row of circles on the
* Messages screen — enough to tell you something exists and nothing more. The
* A screen, not a strip. Stories were a horizontal row of circles on the
* Messages screen, enough to tell you something exists and nothing more. The
* kit's Timeline is a column of full-width cards: author, image, caption. A
* card feed cannot sit above a contact list without pushing it off the phone,
* so it gets a tab. The strip stays on Messages as the entry point.
*
* ⚠️ **No like or comment bar, and the kit has one.** Its post card carries two
* emoji reactions and a share icon; a story here has none of those — there is
* No like or comment bar, and the kit has one. Its post card carries two
* emoji reactions and a share icon; a story here has none of those, there is
* no comment thread, no reaction on a story, and nothing to share it to. Drawing
* the bar and wiring it to nothing would be a lie told in pixels. What a story
* DOES have that the kit's post does not is a deadline, so that is what the
@@ -85,10 +85,10 @@ fun ColumnScope.StoriesFeed(
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.weight(1f),
)
// 🔴 A menu, not a picker.
// A menu, not a picker.
//
// This went straight to the file chooser, so there was no way to reach
// anything else — a story of words was unreachable even once it
// anything else, a story of words was unreachable even once it
// existed, for the same reason it was on iOS.
var choosing by remember { mutableStateOf(false) }
Box {
@@ -165,7 +165,7 @@ private fun StoryCard(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
) {
// The author's actual picture when one is held — your own comes
// The author's actual picture when one is held, your own comes
// from myAvatar, a contact's from the avatar map. Initials remain
// the normal case for anyone who never set one.
Avatar(
@@ -177,7 +177,7 @@ private fun StoryCard(
)
Column(modifier = Modifier.weight(1f)) {
Text(author, fontSize = 14.sp, fontWeight = FontWeight.Medium)
// ⚠️ The deadline, in the slot the kit gives a description.
// The deadline, in the slot the kit gives a description.
// It is the one fact about a story that changes while you are
// looking at it, and the whole point of the feature.
Text(remaining, fontSize = 12.sp, color = Palette.SecondaryText)
@@ -206,10 +206,10 @@ private fun StoryCard(
.background(Palette.SurfaceAlt),
contentAlignment = Alignment.Center,
) {
// 🔴 The card's own image, cropped to FILL the box — not the chat
// The card's own image, cropped to FILL the box, not the chat
// bubble's 260dp thumbnail, which floated small on a full-width
// backboard and read as a broken layout.
// §6.13 — an embedded live stream. The gate comes FIRST, because
// §6.13, an embedded live stream. The gate comes FIRST, because
// tapping play is what connects this device to the stream host; a
// card that started playing on its own would disclose that after
// the fact, which is not a disclosure. `ref` is the poster.
@@ -221,7 +221,7 @@ private fun StoryCard(
} else {
val ref = story.ref
when {
// 🔴 Words alone, set large on a tinted band — the way
// Words alone, set large on a tinted band, the way
// every other messenger draws one. A text story rendered
// as a caption under an empty rectangle reads as a picture
// that failed to load.
@@ -245,18 +245,18 @@ private fun StoryCard(
/**
* React, reply, or stop seeing somebody's stories.
*
* 🔴 A fixed row of five, not a picker. A story is glanced at and scrolled
* A fixed row of five, not a picker. A story is glanced at and scrolled
* past; anything that takes two taps to say "nice" does not get used, and the
* feature is only worth having if it is faster than typing.
*
* ⚠️ Your OWN story gets the tally instead — you cannot react to yourself, and
* Your OWN story gets the tally instead, you cannot react to yourself, and
* a row of buttons that do nothing is worse than no row.
*/
@Composable
private fun StoryActions(
story: Vault.Story,
model: MessengerViewModel,
// ⚠️ Passed in rather than resolved here: `StoryCard` already has the
// Passed in rather than resolved here: `StoryCard` already has the
// buddy list and has already done this, and two resolutions of the same
// name are two chances to disagree about who is being reported.
authorName: String = "",
@@ -306,14 +306,14 @@ private fun StoryActions(
Spacer(Modifier.size(12.dp))
Text("Mute", fontSize = 13.sp, color = Palette.Mist,
modifier = Modifier.clickable { muting = true }.testTag("story-mute"))
// 🔴 **Reporting a story, which Android could not do.** iOS has had it
// Reporting a story, which Android could not do. iOS has had it
// since stories shipped; here `MessengerViewModel.reportStory` was
// written, tested by nothing, and called by no screen. A capability
// with no way in is, for Apple's Guideline 1.2 and Play's UGC policy,
// the same as no capability — and a story is the most public thing
// the same as no capability, and a story is the most public thing
// this app carries, so it is the surface those rules are aimed at.
//
// ⚠️ Not on your own story. Reporting yourself to the operator is not
// Not on your own story. Reporting yourself to the operator is not
// something anybody means to do, and offering it invites a misfire.
if (!story.mine) {
Spacer(Modifier.size(12.dp))
@@ -324,9 +324,9 @@ private fun StoryActions(
}
if (reporting) {
// ⚠️ The SAME sheet a message report uses, deliberately. Two sheets
// The SAME sheet a message report uses, deliberately. Two sheets
// would be two places for the consent wording to drift apart, and the
// consent — that sending a copy hands the operator something readable —
// consent, that sending a copy hands the operator something readable,
// is the part that matters.
ReportSheet(
authorName = authorName.ifBlank { "this person" },
@@ -348,8 +348,8 @@ private fun StoryActions(
AlertDialog(
onDismissRequest = { replying = false },
title = { Text("Reply privately") },
// 🔴 Says where it goes. A reply that looked like a comment under
// the story — readable by whoever else saw it — would be a privacy
// Says where it goes. A reply that looked like a comment under
// the story, readable by whoever else saw it, would be a privacy
// surprise in the wrong direction.
text = {
Column {
@@ -378,7 +378,7 @@ private fun StoryActions(
AlertDialog(
onDismissRequest = { muting = false },
title = { Text("Mute their stories?") },
// ⚠️ Says what muting is NOT. People reach for it expecting
// Says what muting is NOT. People reach for it expecting
// something closer to blocking, and are owed the difference before
// they use it rather than after.
text = {
@@ -401,7 +401,7 @@ private fun StoryActions(
/**
* Caption + a deliberate Post for a picked story image, at the shell level so
* whichever screen picked it (the strip or the Stories tab) gets the same
* sheet. Picking used to post instantly — no preview, no caption, no way back.
* sheet. Picking used to post instantly, no preview, no caption, no way back.
*/
@Composable
fun StoryComposerDialog(
@@ -459,9 +459,9 @@ fun StoryComposerDialog(
modifier = Modifier.fillMaxWidth().testTag("story-caption"),
)
// Who THIS story goes to. The audience decides who is SENT
// the bytes — there is no relay copy to deny anyone later —
// the bytes, there is no relay copy to deny anyone later,
// so the choice belongs here, before posting. Seeded from the
// §6.13 — attach a live stream. The picked picture becomes its
// §6.13, attach a live stream. The picked picture becomes its
// poster: what people see before deciding whether to connect.
androidx.compose.material3.OutlinedTextField(
value = streamUrl.value,
@@ -472,14 +472,14 @@ fun StoryComposerDialog(
modifier = Modifier.fillMaxWidth().testTag("story-stream-field"),
)
if (streamUrl.value.isNotBlank()) {
// 🔴 Says what a stream costs the people watching, at the
// Says what a stream costs the people watching, at the
// moment of attaching one. Everything else in a story is
// sealed; this is the one thing that sends viewers to
// somebody else's server.
Text(
if (streamOk) {
"Your picture becomes the cover. Anyone who taps play connects " +
"to that site, which will see their IP address — they are " +
"to that site, which will see their IP address, they are " +
"told before it happens."
} else {
"That needs to be a full http:// or https:// link with a site " +
@@ -549,7 +549,7 @@ fun StoryComposerDialog(
streamUrl.value,
)
},
// ⚠️ Refuses to post a stream URL it could not disclose. A
// Refuses to post a stream URL it could not disclose. A
// destination with no host cannot be named, and a viewer cannot
// consent to one they cannot be shown.
enabled = streamOk,
@@ -566,7 +566,7 @@ fun StoryComposerDialog(
/**
* The story cards themselves — everyone's stories, or one honest message when
* The story cards themselves, everyone's stories, or one honest message when
* there are none. Shared by the Stories tab and the Home screen, so the two
* renderings cannot drift.
*/
@@ -591,7 +591,7 @@ fun ColumnScope.StoriesCards(
)
Text("Nothing here yet", fontSize = 16.sp, fontWeight = FontWeight.Medium)
Text(
"A story goes to your contacts and disappears after a day — on "
"A story goes to your contacts and disappears after a day, on "
+ "your device and theirs, because their app deletes it too. "
+ "It is not a secret you can take back.",
fontSize = 14.sp,
@@ -611,7 +611,7 @@ fun ColumnScope.StoriesCards(
/**
* A story's image at card size: fills the box, cropped. The chat bubble's
* `AttachedImage` deliberately caps at 260dp and letterboxes — right for a
* `AttachedImage` deliberately caps at 260dp and letterboxes, right for a
* bubble, wrong for the kit's full-width post card.
*/
/** A story's video, fetched, decrypted, and looping in the card. */
@@ -665,7 +665,7 @@ private fun StoryImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerView
/**
* A story that is only words.
*
* Set large and centred on a tinted band, matching iOS — a text story drawn as
* Set large and centred on a tinted band, matching iOS, a text story drawn as
* a caption under an empty grey rectangle reads as a picture that failed to
* load, which is the opposite of what the sender meant.
*/
@@ -30,8 +30,8 @@ import java.io.File
*
* Videos are validated and scrubbed, not transcoded: this platform has no
* built-in exporter the way AVFoundation is one, and pulling in a transcoding
* stack is a decision for later. So the caps are enforced honestly — a clip too
* long or too heavy is refused with the reason — and a clip that fits is
* stack is a decision for later. So the caps are enforced honestly, a clip too
* long or too heavy is refused with the reason, and a clip that fits is
* remuxed to drop the container metadata before it goes out. See [MediaScrub];
* "exactly as picked" used to include the capture location.
*/
@@ -45,7 +45,7 @@ object StoryMedia {
val rotation: Int,
)
/** A story is a moment, not a film — same cap as iOS. */
/** A story is a moment, not a film, same cap as iOS. */
const val MAX_DURATION_S = 30
/** Under the relay's 10 MB attachment cap, with room for the seal. */
const val MAX_BYTES = 9_500_000
@@ -75,7 +75,7 @@ object StoryMedia {
?.toIntOrNull() ?: 0
// A portrait phone clip is stored landscape plus a rotation.
if (rotation == 90 || rotation == 270) { val t = w; w = h; h = t }
// ⚠️ The rotation is carried out of here now, not just used to
// The rotation is carried out of here now, not just used to
// swap the dimensions: rebuilding the container to strip its
// metadata drops the orientation with everything else, and a
// portrait story would play on its side.
@@ -89,7 +89,7 @@ object StoryMedia {
val (seconds, w, h, rotation) = meta
if (seconds > MAX_DURATION_S + 0.5) {
model.reportStoryProblem(
"Stories are capped at $MAX_DURATION_S seconds — this one is "
"Stories are capped at $MAX_DURATION_S seconds, this one is "
+ "${seconds.toInt()}. Trim it and try again.")
return
}
@@ -100,7 +100,7 @@ object StoryMedia {
model.reportStoryProblem("That video couldn't be read.")
return
}
// 🔴 **Where it was shot does not go with it.** See [MediaScrub].
// Where it was shot does not go with it. See [MediaScrub].
// A failure here refuses the post rather than falling back to the
// original: "we could not remove the location, so we sent it
// anyway" is the one outcome this must never produce.
@@ -111,7 +111,7 @@ object StoryMedia {
}
if (bytes.size > MAX_BYTES) {
model.reportStoryProblem(
"That video is too large to send — the cap is about 9 MB. "
"That video is too large to send, the cap is about 9 MB. "
+ "A shorter or lower-resolution clip will fit.")
return
}
@@ -126,9 +126,9 @@ object StoryMedia {
model.reportStoryProblem("That file couldn't be read.")
return
}
// 🔴 **Prepared, not posted as picked.** A story photo was the one
// Prepared, not posted as picked. A story photo was the one
// image path in the app that never went through [ImagePrep], so it was
// uploaded with its EXIF block intact — including, for anything shot on
// uploaded with its EXIF block intact, including, for anything shot on
// the phone, the GPS coordinates. Chat photos have always been
// re-encoded and were safe by consequence; a story, which is shown to
// every contact at once, was not.
@@ -159,7 +159,7 @@ object StoryMedia {
/**
* Plays a decrypted story video, looping, with sound.
*
* Framework `VideoView` on purpose — playback of one local file needs no
* Framework `VideoView` on purpose, playback of one local file needs no
* player library. The decrypted bytes land in the app's cache dir, which is
* the same exposure as a decoded story bitmap in memory plus the OS's own
* cache lifetime.
@@ -194,10 +194,10 @@ fun StoryVideoPlayer(bytes: ByteArray, cacheKey: String, modifier: Modifier = Mo
/**
* The consent gate in front of an embedded live stream (spec §6.13).
*
* 🔴 **Watching connects this device to the stream host.** A live stream is
* Watching connects this device to the stream host. A live stream is
* continuous and hosted elsewhere, so unlike a link preview there is no
* sender-side trick that avoids it: the URL is hidden from the relay, but the
* connection is not hidden from whoever serves the stream — they learn the
* connection is not hidden from whoever serves the stream, they learn the
* viewer's IP address and that they watched.
*
* So playback is an explicit tap with the host named, never autoplay. A story
@@ -9,10 +9,10 @@ import android.provider.MediaStore
/**
* Writing one of your own stories out to the device gallery (ios#34).
*
* ⚠️ **MediaStore, so no storage permission is asked for.** An app writing its
* MediaStore, so no storage permission is asked for. An app writing its
* own entry through `MediaStore` needs no runtime grant on any version this app
* supports. Asking for `WRITE_EXTERNAL_STORAGE` in order to save one picture
* would request read access to everything the person has, to perform a write —
* would request read access to everything the person has, to perform a write,
* a much larger ask than the feature.
*/
object StorySave {
@@ -22,34 +22,34 @@ import androidx.compose.ui.unit.sp
/**
* The Flamenet design system.
*
* 🔴 **Taken from the design kit, measured out of the Sketch file.** This
* Taken from the design kit, measured out of the Sketch file. This
* comment used to say the palette came from flamenetmessenger.com and quoted
* `--flame: #ff5a1f` — which had already been retired from the public pages,
* `--flame: #ff5a1f`, which had already been retired from the public pages,
* so the app was carrying a brand colour the brand no longer used, on the
* authority of a comment nobody rechecked. The values here are read from the
* kit's own symbols; see `Palette`.
*
* 🔴 **One palette, two apps.** iOS carries the same values under the same
* One palette, two apps. iOS carries the same values under the same
* names in `Sources/Theme/Theme.swift`. This file's comment used to claim the
* two were already matched — while iOS was indigo and this was ember, so the
* two were already matched, while iOS was indigo and this was ember, so the
* claim was simply false and nobody noticed because nothing checks it.
* **Change a value here and change it there in the same commit.**
* Change a value here and change it there in the same commit.
*
* The look: a light cool-navy ground, white cards with a soft shadow and a
* generous corner radius, one confident accent, quiet neutrals, and space
* between things. Screen structure and every `testTag` are deliberately
* untouched — this is a skin over working screens.
* untouched, this is a skin over working screens.
*/
object Palette {
// 🔴 **The design kit's palette, measured out of the file** — not sampled
// The design kit's palette, measured out of the file, not sampled
// from a render and not approximated. The text colours below are the two
// that actually appear in its symbols: #090710 on 165 text layers and
// #505C69 on 164. The accent is the one it uses at 12pt as well as on
// buttons, so it is their link colour, not only their fill.
//
// ⚠️ These names used to say Ember, and held orange. The orange was the
// 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
// These names used to say Ember, and held orange. The orange was the
// 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
@@ -63,7 +63,7 @@ object Palette {
val Mist = Color(0xFFF9F9F9) // the page ground
val SurfaceAlt = Color(0xFFF3F3F3) // received bubbles, pills, bands
val IncomingBubble = Color(0xFFF3F3F3)
// ⚠️ A blue-tinted grey rather than the flat #F3F3F3: an initials avatar
// 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)
@@ -75,7 +75,7 @@ object Palette {
val Warning = Color(0xFFB8860B)
val Danger = Color(0xFFED497C)
// Dark-mode grounds — these ARE the site's, since the site is dark.
// Dark-mode grounds, these ARE the site's, since the site is dark.
val InkSurface = Color(0xFF121A2E) // --panel
val NightGround = Color(0xFF0A0E1A) // --bg
}
@@ -92,8 +92,8 @@ object Radius {
/**
* Measurements taken from the design kit, in the kit's own terms.
*
* 🔴 **Insets, never fixed widths.** The kit is drawn on a 375pt canvas and its
* numbers are absolute — a 327-wide row is 375 minus two 24pt margins. Copying
* Insets, never fixed widths. The kit is drawn on a 375pt canvas and its
* numbers are absolute, a 327-wide row is 375 minus two 24pt margins. Copying
* 327 across would break the layout on every phone that is not an iPhone X.
* Everything here is an inset or an intrinsic size, with the width left to the
* layout. See `DESIGN.md`, which both apps read from.
@@ -137,23 +137,23 @@ private val FlamenetShapes = Shapes(
/**
* The type scale.
*
* ⚠️ Sizes only — no `fontFamily`. Bundling a font would need the licence to
* Sizes only, no `fontFamily`. Bundling a font would need the licence to
* cover redistribution, and the platform default already reads well at these
* sizes. If a face is chosen later it goes here and in the iOS `Theme.Font`.
*/
/**
* The design kit's type scale, applied globally.
*
* 🔴 **This is the single biggest reason the app did not look like the kit.**
* This is the single biggest reason the app did not look like the kit.
* Material 3's defaults are considerably larger than the kit's, and every
* screen inherits them — so a section heading rendered at 24sp where the kit
* screen inherits them, so a section heading rendered at 24sp where the kit
* says 16, and a body line at 16 where the kit says 14. Restyling individual
* screens against those defaults is pushing against the whole theme; setting
* the scale here fixes every screen at once, including ones nobody has
* touched yet.
*
* Names on the left are Material's; sizes on the right are the kit's. See
* `DESIGN.md` — H1 40, H2 32, H3 24, H4 20, Sub 16/14/12, Body 16/14,
* `DESIGN.md`. H1 40, H2 32, H3 24, H4 20, Sub 16/14/12, Body 16/14,
* Description 12/10.
*/
private val FlamenetTypography = Typography().let { base ->
@@ -162,7 +162,7 @@ private val FlamenetTypography = Typography().let { base ->
headlineMedium = base.headlineMedium.copy(fontSize = 24.sp, fontWeight = FontWeight.Bold),
headlineSmall = base.headlineSmall.copy(fontSize = 20.sp, fontWeight = FontWeight.Bold),
titleLarge = base.titleLarge.copy(fontSize = 20.sp, fontWeight = FontWeight.Bold),
// Sub 3 — what a section label is, not a headline.
// Sub 3, what a section label is, not a headline.
titleMedium = base.titleMedium.copy(fontSize = 16.sp, fontWeight = FontWeight.Medium),
titleSmall = base.titleSmall.copy(fontSize = 14.sp, fontWeight = FontWeight.Medium),
bodyLarge = base.bodyLarge.copy(fontSize = 16.sp),
@@ -170,7 +170,7 @@ private val FlamenetTypography = Typography().let { base ->
bodySmall = base.bodySmall.copy(fontSize = 12.sp),
labelLarge = base.labelLarge.copy(fontSize = 14.sp, fontWeight = FontWeight.Medium),
labelMedium = base.labelMedium.copy(fontSize = 12.sp),
// Description 2 — the timestamp above a bubble.
// Description 2, the timestamp above a bubble.
labelSmall = base.labelSmall.copy(fontSize = 10.sp, letterSpacing = 0.sp,
fontWeight = FontWeight.Normal),
)
@@ -181,7 +181,7 @@ private val FlamenetTypography = Typography().let { base ->
*
* A modifier rather than a component so it can wrap the existing sections
* without restructuring them. The look depends on every card sharing one
* depth — resist giving any single card its own elevation.
* depth, resist giving any single card its own elevation.
*/
fun Modifier.flamenetCard(
padding: PaddingValues = PaddingValues(Space.lg),
@@ -192,7 +192,7 @@ fun Modifier.flamenetCard(
@Composable
fun MessengerTheme(content: @Composable () -> Unit) {
// ⚠️ Read here rather than passed in, so every entry point picks it up —
// 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.
@@ -220,7 +220,7 @@ fun MessengerTheme(content: @Composable () -> Unit) {
)
} else {
lightColorScheme(
// ⚠️ The kit's #2298FF on white is about 3.3:1 — fine for a
// 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.
@@ -7,13 +7,13 @@ import java.io.File
/**
* Recording and playing voice notes.
*
* Platform APIs only — `MediaRecorder` and `MediaPlayer` — because a voice note
* Platform APIs only, `MediaRecorder` and `MediaPlayer`, because a voice note
* is an ordinary encrypted attachment once it is bytes, and everything below
* this file already handles that. Nothing here knows about the relay.
*
* AAC in an MP4 container (`audio/mp4`): playable by the other clients without
* a codec conversation, and small enough that a minute of speech is tens of
* kilobytes rather than megabytes — which matters when every byte is uploaded
* kilobytes rather than megabytes, which matters when every byte is uploaded
* encrypted and stored by somebody paying for it.
*/
object VoiceNote {
@@ -32,7 +32,7 @@ object VoiceNote {
/**
* A recording in progress.
*
* ⚠️ **Holds a file the caller must dispose of.** Both [stop] and [cancel]
* Holds a file the caller must dispose of. Both [stop] and [cancel]
* delete it; a path that does neither leaves plaintext audio in the cache
* directory, which is exactly the thing this product is not supposed to do.
*/
@@ -44,7 +44,7 @@ object VoiceNote {
/**
* Stop, and return the bytes.
*
* ⚠️ **`MediaRecorder.stop()` throws if it never captured a frame** —
* `MediaRecorder.stop()` throws if it never captured a frame,
* a tap rather than a hold, or a permission that was granted but is
* being denied by a policy. The exception is `RuntimeException` with no
* useful message, and it reads as "recording is broken" when the truth
@@ -76,7 +76,7 @@ object VoiceNote {
recorder.stop()
} catch (_: RuntimeException) {
// Never started, or stopped with no frames. Nothing to salvage
// and nothing to report — this path is the user changing their
// and nothing to report, this path is the user changing their
// mind, not a failure.
} finally {
recorder.release()
@@ -94,7 +94,7 @@ object VoiceNote {
/**
* Start recording into the cache directory.
*
* @throws IllegalStateException when the microphone is unavailable — most
* @throws IllegalStateException when the microphone is unavailable, most
* often because `RECORD_AUDIO` has not been granted. The caller asks for
* the permission first; this does not, because a library that raises
* dialogs is a library that raises them at the wrong moment.
@@ -126,9 +126,9 @@ object VoiceNote {
/**
* Play decrypted bytes.
*
* ⚠️ Writes them to a file first, because `MediaPlayer` cannot be handed a
* Writes them to a file first, because `MediaPlayer` cannot be handed a
* byte array. The file lives in the cache and is deleted when playback
* ends or is stopped — decrypted audio sitting on disk after the user has
* ends or is stopped, decrypted audio sitting on disk after the user has
* finished with it is a leak, however briefly.
*/
class Player(private val cacheDir: File) {
@@ -5,13 +5,13 @@ import android.content.Context
/**
* Which chain node the wallet reads from.
*
* ⚠️ **Pointing at a node is a privacy decision, and the app says so rather
* **Pointing at a node is a privacy decision, and the app says so rather
* than choosing quietly.** Asking a public RPC endpoint what an address holds
* ties that address to this device's IP in somebody else's logs — every time
* ties that address to this device's IP in somebody else's logs, every time
* the screen refreshes. Running your own node is the only answer that removes
* it. The default exists so the wallet works at all, not because it is private.
*
* ⚠️ **https only.** A balance read over plain http is one anybody on the path
* https only. A balance read over plain http is one anybody on the path
* can rewrite, and a wallet showing a number an attacker chose is worse than a
* wallet showing nothing. A configured http endpoint is ignored rather than
* honoured, and the screen falls back to the default.
@@ -49,7 +49,7 @@ object WalletChain {
/**
* What a transfer costs, and what must stay behind.
*
* 🔴 **An account below the rent-exempt minimum is reclaimed by the
* **An account below the rent-exempt minimum is reclaimed by the
* network.** Emptying a wallet to the last lamport does not leave it empty;
* it deletes it. Anything offering to send "everything" has to stop here,
* and the two numbers are named rather than buried in an expression so that
@@ -39,13 +39,13 @@ import java.util.Locale
/**
* The wallet.
*
* 🔴 **A balance this app could not read is drawn as an em dash, never as
* **A balance this app could not read is drawn as an em dash, never as
* zero.** Showing 0.00 because a node did not answer tells somebody their money
* is gone — the single worst sentence this screen can say, and one it would say
* is gone, the single worst sentence this screen can say, and one it would say
* on every flaky connection. Null is a different fact from empty and is drawn
* as one.
*
* ⚠️ **Both cards are always here once a wallet exists**, empty or not. A
* Both cards are always here once a wallet exists, empty or not. A
* screen that hides the balance until it has one, and hides the history until
* there is one, is a screen that looks broken exactly when somebody is looking
* for reassurance that nothing went wrong.
@@ -73,8 +73,8 @@ fun ColumnScope.WalletScreen(
return@Column
}
// 🔴 The phrase card sits ABOVE everything about the chain. Somebody
// who has just made a wallet has one job — write these words down — and
// The phrase card sits ABOVE everything about the chain. Somebody
// who has just made a wallet has one job, write these words down, and
// putting a balance in front of it invites them to skip it and come
// back later, which for most people means never.
wallet.phrase?.let { phrase -> PhraseCard(phrase, model, clipboard) }
@@ -105,12 +105,12 @@ private fun ColumnScope.NoWallet(model: MessengerViewModel) {
Card {
Text("No wallet yet", fontWeight = FontWeight.Medium, fontSize = 16.sp)
Spacer(Modifier.height(Space.xs))
// ⚠️ Says what a wallet here IS before offering to make one. The two
// Says what a wallet here IS before offering to make one. The two
// facts that surprise people later are that it has nothing to do with
// their Flamenet account, and that the phrase is the only backup.
Text(
"A wallet on this phone, with keys only you hold. It is not connected "
+ "to your Flamenet account — nobody can work out one from the other — "
+ "to your Flamenet account, nobody can work out one from the other, "
+ "and the recovery phrase is the only way back if this phone is "
+ "erased or lost.",
fontSize = 13.sp,
@@ -145,7 +145,7 @@ private fun ColumnScope.NoWallet(model: MessengerViewModel) {
}
/**
* 🔴 **Shown once, and the screen says what that means.** The words are the
* Shown once, and the screen says what that means. The words are the
* whole wallet: anybody who reads them owns it, and anybody who loses them
* loses it. Both halves have to be said at the same moment, because saying only
* the first makes people hide it and saying only the second makes them
@@ -164,7 +164,7 @@ private fun ColumnScope.PhraseCard(
"Write these words down on paper, in order, and keep them somewhere "
+ "safe. Anybody who has them can spend from this wallet. If this "
+ "phone is erased or lost and you do not have them, the wallet is "
+ "gone — nobody can recover it for you, including us.",
+ "gone, nobody can recover it for you, including us.",
fontSize = 13.sp,
color = Palette.SecondaryText,
)
@@ -176,7 +176,7 @@ private fun ColumnScope.PhraseCard(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(Radius.small))
// ⚠️ `Mist`, not `SurfaceAlt`: the card is already SurfaceAlt,
// `Mist`, not `SurfaceAlt`: the card is already SurfaceAlt,
// and two identical greys would leave the words looking like
// body text rather than the one thing on this screen worth
// copying onto paper.
@@ -188,9 +188,9 @@ private fun ColumnScope.PhraseCard(
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
KitSecondaryButton(
text = "Copy",
// ⚠️ Copying puts the phrase on a clipboard every other app can
// Copying puts the phrase on a clipboard every other app can
// read. Offered because people will otherwise photograph the
// screen, which is worse and permanent — but it is the second
// screen, which is worse and permanent, but it is the second
// option, not the first, and paper is what the text asks for.
onClick = { clipboard.setText(AnnotatedString(phrase)) },
modifier = Modifier.weight(1f),
@@ -223,17 +223,17 @@ private fun ColumnScope.BalanceCard(
}
Spacer(Modifier.height(Space.xs))
Text(
// 🔴 An em dash, never "0.00". Null means this app could not read
// An em dash, never "0.00". Null means this app could not read
// the balance; zero means the account is empty. Drawing the first
// as the second tells somebody their money is gone.
wallet.lamports?.let { "${WalletChain.sol(it)} SOL" } ?: "—",
wallet.lamports?.let { "${WalletChain.sol(it)} SOL" } ?: ", ",
fontSize = 28.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.testTag("wallet-balance"),
)
if (wallet.lamports == null && !wallet.busy) {
Text(
"Not read yet. Nothing has been lost — this is what the app knows, "
"Not read yet. Nothing has been lost, this is what the app knows, "
+ "not what the wallet holds.",
fontSize = 12.sp,
color = Palette.SecondaryText,
@@ -260,12 +260,12 @@ private fun ColumnScope.ReceiveCard(
Text("Receive at", fontSize = 13.sp, color = Palette.SecondaryText)
Spacer(Modifier.height(Space.xs))
Text(
wallet.address ?: "—",
wallet.address ?: ", ",
fontSize = 13.sp,
modifier = Modifier.fillMaxWidth().testTag("wallet-address"),
)
Spacer(Modifier.height(Space.xs))
// ⚠️ Says the address changes BEFORE somebody saves the old one
// Says the address changes BEFORE somebody saves the old one
// somewhere. A fresh address per payment is the one linkability a
// wallet can prevent, and an address that quietly changed would look
// like a bug to anybody who had written it down.
@@ -330,7 +330,7 @@ private fun ActivityRow(entry: io.flamenet.client.ChainRpc.Entry) {
overflow = TextOverflow.Ellipsis,
)
Text(
// ⚠️ "Just now" for a transaction the node has not dated yet.
// "Just now" for a transaction the node has not dated yet.
// It has it and has not assigned a block time; showing 1970
// would be the epoch leaking onto somebody's screen.
entry.blockTimeSeconds?.let {
@@ -341,7 +341,7 @@ private fun ActivityRow(entry: io.flamenet.client.ChainRpc.Entry) {
color = Palette.SecondaryText,
)
}
// 🔴 A failed transaction says so. Not showing it would have somebody
// A failed transaction says so. Not showing it would have somebody
// believe a payment went through.
if (entry.failed) {
Text("Failed", fontSize = 12.sp, color = Palette.Pink,
@@ -7,21 +7,21 @@ import kotlin.test.assertTrue
/**
* Slowing down somebody who keeps trying (ios#41, Android half).
*
* 🔴 **Android had the lock and none of the back-off.** iOS has counted failed
* Android had the lock and none of the back-off. iOS has counted failed
* unlocks and imposed a growing wait since ios#41; here every attempt was free,
* so somebody holding an unlocked phone could work through PINs as fast as the
* prompt would reappear. Two platforms disagreeing about a security guarantee is
* how one of them ends up being the one that leaks.
*
* 🔴 **What this defends and what it does not.** It slows a person holding the
* phone. It is not a defence of the data — the vault and the ratchet keys are
* What this defends and what it does not. It slows a person holding the
* phone. It is not a defence of the data, the vault and the ratchet keys are
* protected by the Android keystore, exactly as `AppLock`'s own note says, and
* no amount of back-off changes that.
*
* The two ends of the schedule are what matter, and both are tested: an
* ordinary slip costs nothing, and the wait has a ceiling.
*
* ⚠️ The schedule is a pure function, so it is tested without a `Context`. The
* The schedule is a pure function, so it is tested without a `Context`. The
* parts that need SharedPreferences are exercised on device in
* `CrudSurfaceTest`; putting the arithmetic behind an instrumented test would
* have meant the rule nobody could check quickly was the rule that decides
@@ -32,7 +32,7 @@ class AppLockBackoffTest {
// ---- The schedule ---------------------------------------------------
/**
* ⚠️ Three, not one. A mistyped PIN or a fingerprint that did not read is
* Three, not one. A mistyped PIN or a fingerprint that did not read is
* ordinary, and making the first slip cost a wait would punish the owner far
* more often than anybody else.
*/
@@ -52,7 +52,7 @@ class AppLockBackoffTest {
}
/**
* 🔴 **The ceiling is the important half.** An unbounded back-off locks
* The ceiling is the important half. An unbounded back-off locks
* somebody out of their own messages permanently, which is a worse outcome
* than the one it defends against: there is no reset, no support desk, and
* the data is on their own phone.
@@ -65,10 +65,10 @@ class AppLockBackoffTest {
}
/**
* 🔴 **The overflow the Swift version cannot have and Kotlin can.** iOS
* The overflow the Swift version cannot have and Kotlin can. iOS
* computes `30 * pow(2, steps)` in a Double, which saturates to infinity and
* still caps correctly. Shifting a Long does not: `30L shl 60` wraps to a
* negative number, and `minOf(negative, cap)` returns the negative — a
* negative number, and `minOf(negative, cap)` returns the negative, a
* "wait" in the past, which reads as no wait at all and quietly removes the
* whole feature at exactly the point somebody is grinding attempts.
*
@@ -78,7 +78,7 @@ class AppLockBackoffTest {
fun `an absurd failure count still returns the ceiling, not a negative wait`() {
for (failures in intArrayOf(60, 64, 100, Int.MAX_VALUE)) {
val wait = AppLock.delayMillis(failures)
assertTrue(wait > 0, "delayMillis($failures) was $wait — a wait in the past")
assertTrue(wait > 0, "delayMillis($failures) was $wait, a wait in the past")
assertEquals(AppLock.MAXIMUM_DELAY_MS, wait, "delayMillis($failures)")
}
}
@@ -98,7 +98,7 @@ class AppLockBackoffTest {
// ---- How it is said -------------------------------------------------
/**
* ⚠️ A lock screen that says "try again in 240 seconds" is one people read
* A lock screen that says "try again in 240 seconds" is one people read
* as broken. The unit follows the size of the number.
*/
@Test
@@ -124,7 +124,7 @@ class AppLockBackoffTest {
/**
* The wording rule, lifted out so it can be checked without a `Context`.
*
* ⚠️ Kept byte-identical to `AppLock.waitMessage` on purpose — if the two
* Kept byte-identical to `AppLock.waitMessage` on purpose, if the two
* drift, this test is asserting about a string nobody ever sees. It is a
* copy because a `Context` is the only thing standing between the real one
* and a plain JVM test, and the arithmetic is worth more than the purity.
@@ -141,7 +141,7 @@ class AppLockBackoffTest {
@Test
fun `a partial second still reads as a second to wait`() {
assertEquals("Too many attempts. Try again in 1 second.", sayWait(1))
// 61 seconds is two minutes' worth of rounding up, not one — reporting
// 61 seconds is two minutes' worth of rounding up, not one, reporting
// "1 minute" would have somebody tapping again into a refusal.
assertEquals("Too many attempts. Try again in 2 minutes.", sayWait(61))
}
@@ -23,12 +23,12 @@ import org.robolectric.annotation.Config
/**
* Cached plaintext goes when nothing refers to it any more.
*
* 🔴 **`attachmentBytes` writes DECRYPTED bytes and nothing removed them**, so
* `attachmentBytes` writes DECRYPTED bytes and nothing removed them, so
* a disappearing message expired, the bubble went, and the photo stayed on disk
* indefinitely — the exact failure the feature exists to prevent (android#11,
* indefinitely, the exact failure the feature exists to prevent (android#11,
* spec §6.11).
*
* ⚠️ The dangerous direction here is the opposite of the bug. A sweep that
* The dangerous direction here is the opposite of the bug. A sweep that
* deletes too much loses somebody's photo, which is worse than the leak. So
* most of these are about what must SURVIVE.
*/
@@ -106,7 +106,7 @@ class AttachmentPurgeTest {
}
/**
* 🔴 The case that makes a naive sweep destructive: an expired story is
* The case that makes a naive sweep destructive: an expired story is
* dropped from the live set and folded into the private archive, which
* keeps its reference.
*/
@@ -135,13 +135,13 @@ class AttachmentPurgeTest {
}
/**
* 🔴 **An id containing a slash, which is the reason the transformation
* **An id containing a slash, which is the reason the transformation
* exists.** `attachmentBytes` writes the file as `att-<id with / -> _>`, so
* membership has to be tested against the SAME mangling. Compare raw ids to
* mangled filenames and nothing matches — every attachment with a slash in
* mangled filenames and nothing matches, every attachment with a slash in
* its id is deleted while still on screen.
*
* ⚠️ Added after a mutation test: removing the transformation broke nothing,
* Added after a mutation test: removing the transformation broke nothing,
* because every other id here is slash-free. The guard was untested by the
* suite that was meant to be guarding it.
*/
@@ -159,7 +159,7 @@ class AttachmentPurgeTest {
}
/**
* ⚠️ A file written moments ago is left alone, so a sweep racing a fetch
* A file written moments ago is left alone, so a sweep racing a fetch
* cannot delete bytes on their way to being shown.
*/
@Test
@@ -183,7 +183,7 @@ class AttachmentPurgeTest {
// ---- Failing closed ---------------------------------------------------
/**
* 🔴 If the in-use set cannot be built, NOTHING is deleted. A partial
* If the in-use set cannot be built, NOTHING is deleted. A partial
* answer to "what is still in use" is the one input that turns this from a
* cleanup into data loss.
*/
@@ -205,8 +205,8 @@ class AttachmentPurgeTest {
// ---- The throttle -----------------------------------------------------
/**
* ⚠️ Building the in-use set reads every vault record, and this is called
* from the poll loop — so a second call moments later does nothing.
* Building the in-use set reads every vault record, and this is called
* from the poll loop, so a second call moments later does nothing.
*/
@Test
fun `a second sweep moments later does not re-read the vault`() {
@@ -7,20 +7,20 @@ import kotlin.test.assertTrue
/**
* A refused-because-the-key-changed failure has to say so (android#20).
*
* 🔴 **Reported from a real session: "the calls wouldn't connect because the
* **Reported from a real session: "the calls wouldn't connect because the
* keys were changed. it wasn't obvious why the call failed."** It was not
* obvious because nothing said it. The call's signalling lambda was
* `runCatching { … }` with no `onFailure`, so `IdentityChangedException` — the
* `runCatching { … }` with no `onFailure`, so `IdentityChangedException`, the
* send path refusing to encrypt to an unreviewed identity, which is TOFU
* working — vanished. The offer never left and the screen sat in DIALLING.
* working, vanished. The offer never left and the screen sat in DIALLING.
*
* ⚠️ **And the fix is on a screen the caller is not looking at.** Accepting a
* And the fix is on a screen the caller is not looking at. Accepting a
* changed key is a banner in the CHAT; somebody calling from the Calls tab
* never sees it. So the message has to name the conversation, not just the
* problem.
*
* ⚠️ iOS has said "Their security code changed" on this path since it was
* built. This is a parity gap, not a new idea — which is why the assertion is
* iOS has said "Their security code changed" on this path since it was
* built. This is a parity gap, not a new idea, which is why the assertion is
* about the substance of the sentence rather than its exact wording.
*/
class ChangedKeyIsExplainedTest {
@@ -31,16 +31,16 @@ class ChangedKeyIsExplainedTest {
fun `it names the security code rather than the network`() {
assertTrue(
message.contains("security code", ignoreCase = true),
"a changed key was described as \"$message\" — the person is sent " +
"a changed key was described as \"$message\", the person is sent " +
"hunting a network problem that does not exist",
)
}
/**
* 🔴 **The actionable half.** A refusal somebody cannot act on is the same
* The actionable half. A refusal somebody cannot act on is the same
* as no explanation: they retry the same thing and are refused again. This
* file's neighbours in `describe()` all say what to DO, and that rule is
* written down there — this one has to keep it.
* written down there, this one has to keep it.
*/
@Test
fun `it says where to go and what to do`() {
@@ -52,7 +52,7 @@ class ChangedKeyIsExplainedTest {
)
}
/** ⚠️ Not the generic fallback, which is what this used to produce. */
/** Not the generic fallback, which is what this used to produce. */
@Test
fun `it is not the generic something-went-wrong line`() {
assertTrue(
@@ -7,20 +7,20 @@ import kotlin.test.assertTrue
/**
* A destructive button must not claim more than it does (android#18).
*
* 🔴 **"Unsend" did not unsend.** The message menu offered it on your own
* "Unsend" did not unsend. The message menu offered it on your own
* messages and called `deleteMessage`, which reaches `vault.deleteLine` and
* stops there. No envelope leaves the phone; the recipient's copy stays exactly
* where it is. Somebody who sent to the wrong person tapped it, watched the
* bubble vanish, and had no way to find out.
*
* ⚠️ **Worse than an overclaiming page**, which is why this is a test and not
* Worse than an overclaiming page, which is why this is a test and not
* a one-line fix left to stay fixed. A page that overclaims can be read
* sceptically. A button that reports success cannot: the feedback IS the claim,
* and it arrives at the exact moment the person is most relieved to believe it.
*
* A source scan, and coarse on purpose. It cannot prove every label is honest.
* It proves this particular word cannot come back on a local-only action
* without somebody deleting a test that explains why — and it is a plain JVM
* without somebody deleting a test that explains why, and it is a plain JVM
* test, so it costs no emulator.
*/
class DestructiveLabelsTest {
@@ -38,7 +38,7 @@ class DestructiveLabelsTest {
}
/**
* ⚠️ **Comments are stripped, and that is required rather than tidy.** The
* Comments are stripped, and that is required rather than tidy. The
* comment explaining this bug necessarily quotes the word it is about, and
* a guard that flags its own explanation is a guard somebody deletes.
*/
@@ -48,9 +48,9 @@ class DestructiveLabelsTest {
.map { it.substringBefore("//") }
/**
* 🔴 **The word promises the recipient's copy is gone.** Nothing in this app
* The word promises the recipient's copy is gone. Nothing in this app
* can do that yet: it would take a signed control envelope naming the `mid`,
* which the recipient acts on — and even then it is a *request*, so whatever
* which the recipient acts on, and even then it is a *request*, so whatever
* the button eventually says must not promise more than "we asked them to
* remove it."
*
@@ -60,7 +60,7 @@ class DestructiveLabelsTest {
@Test
fun `no button says Unsend`() {
val files = uiSources()
assertTrue(files.isNotEmpty(), "scanned no sources — the walk found nothing")
assertTrue(files.isNotEmpty(), "scanned no sources, the walk found nothing")
for (file in files) {
codeLines(file.readText()).forEachIndexed { i, line ->
assertTrue(
@@ -75,7 +75,7 @@ class DestructiveLabelsTest {
}
/**
* ⚠️ The walk is the part most likely to rot. A scan that finds no files
* The walk is the part most likely to rot. A scan that finds no files
* passes every assertion in it, so this pins that it is actually reading
* the screen the bug was on.
*/
@@ -83,7 +83,7 @@ class DestructiveLabelsTest {
fun `the scan actually reaches the chat screen`() {
assertTrue(
uiSources().any { it.name == "ChatScreens.kt" },
"the source walk did not reach ChatScreens.kt — this guard is scanning nothing",
"the source walk did not reach ChatScreens.kt, this guard is scanning nothing",
)
}
}
@@ -6,8 +6,8 @@ import kotlin.test.assertEquals
/**
* The display preferences Android was missing.
*
* ⚠️ Only the pure decisions are here. Whether a switch is wired to the dot,
* the timestamp and the theme is not something a JVM test can see — that is
* Only the pure decisions are here. Whether a switch is wired to the dot,
* the timestamp and the theme is not something a JVM test can see, that is
* what the instrumented suite and a person looking at the screen are for. What
* IS worth pinning is the arithmetic, because it has a default that must not
* move and a clamp that must not be skipped.
@@ -15,7 +15,7 @@ import kotlin.test.assertEquals
class DisplaySettingsTest {
/**
* 🔴 **16sp with the setting off, because that is what the kit specifies
* **16sp with the setting off, because that is what the kit specifies
* for a message body.** A "larger type" switch that quietly restyled every
* message when it was turned OFF would be a worse bug than the absence it
* fixes, and the number is easy to get wrong because it lives in one file
@@ -28,7 +28,7 @@ class DisplaySettingsTest {
}
/**
* ⚠️ An unrecognised stored value reads as `system` — the state somebody
* An unrecognised stored value reads as `system`, the state somebody
* who never touched this has. A value from a future build, or a corrupted
* preference, must not leave the app in no theme at all.
*/
@@ -10,14 +10,14 @@ import kotlin.test.assertTrue
/**
* What the three new controls actually put on the wire (§6.15/§6.16/§6.19).
*
* 🔴 **The privacy rule for a forward is the one worth testing, because it is
* **The privacy rule for a forward is the one worth testing, because it is
* the one a "helpful" change would break.** §6.16 says a forward carries the
* words and nothing about where they came from. The obvious improvement —
* carrying a reference so the reader can see the original — would republish a
* words and nothing about where they came from. The obvious improvement,
* carrying a reference so the reader can see the original, would republish a
* private message's author to somebody who was never in that conversation. So
* the absence of that field is asserted, not just the presence of the flag.
*
* ⚠️ These check the ENVELOPE. The `Session` methods that build and apply them
* These check the ENVELOPE. The `Session` methods that build and apply them
* need a vault and a keystore, so their behaviour is covered on device; what is
* checkable here is the shape that crosses to the other app, which is where an
* interop bug would live.
@@ -29,7 +29,7 @@ class EditForwardPinWireTest {
// ---- Forward (§6.16) -------------------------------------------------
/**
* 🔴 The whole point of the feature: forwarded, and no trail back.
* The whole point of the feature: forwarded, and no trail back.
*/
@Test
fun `a forward carries the words and nothing about their origin`() {
@@ -38,7 +38,7 @@ class EditForwardPinWireTest {
assertEquals("passed along", sent.text)
assertTrue(sent.isForwarded, "the fwd flag was lost")
// 🔴 No reference to the original, in any form.
// No reference to the original, in any form.
assertNull(sent.about, "a forward is carrying a reference to its source")
assertNull(sent.editOf)
assertNull(sent.pins)
@@ -46,8 +46,8 @@ class EditForwardPinWireTest {
}
/**
* ⚠️ A forward gets a NEW id, not the original's. Reusing it would make the
* copy a target for edits and reactions aimed at the original — one of
* A forward gets a NEW id, not the original's. Reusing it would make the
* copy a target for edits and reactions aimed at the original, one of
* which would silently rewrite the other.
*/
@Test
@@ -76,7 +76,7 @@ class EditForwardPinWireTest {
val sent = parse(Content.edit("the corrected wording", of = "mid-1"))
assertEquals("mid-1", sent.editOf)
assertEquals("the corrected wording", sent.text)
// 🔴 Never drawn as a bubble of its own — the failure that shipped a
// Never drawn as a bubble of its own, the failure that shipped a
// duplicate beside the typo.
assertFalse(sent.isRenderable)
}
@@ -91,8 +91,8 @@ class EditForwardPinWireTest {
// ---- Pin (§6.19) -----------------------------------------------------
/**
* 🔴 An id and never a copy. A copy would go stale the moment the message
* was edited and would outlive it being deleted — a banner quoting words
* An id and never a copy. A copy would go stale the moment the message
* was edited and would outlive it being deleted, a banner quoting words
* that are no longer in the conversation.
*/
@Test
@@ -121,7 +121,7 @@ class EditForwardPinWireTest {
// ---- The three do not collide ----------------------------------------
/**
* ⚠️ Each envelope sets exactly one of the three fields. A message that was
* Each envelope sets exactly one of the three fields. A message that was
* both an edit and a pin would be applied twice by a receiver that checks
* them in sequence, and the order of those checks is not part of the spec.
*/
@@ -8,9 +8,9 @@ import org.junit.Test
* "Nobody here yet" is a claim about the ACCOUNT. It must not be made about a
* request that failed.
*
* 🔴 Both roster load sites swallow failures with
* Both roster load sites swallow failures with
* `runCatching { … }.getOrDefault(emptyList())`, so a hiccup, an expired token
* or a rate limit all produced an empty list — indistinguishable from a real
* or a rate limit all produced an empty list, indistinguishable from a real
* empty account. The same swallow-and-look-empty shape hid a decode bug that
* made groups invisible on iOS.
*/
@@ -7,8 +7,8 @@ import kotlin.test.assertTrue
/**
* The classification vocabulary the local vault offers.
*
* 🔴 **This is a change detector, and that is exactly what it should be.** The
* ORDINAL is what travels with a file — the word is only how it is drawn — so
* This is a change detector, and that is exactly what it should be. The
* ORDINAL is what travels with a file, the word is only how it is drawn, so
* renumbering these would file somebody's Restricted document as Internal on
* the other phone. Nothing would error and neither screen would look wrong; the
* only symptom is a document sitting at a level nobody chose.
@@ -29,7 +29,7 @@ class FileLevelsTest {
3.toShort() to "Restricted",
),
FileLevels.LOCAL_LEVELS,
"the local vocabulary changed — iOS's Vocabularies.local must change with it, " +
"the local vocabulary changed, iOS's Vocabularies.local must change with it, " +
"or a file classified on one phone arrives at a different level on the other",
)
}
@@ -39,9 +39,9 @@ class FileLevelsTest {
for ((ordinal, name) in FileLevels.LOCAL_LEVELS) {
assertEquals(name, FileLevels.levelName(ordinal))
}
// ⚠️ A file can arrive from a client with a wider vocabulary. Drawing an
// A file can arrive from a client with a wider vocabulary. Drawing an
// empty chip for it would show a row with no classification at all,
// which reads as "unclassified" — the most permissive thing it could
// which reads as "unclassified", the most permissive thing it could
// possibly mean, and a guess.
assertTrue(FileLevels.levelName(9).contains("9"),
"an unknown level must still say WHICH level, not render as blank")
@@ -10,13 +10,13 @@ import kotlin.test.assertTrue
/**
* What a document sent from Files actually puts on the wire.
*
* 🔴 **The classification is the only thing that survives the trip.** The
* recipient's phone files the document at whatever number arrives — there is
* nothing else to consult and no server to ask — so a label dropped in transit
* The classification is the only thing that survives the trip. The
* recipient's phone files the document at whatever number arrives, there is
* nothing else to consult and no server to ask, so a label dropped in transit
* does not error anywhere: the file simply arrives Unclassified and gets filed
* that way, on the most permissive setting it could possibly have.
*
* ⚠️ These check the ENVELOPE. `Session.sendFile` needs a relay and a vault, so
* These check the ENVELOPE. `Session.sendFile` needs a relay and a vault, so
* the send itself is covered on device; the shape that crosses to the other app
* is what an interop bug would live in, and it is checkable here.
*/
@@ -44,7 +44,7 @@ class FileShareWireTest {
}
/**
* 🔴 **Unclassified must round-trip as a real value, not as an absence.**
* Unclassified must round-trip as a real value, not as an absence.
* Zero is the default for a missing field, so a sender that dropped the key
* and a sender that meant zero are indistinguishable at the other end. This
* pins that the zero case still parses as a file rather than as something
@@ -59,7 +59,7 @@ class FileShareWireTest {
}
/**
* ⚠️ A file envelope always carries a caption, even when nobody wrote one.
* A file envelope always carries a caption, even when nobody wrote one.
* Builds predating this attachment kind show the caption and drop the rest,
* so an empty one reaches them as "[unsupported attachment]" with no clue
* what was sent.
@@ -81,9 +81,9 @@ class FileShareWireTest {
}
/**
* ⚠️ Compartment order is normalised on the way out, so two senders who
* Compartment order is normalised on the way out, so two senders who
* chose the same areas produce the same bytes. Unsorted, the label would
* still be *correct* and would still encode differently — which matters
* still be *correct* and would still encode differently, which matters
* because the canonical encoding is authenticated data elsewhere.
*/
@Test
@@ -92,8 +92,8 @@ class FileShareWireTest {
}
/**
* 🔴 An image must NOT gain a label. `label` is written only for the file
* kind — a photo carrying `cls: 0` would tell the other end there is a
* An image must NOT gain a label. `label` is written only for the file
* kind, a photo carrying `cls: 0` would tell the other end there is a
* classification here to respect, and there is not.
*/
@Test
@@ -7,15 +7,15 @@ import kotlin.test.assertTrue
/**
* Files asks for the Flamenet passphrase, not a second secret.
*
* 🔴 **The screen used to say the files were encrypted "with a passphrase of
* **The screen used to say the files were encrypted "with a passphrase of
* their own", and iOS never said anything like it.** iOS says "your
* passphrase" and takes whatever is typed, so people use their Flamenet one
* and it works. Android's copy sent them off to invent something else — and
* and it works. Android's copy sent them off to invent something else, and
* forgetting it is a one-way door: the label keys are derived from the
* identity it protects and exist nowhere else, so every vaulted file becomes
* permanently unreadable.
*
* ⚠️ **This is about the words, and the words are the whole feature here.**
* This is about the words, and the words are the whole feature here.
* Nothing in the derivation changed: it is still a free-choice string
* protecting the identity record, so anyone who already set a different one
* keeps opening their files with it. A guard on the copy is therefore the only
@@ -29,7 +29,7 @@ class FilesUsesTheAccountPassphraseTest {
dir = dir.parentFile
}
val f = File(dir, "app/src/main/kotlin/io/flamenet/messenger/app/$name")
assertTrue(f.isFile, "could not find $name — this guard is reading nothing")
assertTrue(f.isFile, "could not find $name, this guard is reading nothing")
return f.readText()
}
@@ -51,7 +51,7 @@ class FilesUsesTheAccountPassphraseTest {
}
/**
* ⚠️ The exact phrase that caused it. Kept as a literal because a person
* The exact phrase that caused it. Kept as a literal because a person
* rewording this screen should have to read the comment above the string
* and decide deliberately, not discover the consequence from a user.
*/
@@ -61,7 +61,7 @@ class FilesUsesTheAccountPassphraseTest {
val shown = copyLines(source(name)).joinToString("\n")
assertTrue(
!shown.contains("passphrase of their own"),
"$name tells people the files have a passphrase of their own — " +
"$name tells people the files have a passphrase of their own, " +
"iOS asks for the one they already have, and forgetting a " +
"second one destroys every vaulted file",
)
@@ -23,19 +23,19 @@ import org.robolectric.annotation.Config
/**
* The roster gate that turns a group-name CLAIM into a stored name.
*
* 🔴 **This is the check, and it lives here rather than in `Conversations`.**
* This is the check, and it lives here rather than in `Conversations`.
* The layer that consumes control traffic does not know who is in which group,
* so it queues claims and `Session` decides. Anyone who can reach us can send
* one naming any gid they like; adopting on the sender's word would put
* whatever a stranger wants into somebody's chat list, under a group heading
* the reader has no reason to doubt.
*
* ⚠️ It was tested only through `Conversations` — which asserts the claim is
* It was tested only through `Conversations`, which asserts the claim is
* NOT adopted there, and says nothing about what happens next. The half that
* decides was covered one layer removed on Android while iOS had it directly.
* That is the gap this closes.
*/
// ⚠️ Robolectric for the same reason as OutboxTest: `:app` is an Android
// Robolectric for the same reason as OutboxTest: `:app` is an Android
// module, so its unit-test classpath has the stubbed `org.json` that returns
// null from everything, and the vault stores JSON.
@RunWith(RobolectricTestRunner::class)
@@ -50,8 +50,8 @@ class GroupNameClaimTest {
/**
* An empty inbox and whatever roster the test asked for.
*
* ⚠️ Answers by PATH rather than by queue order. `receive()` polls, and
* sending or draining can ask for a log head on the way — a fixture matched
* Answers by PATH rather than by queue order. `receive()` polls, and
* sending or draining can ask for a log head on the way, a fixture matched
* on order hands one of those the other's body and fails on a parse error
* a long way from the cause.
*/
@@ -125,7 +125,7 @@ class GroupNameClaimTest {
}
/**
* 🔴 The whole point. A stranger naming a group they are not in must get
* The whole point. A stranger naming a group they are not in must get
* nowhere, however well-formed their envelope is.
*/
@Test
@@ -140,7 +140,7 @@ class GroupNameClaimTest {
}
/**
* ⚠️ A gid the relay does not list for us is not our group. Adopting it
* A gid the relay does not list for us is not our group. Adopting it
* would let anyone create an entry in our chat list out of nothing.
*/
@Test
@@ -155,9 +155,9 @@ class GroupNameClaimTest {
}
/**
* ⚠️ Draining is not the same as accepting. A refused claim must not sit in
* Draining is not the same as accepting. A refused claim must not sit in
* the queue waiting to be retried against a roster that might later differ
* — and it must not be re-checked on every poll for ever.
* and it must not be re-checked on every poll for ever.
*/
@Test
fun `a refused claim is consumed, not left to retry`() {
@@ -20,14 +20,14 @@ import org.robolectric.annotation.Config
/**
* A pin in a GROUP resolves to the message it names (§6.19).
*
* 🔴 **Written to settle a published claim.** The spec says a group pin *"is
* Written to settle a published claim. The spec says a group pin *"is
* carried correctly on the wire but cannot resolve yet, because a group
* transcript does not record"* a message id. `Session.sendGroup` mints a `mid`
* and passes it to `record(...)`, so the stated reason is already false — but
* and passes it to `record(...)`, so the stated reason is already false, but
* relay#46's rule is that a claim is flipped by a run, not by reading the code,
* and nothing exercised the group case.
*
* ⚠️ The 1:1 case was never in doubt. This is specifically about a group
* The 1:1 case was never in doubt. This is specifically about a group
* thread, because that is what the spec singles out.
*/
@RunWith(RobolectricTestRunner::class)
@@ -67,7 +67,7 @@ class GroupPinResolvesTest {
val s = session()
s.record(thread, "in a group", mine = true, author = 1L, messageId = "gm1")
assertEquals("gm1", s.history(thread).single().id,
"a group line carries no id — the spec's stated reason would hold")
"a group line carries no id, the spec's stated reason would hold")
}
@Test
@@ -80,15 +80,15 @@ class GroupPinResolvesTest {
val pinned = assertNotNull(s.pinnedMessage(thread), "nothing was pinned")
assertEquals("gm1", pinned)
// 🔴 The resolve: the banner needs the MESSAGE, not just its id.
// The resolve: the banner needs the MESSAGE, not just its id.
val target = s.history(thread).firstOrNull { it.id == pinned }
assertEquals("worth keeping", assertNotNull(target).text,
"the pin named a message the group transcript could not find")
}
/**
* ⚠️ An id, never a copy. A stored copy would go stale the moment the
* message was edited and would survive it being deleted — a banner quoting
* An id, never a copy. A stored copy would go stale the moment the
* message was edited and would survive it being deleted, a banner quoting
* words no longer in the conversation.
*/
@Test
@@ -9,19 +9,19 @@ import kotlin.test.assertTrue
* Edits, pins and unknown control envelopes must never become bubbles
* (ios#6.15/#6.16/#6.19, Android half).
*
* 🔴 **The bug this closes was an interop bug, not a missing feature.** The
* The bug this closes was an interop bug, not a missing feature. The
* engine has parsed `editOf`, `pins` and `fwd` for as long as they have
* existed, and it exposes `isRenderable` — "does this belong in a thread" — as
* existed, and it exposes `isRenderable`, "does this belong in a thread", as
* the single answer. `Session`'s inbound loop never consulted it.
*
* So an edit from the iPhone app arrived carrying its new wording in `text` and
* was filed as an ORDINARY MESSAGE: a correction drawn as a duplicate beside the
* typo it was meant to replace. A pin was filed as a bubble whose text was
* empty. And any control envelope from a newer peer — which carries `ctl` and a
* kind this build has never heard of — would have drawn a bubble full of JSON
* empty. And any control envelope from a newer peer, which carries `ctl` and a
* kind this build has never heard of, would have drawn a bubble full of JSON
* in somebody's conversation.
*
* ⚠️ These test the ENGINE's classification, which is what the app now branches
* These test the ENGINE's classification, which is what the app now branches
* on. The app-side application (`applyEdit`, `applyPin`) needs a `Vault` and a
* keystore, so it is covered on device in `FeatureUseTest`; what is checkable
* here is the decision that used to be missing.
@@ -33,7 +33,7 @@ class InboundControlTest {
// ---- What must not be drawn -----------------------------------------
/**
* 🔴 The case that shipped a duplicate bubble. An edit carries the new
* The case that shipped a duplicate bubble. An edit carries the new
* wording, so it looks exactly like a message to anything that does not ask.
*/
@Test
@@ -61,7 +61,7 @@ class InboundControlTest {
}
/**
* 🔴 **The case an allowlist cannot cover, and the reason the app branches
* **The case an allowlist cannot cover, and the reason the app branches
* on `isRenderable` rather than on a list of kinds it knows.** A control
* envelope from a NEWER peer has a kind nothing here has heard of. Keying on
* known kinds ships a bubble full of JSON to every older install the first
@@ -78,9 +78,9 @@ class InboundControlTest {
}
/**
* ⚠️ Both shapes of the flag. `org.json`'s `optBoolean` does not coerce a
* Both shapes of the flag. `org.json`'s `optBoolean` does not coerce a
* number, so an engine sending `"ctl":1` rather than `"ctl":true` would slip
* past a reader that checked only one — which is how this class of bug got
* past a reader that checked only one, which is how this class of bug got
* shipped once already.
*/
@Test
@@ -92,7 +92,7 @@ class InboundControlTest {
// ---- What must still be drawn ---------------------------------------
/**
* 🔴 The guard must not swallow ordinary messages. A `continue` in the wrong
* The guard must not swallow ordinary messages. A `continue` in the wrong
* place is a conversation that silently stops receiving.
*/
@Test
@@ -112,8 +112,8 @@ class InboundControlTest {
}
/**
* 🔴 **A forward IS a message.** It is second-hand words, not a control
* envelope — the flag changes how it is drawn, not whether it is.
* A forward IS a message. It is second-hand words, not a control
* envelope, the flag changes how it is drawn, not whether it is.
*/
@Test
fun `a forwarded message is renderable, and says so`() {
@@ -125,7 +125,7 @@ class InboundControlTest {
}
/**
* ⚠️ And the same both-shapes rule for `fwd`, for the same reason. An engine
* And the same both-shapes rule for `fwd`, for the same reason. An engine
* writing `1` where this one writes `true` would have its forwards drawn as
* the sender's own words.
*/
@@ -20,13 +20,13 @@ import org.robolectric.annotation.Config
/**
* An inbound edit changes the message it names (§6.15).
*
* 🔴 **Written to settle a claim, not to add coverage.** The published spec
* Written to settle a claim, not to add coverage. The published spec
* says Android *"cannot yet apply one, because its transcript does not record a
* `mid` for each line"*, and that both blocks replies and reactions too. The
* transcript does record a `mid` — `MessageReactionTest` folds a reaction onto
* a message by id — so the stated reason is already false.
* transcript does record a `mid`, `MessageReactionTest` folds a reaction onto
* a message by id, so the stated reason is already false.
*
* ⚠️ But relay#46's rule is the point: **do not flip a claim because the code
* But relay#46's rule is the point: **do not flip a claim because the code
* looks present; flip it because a run proved it, and say which run.**
* `applyEdit` was implemented and wired with nothing exercising it, because
* `InboundControlTest` covers the codec and says in its own header that the
@@ -80,7 +80,7 @@ class InboundEditAppliedTest {
assertEquals("untouched", s.textOf("m2"), "an edit changed a message it did not name")
}
/** It survives a reload — the point is the transcript, not a live object. */
/** It survives a reload, the point is the transcript, not a live object. */
@Test
fun `the edit is persisted, not just applied in memory`() {
val s = session()
@@ -92,8 +92,8 @@ class InboundEditAppliedTest {
}
/**
* 🔴 **An edit from anyone but the author is refused.** Without this, one
* contact could rewrite another's words in a transcript — which reads to
* An edit from anyone but the author is refused. Without this, one
* contact could rewrite another's words in a transcript, which reads to
* the person as the sender having said something they never said.
*/
@Test
@@ -8,7 +8,7 @@ import kotlin.test.assertNull
/**
* The two decisions behind the `@` picker, tested away from Compose.
*
* 🔴 Both of these are about somebody being rung, or not being rung, by a
* Both of these are about somebody being rung, or not being rung, by a
* message. A picker that opens at the wrong moment is an annoyance; a mention
* that reaches the wrong person, or fails to reach the right one, is the
* feature not working.
@@ -39,7 +39,7 @@ class MentionPickerTest {
}
/**
* 🔴 The list closes on a space, and closing has to be driven by the text
* The list closes on a space, and closing has to be driven by the text
* rather than by a flag. Somebody deleting their way back past the `@`
* expects it gone, and a flag has to be remembered to be cleared.
*/
@@ -69,8 +69,8 @@ class MentionPickerTest {
}
/**
* ⚠️ The relay stores ids and no names, so a member who is not in your
* contacts has nothing to show. They stay mentionable under their id —
* The relay stores ids and no names, so a member who is not in your
* contacts has nothing to show. They stay mentionable under their id,
* being un-mentionable is worse than being ugly, because the alternative
* is a group where some people cannot be reached.
*/
@@ -23,9 +23,9 @@ import org.robolectric.annotation.Config
/**
* Reactions, and the message id everything else will hang off.
*
* 🔴 **The id is the point.** A reaction, an edit and a pin all say "this
* The id is the point. A reaction, an edit and a pin all say "this
* one", and until every message carried an identifier there was nothing to
* point at — which is why all three sat in the codec with no way to reach
* point at, which is why all three sat in the codec with no way to reach
* them. The rules pinned here are the ones that decide whether a reaction
* lands on the right message, on the wrong one, or on nothing.
*/
@@ -82,7 +82,7 @@ class MessageReactionTest {
// MARK: - The id
/**
* 🔴 A sent message carries an id, and the copy in our own transcript
* A sent message carries an id, and the copy in our own transcript
* carries the SAME one. Two ids for one message means anything the other
* side says about it arrives about a message we do not have.
*/
@@ -99,7 +99,7 @@ class MessageReactionTest {
}
/**
* ⚠️ The queued retry has to be the SAME message, id included. Queuing the
* The queued retry has to be the SAME message, id included. Queuing the
* bare text meant the copy that eventually landed was a different message
* from the one already in this transcript.
*/
@@ -130,14 +130,14 @@ class MessageReactionTest {
val line = session.history(thread).single()
assertEquals(mapOf("❤️" to setOf(peer)), line.reactions)
// 🔴 And it stays one message. A reaction filed as a message of its
// And it stays one message. A reaction filed as a message of its
// own is how a thread becomes unreadable.
assertEquals(1, session.history(thread).size)
}
/**
* 🔴 One emoji per person. Reacting again replaces rather than
* accumulates — otherwise one tap each on five emoji all stand at once,
* One emoji per person. Reacting again replaces rather than
* accumulates, otherwise one tap each on five emoji all stand at once,
* which is not what anybody means by reacting.
*/
@Test
@@ -180,7 +180,7 @@ class MessageReactionTest {
}
/**
* ⚠️ A reaction to a message this device does not have does nothing, and
* A reaction to a message this device does not have does nothing, and
* says so. Inventing a bubble for it would put a message in the thread
* that nobody wrote.
*/
@@ -196,9 +196,9 @@ class MessageReactionTest {
}
/**
* 🔴 A line stored before ids existed has none, and must not be given one
* A line stored before ids existed has none, and must not be given one
* on read. Two devices would invent different ids for the same message,
* and a reaction would land on nothing — or worse, on the wrong thing.
* and a reaction would land on nothing, or worse, on the wrong thing.
*/
@Test
fun `a message stored before ids existed keeps no id`() {
@@ -8,16 +8,16 @@ 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
* **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.**
* 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
* 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 {
@@ -34,7 +34,7 @@ class MetadataScrubTest {
assertEquals(MetadataScrub.ImageKind.TIFF, MetadataScrub.kind(bytes(0x4D, 0x4D, 0x00, 0x2A)))
}
/** ⚠️ Both markers, with the size field between them — matching only the
/** 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`() {
@@ -51,14 +51,14 @@ class MetadataScrubTest {
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
// 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.
// 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
* 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.
*/
@@ -9,22 +9,22 @@ import kotlin.test.assertTrue
/**
* What is in the tab bar.
*
* 🔴 **This test exists because nothing could see the bar.** It was a hand-written
* 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
* 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
* 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
* 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.
@@ -41,7 +41,7 @@ class NavDestinationsTest {
}
/**
* ⚠️ Supervision removes the surface rather than showing an empty one —
* 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.
*/
@@ -56,7 +56,7 @@ class NavDestinationsTest {
}
/**
* 🔴 The tags are how the instrumented suite finds the bar. A renamed tag
* 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
@@ -72,7 +72,7 @@ class NavDestinationsTest {
}
/**
* ⚠️ The label is not decoration: the bar draws icons only, so this string
* 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.
*/
@@ -84,9 +84,9 @@ class NavDestinationsTest {
}
/**
* 🔴 Every destination a person can reach from the bar has to be somewhere
* 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
* 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
@@ -8,11 +8,11 @@ import kotlin.test.assertTrue
/**
* What the Network section says is happening.
*
* 🔴 **A settings screen that reads the stored line and calls it a status is
* **A settings screen that reads the stored line and calls it a status is
* the failure this feature is built around.** A saved bridge that is not there
* leaves the app connecting directly; the preference cannot tell you that and
* a completed handshake can. `statusLine` is lifted out of the Composable
* precisely so this can be asserted — a string built inline in a `Text(...)` is
* precisely so this can be asserted, a string built inline in a `Text(...)` is
* a string nothing can test.
*/
class NetworkStatusLineTest {
@@ -26,7 +26,7 @@ class NetworkStatusLineTest {
}
/**
* 🔴 **The load-bearing case.** A bridge is saved and nothing has gone
* The load-bearing case. A bridge is saved and nothing has gone
* through it. Reporting that as protection is exactly the claim the screen
* exists to stop making.
*/
@@ -47,7 +47,7 @@ class NetworkStatusLineTest {
}
/**
* ⚠️ Every state has to repeat that the relay is unaffected. Somebody who
* Every state has to repeat that the relay is unaffected. Somebody who
* reads "wrapped so nobody can tell what it is" and stops there has been
* told a true thing that leads to a false conclusion.
*/
@@ -7,21 +7,21 @@ import kotlin.test.assertFalse
/**
* What a notification is allowed to say.
*
* 🔴 **The default is the load-bearing part.** Previews off means a lock screen
* says how many messages arrived and nothing else — no name, no words. That was
* The default is the load-bearing part. Previews off means a lock screen
* says how many messages arrived and nothing else, no name, no words. That was
* the behaviour before this setting existed, and nobody may acquire a name on
* their lock screen by installing an update.
*
* ⚠️ The text is built by `NotificationSettings` rather than inline in the
* The text is built by `NotificationSettings` rather than inline in the
* `NotificationCompat.Builder` chain precisely so it can be asserted here. A
* string built inside that chain is a string nothing can test, and what is
* being decided is whether somebody's name reaches a lock screen.
*/
class NotificationTextTest {
// 🔴 Calls the SHIPPING functions. The first version of this file
// Calls the SHIPPING functions. The first version of this file
// reimplemented the rules against a fake and passed perfectly while
// asserting nothing about the code that runs — two identically-wrong ends
// asserting nothing about the code that runs, two identically-wrong ends
// agreeing, which is the shape this codebase has been bitten by before.
private fun messageText(previews: Boolean, threads: Int, from: String?) =
NotificationSettings.messageText(previews, threads, from)
@@ -44,7 +44,7 @@ class NotificationTextTest {
}
/**
* ⚠️ Several threads means no name even with previews on. Naming one of
* Several threads means no name even with previews on. Naming one of
* three would be arbitrary, and naming all of them turns a notification
* into a contact list on the lock screen.
*/
@@ -55,8 +55,8 @@ class NotificationTextTest {
}
/**
* 🔴 A caller that has no name must not produce "New message from null" or
* a dangling "from". The service cannot always resolve one — a group, a
* A caller that has no name must not produce "New message from null" or
* a dangling "from". The service cannot always resolve one, a group, a
* cross-relay peer, or a contact it has never seen.
*/
@Test
@@ -20,22 +20,22 @@ import org.robolectric.annotation.Config
/**
* A message to somebody who is signed out waits, and goes when they come back.
*
* 🔴 **The question this answers is "how does the sender know to try again".**
* The question this answers is "how does the sender know to try again".
* They should not have to. Marking a message undelivered and leaving it there
* puts the work on the person who did nothing wrong — they would have to
* puts the work on the person who did nothing wrong, they would have to
* remember, notice their friend was back, and retype it. So the message is
* held and retried, and the only thing the sender ever does is write it once.
*
* ⚠️ **Nothing expires.** There is no TTL and nothing is discarded: a contact
* Nothing expires. There is no TTL and nothing is discarded: a contact
* who reinstalls in a month gets it in a month. Timing out would mean deleting
* something somebody wrote in order to tidy up a label.
*
* ⚠️ The honest limit, which the UI wording has to respect: the queue is on
* The honest limit, which the UI wording has to respect: the queue is on
* the sender's phone, because an envelope is sealed to a device's keys and
* somebody with no device has none to seal to. So it goes out the next time
* THIS app runs and polls — not the instant the recipient signs in.
* THIS app runs and polls, not the instant the recipient signs in.
*/
// ⚠️ Robolectric, for one boring reason: `:app` is an Android module, so its
// Robolectric, for one boring reason: `:app` is an Android module, so its
// unit-test classpath has the stubbed `org.json` that returns null from every
// call. The vault stores JSON, so without a real implementation every one of
// these fails inside `saveIdentity` before reaching anything worth testing.
@@ -48,8 +48,8 @@ class OutboxTest {
/**
* A relay from before §19, answering the queue for everything else.
*
* ⚠️ Not incidental setup. Sending now asks for a transparency-log head on
* its own, because key checking is automatic on any relay that serves one —
* Not incidental setup. Sending now asks for a transparency-log head on
* its own, because key checking is automatic on any relay that serves one,
* so a queue matched purely by ORDER would hand the head request the device
* listing and fail on a parse error miles from the cause. This says plainly
* what the fixture is: a deployment with no log, where these tests are
@@ -107,7 +107,7 @@ class OutboxTest {
session.send(peer, "are you around later?")
// It is in the thread — the sender's own words do not vanish.
// It is in the thread, the sender's own words do not vanish.
val thread = session.history(Vault.threadForUser(peer))
assertEquals(1, thread.size, "the message must still be in the thread")
assertEquals("are you around later?", thread[0].text)
@@ -154,15 +154,15 @@ class OutboxTest {
}
/**
* 🔴 **A refusal waiting cannot fix must STOP** (ios#22).
* A refusal waiting cannot fix must STOP (ios#22).
*
* The outbox retries for ever, which is right for a contact who is offline
* or has no device yet. It is wrong for a send the trust layer refuses:
* the peer's identity keys changed, so every retry is refused identically
* until a person accepts them — while the bubble goes on promising
* until a person accepts them, while the bubble goes on promising
* "will send", which is a promise the app cannot keep.
*
* ⚠️ Asserted on the OUTBOX and the BUBBLE together. A version that
* Asserted on the OUTBOX and the BUBBLE together. A version that
* dropped the entry but left the bubble pending would pass a test that
* only checked one, and would read as "still sending" for ever.
*/
@@ -173,7 +173,7 @@ class OutboxTest {
session.send(peer, "you there?")
assertEquals(1, session.vault.outbox().length())
// They come back with a REAL device, and the message goes — pinning
// They come back with a REAL device, and the message goes, pinning
// their identity on first use.
val them = DeviceIdentity.create()
val bundleOf = { id: DeviceIdentity ->
@@ -193,7 +193,7 @@ class OutboxTest {
session.flushOutbox()
assertEquals(0, session.vault.outbox().length())
// 🔴 Now they reinstall: a DIFFERENT identity under the same user. The
// Now they reinstall: a DIFFERENT identity under the same user. The
// trust layer refuses, and no amount of retrying changes that.
server.enqueue(json("""{"devices":[]}""")) // queues the second message
session.send(peer, "you there?")
@@ -215,14 +215,14 @@ class OutboxTest {
}
/**
* 🔴 **The promise, end to end.** Everything above only proves the message
* The promise, end to end. Everything above only proves the message
* is not lost; this proves it is eventually sent, which is the part the
* sender is being asked to trust. Both halves are asserted deliberately: a
* version that delivered the message but left the bubble reading
* "undelivered" would pass a test that only checked the outbox, and would
* look broken to the person reading the thread.
*
* ⚠️ Serves a REAL bundle built from a second identity, not a hand-written
* Serves a REAL bundle built from a second identity, not a hand-written
* fake, so the send has to complete genuine X3DH against it. A fake bundle
* would agree with whatever the client did, including the wrong thing.
*/
@@ -239,12 +239,12 @@ class OutboxTest {
val bundle = them.registrationWire().put(
"opk", org.json.JSONObject()
.put("id", opk.id)
// ⚠️ `Bytes` is internal to :engine, so base64 by hand rather
// `Bytes` is internal to :engine, so base64 by hand rather
// than widening its visibility for a test.
.put("pub", android.util.Base64.encodeToString(
opk.publicKey, android.util.Base64.NO_WRAP)))
// The send asks the directory first (free), THEN the key route (which
// pops a prekey) — two requests now, same payload shape.
// pops a prekey), two requests now, same payload shape.
server.enqueue(json(org.json.JSONObject()
.put("devices", org.json.JSONArray().put(bundle)).toString()))
server.enqueue(json(org.json.JSONObject()
@@ -265,9 +265,9 @@ class OutboxTest {
}
/**
* 🔴 The sticker version of the promise, because it was broken separately:
* a sticker to somebody signed out was marked "Undelivered — will send"
* and then never entered the outbox — the label was a lie for exactly one
* The sticker version of the promise, because it was broken separately:
* a sticker to somebody signed out was marked "Undelivered, will send"
* and then never entered the outbox, the label was a lie for exactly one
* content kind. This asserts all three parts: queued, delivered AS A
* STICKER once a device exists, and the bubble restated.
*/
@@ -292,7 +292,7 @@ class OutboxTest {
.put("id", opk.id)
.put("pub", android.util.Base64.encodeToString(
opk.publicKey, android.util.Base64.NO_WRAP)))
// Directory first, then keys — see the note in the first test.
// Directory first, then keys, see the note in the first test.
server.enqueue(json(org.json.JSONObject()
.put("devices", org.json.JSONArray().put(bundle)).toString()))
server.enqueue(json(org.json.JSONObject()
@@ -311,10 +311,10 @@ class OutboxTest {
}
/**
* 🔴 The group half of the promise, with its completion rule made
* The group half of the promise, with its completion rule made
* observable: the bubble flips only when the LAST queued member is
* reached. Restating on the first success would mark the message delivered
* while other members are still owed it — "will send" would quietly stop
* while other members are still owed it, "will send" would quietly stop
* being true for exactly the people it was still true for.
*/
@Test
@@ -380,8 +380,8 @@ class OutboxTest {
server.enqueue(json("""{"devices":[]}"""))
session.send(peer, "same words")
// 🔴 Two identical messages. `restate` matches on the timestamp the
// send stamped, not on the text — matching on text would restate
// Two identical messages. `restate` matches on the timestamp the
// send stamped, not on the text, matching on text would restate
// whichever came first every time somebody repeats themselves.
val outbox = session.vault.outbox()
assertEquals(2, outbox.length())
@@ -12,8 +12,8 @@ import kotlin.test.assertTrue
* The parked incoming offer, which is what makes a call answerable from a
* locked phone.
*
* 🔴 **The ringing notification is `ongoing`**, so it cannot be swiped away by
* accident mid-ring — which also means it never dismisses itself, and every
* The ringing notification is `ongoing`, so it cannot be swiped away by
* accident mid-ring, which also means it never dismisses itself, and every
* path that ends a call has to cancel it: declined, answered, the caller hung
* up, and nobody picked up. The last one is the one with no event behind it,
* and [PendingIncomingCall.isStale] is how it is noticed.
@@ -37,7 +37,7 @@ class PendingCallTest {
assertFalse(PendingIncomingCall.isStale())
}
/** ⚠️ Without this the lock screen keeps offering to answer a dead call. */
/** Without this the lock screen keeps offering to answer a dead call. */
@Test
fun `an offer older than the dial timeout is stale`() {
PendingIncomingCall.park(
@@ -46,8 +46,8 @@ class PendingCallTest {
}
/**
* 🔴 Asking must not consume. A staleness check that swallowed a live offer
* would drop a call that was still ringing — the exact opposite of the bug
* Asking must not consume. A staleness check that swallowed a live offer
* would drop a call that was still ringing, the exact opposite of the bug
* it exists to prevent.
*/
@Test
@@ -65,7 +65,7 @@ class PendingCallTest {
assertNull(PendingIncomingCall.take())
}
/** An offer is handed over exactly once — two answers is two call screens. */
/** An offer is handed over exactly once, two answers is two call screens. */
@Test
fun `taking twice yields nothing the second time`() {
PendingIncomingCall.park(42L, offer(), System.currentTimeMillis())
@@ -7,12 +7,12 @@ import kotlin.test.assertTrue
/**
* Presence, and the vocabulary it shares with the relay.
*
* 🔴 **The bug this guards shipped here and hid for months.** This screen has
* The bug this guards shipped here and hid for months. This screen has
* always offered `busy` and `offline` chips. The relay's status route parsed
* with a plain `rawValue:` init inside an `if let` with no `else`, so both were
* accepted with **200** and stored nothing, and `setPresence` then set its local
* accepted with 200 and stored nothing, and `setPresence` then set its local
* state from the value it had *asked* for. Tapping "offline" lit the chip,
* changed nothing, and left the user visible to every contact — a privacy
* changed nothing, and left the user visible to every contact, a privacy
* control that reported success and did nothing.
*
* Both ends are fixed. These assertions are what stops the vocabulary drifting
@@ -30,8 +30,8 @@ class PresenceTest {
}
/**
* ⚠️ The relay stores `offline` as `invisible` — the same choice under two
* names — so the reply has to be mapped back or no chip appears selected
* The relay stores `offline` as `invisible`, the same choice under two
* names, so the reply has to be mapped back or no chip appears selected
* and the screen looks broken right after a successful change.
*/
@Test
@@ -8,17 +8,17 @@ 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
* "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
* 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
* 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
@@ -29,7 +29,7 @@ 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")
assertTrue(f.isFile, "cannot find $name, this test reads the sources")
return f.readText()
}
@@ -47,7 +47,7 @@ class RecoveryActuallyRecoversTest {
val screen = vaultUnreadableScreen()
assertFalse(
screen.contains("model::signOut") || screen.contains("model.signOut("),
"the recovery button calls signOut, which keeps the device by default — " +
"the recovery button calls signOut, which keeps the device by default, " +
"it leaves the unreadable vault in place and the next restore returns here",
)
}
@@ -61,7 +61,7 @@ class RecoveryActuallyRecoversTest {
}
/**
* 🔴 And that the discard actually destroys something. A method with the
* 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.
*/
@@ -8,10 +8,10 @@ import kotlin.test.assertTrue
/**
* The device row's "Registered <date>" line.
*
* 🔴 **This shipped rendering its own source code.** The string was written
* This shipped rendering its own source code. The string was written
* with an escaped dollar, so the template was never evaluated and the screen
* showed `Registered ${fmt.format(instant)}` to the user. Nothing could warn
* about it — escaping a dollar is legitimate, just never there — and nothing
* about it, escaping a dollar is legitimate, just never there, and nothing
* could test it either, because it was built inline inside a `Text(...)`.
*/
class RegisteredLineTest {
@@ -31,7 +31,7 @@ class RegisteredLineTest {
assertEquals("Registered whenever", registeredLine("whenever"))
}
/** ⚠️ Absent is not the same as unparseable, and must not read as a date. */
/** Absent is not the same as unparseable, and must not read as a date. */
@Test
fun `a missing timestamp says so`() {
assertEquals("Registered date unknown", registeredLine(null))
@@ -9,7 +9,7 @@ import kotlin.test.assertTrue
/**
* Starting a reply, on the wire (android#16).
*
* ⚠️ **The receiving half already worked** — `InboundControlTest` has "a reply
* The receiving half already worked, `InboundControlTest` has "a reply
* is still renderable", and `Content.Reference` was in use for reactions. What
* was missing was any way to START one: no Reply in the menu, no composer
* state, and `Session.send` had nowhere to put a reference. So these tests are
@@ -31,10 +31,10 @@ class ReplyWireTest {
}
/**
* 🔴 **The preview travels, and this is why.** A recipient who joined the
* The preview travels, and this is why. A recipient who joined the
* thread later, or cleared their history, has never seen the message being
* answered. A reply rendering as "> [missing]" is worse than one that shows
* what was said — so the sender's own copy of the quoted words goes with
* what was said, so the sender's own copy of the quoted words goes with
* it, and dropping the field to save bytes is not a free optimisation.
*/
@Test
@@ -45,7 +45,7 @@ class ReplyWireTest {
}
/**
* ⚠️ A message with nothing extra to carry still goes out as raw bytes, so
* A message with nothing extra to carry still goes out as raw bytes, so
* an older peer reads it as text. Adding a reply must not have changed
* that for messages that are not replies.
*/
@@ -9,9 +9,9 @@ import kotlin.test.assertTrue
/**
* Scheduled send: the drain rule.
*
* 🔴 **"Not due yet" must mean KEEP, never fail.** The outbox loop is also the
* "Not due yet" must mean KEEP, never fail. The outbox loop is also the
* retry queue, so a scheduled entry that fell through the failure path would be
* dropped outright — the message would simply never exist. This asserts the
* dropped outright, the message would simply never exist. This asserts the
* predicate the drain uses, which is the whole of the risk.
*/
class ScheduledSendTest {
@@ -23,7 +23,7 @@ class ScheduledSendTest {
@Test
fun `an entry with no schedule sends immediately`() {
// ⚠️ Entries queued before scheduling existed carry no `not_before`,
// Entries queued before scheduling existed carry no `not_before`,
// and must not be held for a date they have no value for.
assertTrue(!isHeld(JSONObject().put("to", 1).put("text", "hi"), now))
}
@@ -40,7 +40,7 @@ class ScheduledSendTest {
assertTrue(!isHeld(item, now))
}
/** Exactly at the moment it is due, it goes — off-by-one delays by a cycle. */
/** Exactly at the moment it is due, it goes, off-by-one delays by a cycle. */
@Test
fun `an entry due exactly now sends`() {
val item = JSONObject().put("to", 1).put("text", "hi").put("not_before", now)
@@ -23,19 +23,19 @@ import java.io.File
/**
* Renders screens to PNG on the JVM. No emulator, no device, no 8 GB AVD.
*
* 🔴 **This exists because looking at a layout should not cost an emulator.**
* This exists because looking at a layout should not cost an emulator.
* Verifying the design pass meant booting an AVD, installing an APK, tapping
* to a screen and pulling a screencap — and the emulator was killed by memory
* to a screen and pulling a screencap, and the emulator was killed by memory
* pressure four times in one session, each time mid-check. Robolectric's
* native graphics mode draws the same Compose pixels on the JVM in seconds.
*
* ⚠️ **Not assertions — output.** These write files and pass as long as
* Not assertions, output. These write files and pass as long as
* nothing throws. That is the honest description: a screenshot test that
* asserts against a stored baseline is a different thing, worth adding once
* the design has stopped moving. Right now the design IS moving, and a
* baseline would be re-blessed on every change, which proves nothing.
*
* ⚠️ **Rendered at the kit's own canvas**, 375x812, so a PNG here can be put
* Rendered at the kit's own canvas, 375x812, so a PNG here can be put
* beside the Sketch artboard and compared directly rather than mentally
* rescaled from a 1080x2400 phone.
*
@@ -46,9 +46,9 @@ import java.io.File
@Config(sdk = [34], qualifiers = "w375dp-h812dp-normal-long-notround-any-440dpi-keyshidden-nonav")
class ScreenshotTest {
// ⚠️ **An Activity rule, not `createComposeRule()`.** The plain rule hosts
// An Activity rule, not `createComposeRule()`. The plain rule hosts
// the tree in a bare ComposeView, and `captureToImage` then goes down the
// window-capture path looking for a real window to redraw — which under
// window-capture path looking for a real window to redraw, which under
// Robolectric never signals, and the whole thing dies as
// "Condition still not satisfied after 2000 ms". That reads like a hung
// composition and is nothing of the sort.
@@ -72,9 +72,9 @@ class ScreenshotTest {
/**
* A whole screen, with a fabricated phase and an idle view model.
*
* ⚠️ The screens take their state as `phase` and only touch the model when
* The screens take their state as `phase` and only touch the model when
* something is tapped, so an unstarted model is enough to render them. It
* does open the Keystore vault in `init` — under Robolectric that fails
* does open the Keystore vault in `init`, under Robolectric that fails
* inside a `runCatching` on a background dispatcher, which is why this
* works at all rather than by design. If it starts hanging, that is the
* first place to look.
@@ -118,7 +118,7 @@ class ScreenshotTest {
}
/**
* A conversation. The one screen with no render until now — it was checked
* A conversation. The one screen with no render until now, it was checked
* on the emulator before the palette changed, and not since.
*/
@Test fun chatScreen() = shootFullScreen("screen-chat") { model, phase ->
@@ -146,7 +146,7 @@ class ScreenshotTest {
// out, so no device took it. It waits in the outbox and says so.
Session.Line("Are you around later?", mine = true, at = 1_756_300_440_000,
delivery = Session.Delivery.PENDING),
// A sticker draws bare — no bubble — so this catches the case where
// A sticker draws bare, no bubble, so this catches the case where
// it falls through to the text path and renders as an empty one.
Session.Line("", mine = false, at = 1_756_300_500_000,
sticker = Session.Sticker("flamenet.core", "wave")),
@@ -160,7 +160,7 @@ class ScreenshotTest {
}
}
/** A call in progress — renderable because the look takes plain values. */
/** A call in progress, renderable because the look takes plain values. */
@Test fun callActive() = shoot("screen-call-active") {
CallScreenBody(
peerName = "Hoang Nguyen",
@@ -204,7 +204,7 @@ class ScreenshotTest {
/**
* Render one composable inside the app's theme and write it out.
*
* ⚠️ `Surface` with the theme background, not a bare composable: without a
* `Surface` with the theme background, not a bare composable: without a
* background the PNG has a transparent ground and every colour judgement
* made from it is wrong.
*/
@@ -271,7 +271,7 @@ class ScreenshotTest {
),
)
/** The same screen with nothing on it — the state a new account sees. */
/** The same screen with nothing on it, the state a new account sees. */
@Test fun alertsEmpty() = shoot("screen-alerts-empty") {
val app = androidx.test.core.app.ApplicationProvider
.getApplicationContext<android.app.Application>()
@@ -302,10 +302,10 @@ class ScreenshotTest {
}
compose.waitForIdle()
// 🔴 **Draw the view, do not `captureToImage()`.** Compose's capture
// Draw the view, do not `captureToImage()`. Compose's capture
// helper goes through `PixelCopy` and waits for a frame-commit
// callback that Robolectric never delivers, so every render died as
// "Condition still not satisfied after 2000 ms" — which reads as a
// "Condition still not satisfied after 2000 ms", which reads as a
// hung composition and is nothing of the sort. `View.draw` into a
// software canvas produces the same pixels with no window involved.
var shot: Bitmap? = null
@@ -22,13 +22,13 @@ import org.robolectric.annotation.Config
/**
* Which roster the group list believes, when the relay offers a different one.
*
* 🔴 **The relay must not win.** A sealed group's members come out of a blob
* The relay must not win. A sealed group's members come out of a blob
* the owner signed. `GET /e2e/groups` carries no signature at all, so if the
* plaintext answer were preferred — or merely allowed to shadow the sealed one
* for the same gid — the server would be back in charge of deciding who is in
* plaintext answer were preferred, or merely allowed to shadow the sealed one
* for the same gid, the server would be back in charge of deciding who is in
* a group, which is the whole thing sealing exists to stop.
*
* ⚠️ Robolectric for the same reason as the other `:app` tests: this is an
* Robolectric for the same reason as the other `:app` tests: this is an
* Android module, so its unit-test classpath has the stubbed `org.json` that
* returns null from everything, and the vault stores JSON.
*/
@@ -45,7 +45,7 @@ class SealedGroupListTest {
private var plaintextRoster: List<Long> = emptyList()
/**
* Answers by path, not by queue order — `namedGroups()` makes two calls and
* Answers by path, not by queue order, `namedGroups()` makes two calls and
* a fixture matched on order hands one of them the other's body.
*/
private inner class Relay : okhttp3.mockwebserver.Dispatcher() {
@@ -122,7 +122,7 @@ class SealedGroupListTest {
}
/**
* 🔴 The relay names a third account as a member of a group whose roster is
* The relay names a third account as a member of a group whose roster is
* sealed. The blob says otherwise, and the blob is what the owner signed.
*/
@Test
@@ -150,8 +150,8 @@ class SealedGroupListTest {
}
/**
* ⚠️ A group we hold no secrets for is still the relay's to describe. That
* is the unsealed model, unchanged — dropping those would make somebody's
* A group we hold no secrets for is still the relay's to describe. That
* is the unsealed model, unchanged, dropping those would make somebody's
* older groups vanish the moment sealing shipped.
*/
@Test
@@ -12,8 +12,8 @@ import org.junit.Test
*
* These guard a bug that shipped silently: `discovered` was a `var` in the
* class body, so it was excluded from the data class `equals()`. Writing the
* discovered config back into a `MutableStateFlow` — which conflates on
* equality — was a no-op. The relay answered correctly and the app ignored it:
* discovered config back into a `MutableStateFlow`, which conflates on
* equality, was a no-op. The relay answered correctly and the app ignored it:
* the sign-in screen sat on "Checking what this server uses…" forever, and the
* account mode stayed LOCAL for a relay that had just said SHARED.
*/
@@ -8,15 +8,15 @@ import kotlin.test.assertTrue
/**
* Reporting a story (spec §6.25, ios#56).
*
* 🔴 **A report is the one thing this app sends that the server can read.**
* A report is the one thing this app sends that the server can read.
* Everything else is sealed. The relay cannot open a story, so a report about
* one is only actionable if the reporter hands over what they saw — which makes
* one is only actionable if the reporter hands over what they saw, which makes
* the consent, and the exact set of things that travel, the feature.
*/
class StoryReportTest {
/**
* ⚠️ The wire values must match what the relay accepts. A reason the relay
* The wire values must match what the relay accepts. A reason the relay
* rejects is a report somebody believed they had filed.
*/
@Test
@@ -37,7 +37,7 @@ class StoryReportTest {
}
/**
* ⚠️ CSAM must be reportable as itself rather than folded into abuse: it
* CSAM must be reportable as itself rather than folded into abuse: it
* carries a different obligation, and an operator cannot triage what
* nobody can name.
*/
@@ -48,7 +48,7 @@ class StoryReportTest {
}
/**
* ⚠️ Without one, the picker forces a person to mis-categorise in order to
* Without one, the picker forces a person to mis-categorise in order to
* report at all.
*/
@Test
@@ -7,9 +7,9 @@ import kotlin.test.assertTrue
/**
* How the wallet screen states a number.
*
* 🔴 **The rule this suite exists for: an unread balance is NOT zero.** A screen
* The rule this suite exists for: an unread balance is NOT zero. A screen
* that draws 0.00 because a node did not answer has told somebody their money is
* gone — on every flaky connection. The screen renders null as an em dash, and
* gone, on every flaky connection. The screen renders null as an em dash, and
* these cover the arithmetic underneath it.
*/
class WalletDisplayTest {
@@ -30,7 +30,7 @@ class WalletDisplayTest {
}
/**
* ⚠️ Zero is a real balance and reads as "0", not as an empty string. The
* Zero is a real balance and reads as "0", not as an empty string. The
* trimming that removes a decimal tail must not eat the whole number.
*/
@Test
@@ -39,7 +39,7 @@ class WalletDisplayTest {
}
/**
* ⚠️ Five SOL already exceeds Int.MAX_VALUE in lamports. Anything holding a
* Five SOL already exceeds Int.MAX_VALUE in lamports. Anything holding a
* balance in an Int would overflow long before the number got interesting.
*/
@Test
@@ -50,9 +50,9 @@ class WalletDisplayTest {
// ---- What can actually be sent ---------------------------------------
/**
* 🔴 **An account below the rent-exempt minimum is RECLAIMED by the
* **An account below the rent-exempt minimum is RECLAIMED by the
* network.** Emptying a wallet to the last lamport does not leave it empty,
* it deletes it — so "send everything" has to stop short of everything.
* it deletes it, so "send everything" has to stop short of everything.
*/
@Test
fun `spendable leaves the fee and the rent-exempt minimum behind`() {
@@ -81,7 +81,7 @@ class WalletDisplayTest {
// ---- Which node ------------------------------------------------------
/**
* 🔴 **http is ignored rather than honoured.** A balance read over plain
* http is ignored rather than honoured. A balance read over plain
* http is one anybody on the path can rewrite, and a wallet showing a number
* an attacker chose is worse than a wallet showing nothing.
*/