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
+4 -4
View File
@@ -4,16 +4,16 @@
whose protocol vectors the `unit-tests` job checks against. `AccountKdfTest`,
`PQVectorTest` and `PublishedVectorsTest` all read them.
🔴 **It exists because a merge in that repository could stop all work in this
**It exists because a merge in that repository could stop all work in this
one.** flamenet-relay learned this the hard way: its jobs cloned `main` at HEAD,
and the switch got thrown three times in twelve hours (flamenet-relay#56). Once
it was a deadlock with no way out from inside either repository — that repo
it was a deadlock with no way out from inside either repository, that repo
expected a change this one had not made, so every pipeline failed, *including
the pipeline for the branch that made it*. See `.e2e-ref.md` there for the full
account; the conclusion was that a pin makes that situation impossible rather
than survivable.
⚠️ **A pin alone is worse than HEAD, though.** The argument for HEAD is real: a
**A pin alone is worse than HEAD, though.** The argument for HEAD is real: a
client tested against a pinned old harness is tested against a pinned old spec,
and if the vectors move and nothing says so, this suite quietly stops checking
the protocol anybody is actually running. That is why `vectors-head` exists and
@@ -27,6 +27,6 @@ is `allow_failure: true`.
One MR, both parts together:
1. Put the new e2e commit in `.e2e-ref`.
2. If the vectors themselves changed, read what changed before accepting it —
2. If the vectors themselves changed, read what changed before accepting it,
a moved vector is a protocol change, and this app is one of the two ends
that has to agree about it.
+22 -22
View File
@@ -11,7 +11,7 @@ unit-tests:
- if: '$CI_COMMIT_BRANCH == "main"'
variables:
GIT_DEPTH: "1"
# 🔴 The interop tests FAIL rather than skip when unconfigured, on purpose —
# The interop tests FAIL rather than skip when unconfigured, on purpose,
# a silent skip is how a transport suite goes green while proving nothing.
# They need a real lyrebird from the flamenet-elligator2 checkout, which is
# not in this build directory, so they are opted out HERE and nowhere else.
@@ -22,17 +22,17 @@ unit-tests:
script:
- export JAVA_HOME=/opt/homebrew/opt/openjdk@21
- export ANDROID_HOME=$HOME/Library/Android/sdk
# 🔴 **The protocol vectors are not in this repo, and CI never had them.**
# The protocol vectors are not in this repo, and CI never had them.
# `AccountKdfTest`, `PQVectorTest` and `PublishedVectorsTest` look for a
# sibling `flamenet-e2e` checkout. That exists on a developer's machine and
# has never existed in a build directory, so all twenty of them have been
# failing here since they were written — invisibly, because the build cache
# failing here since they were written, invisibly, because the build cache
# replayed a local pass. They fail rather than skip when the file is
# missing, exactly so this could not hide; it took `--rerun-tasks` to let
# them say so.
#
# 🔴 **Pinned to a commit, NOT cloned at tip.** Tip was the first instinct —
# "if the protocol changes this SHOULD go red" — and flamenet-relay#56 is
# Pinned to a commit, NOT cloned at tip. Tip was the first instinct,
# "if the protocol changes this SHOULD go red", and flamenet-relay#56 is
# the write-up of what that actually costs. There, a job clones
# `flamenet-e2e` at HEAD, so merging anything in that repo turned relay's
# `main` red in a pipeline nobody was watching, blocked six MRs sitting on
@@ -44,9 +44,9 @@ unit-tests:
# of a surprise failure in a repo nobody was thinking about. The commit
# lives in `.e2e-ref`; `.e2e-ref.md` says how to take an update.
#
# ⚠️ **A pin ALONE is worse than HEAD**, and that is what the first version
# A pin ALONE is worse than HEAD, and that is what the first version
# of this got wrong. Pinning silently means the vectors can move and this
# suite goes on checking last month's protocol, reporting green — the exact
# suite goes on checking last month's protocol, reporting green, the exact
# "tested against a pinned old spec" failure the relay's own note warns
# about. `vectors-head` below is the other half: it runs the same checks at
# HEAD and is allowed to fail, so being behind is LOUD and non-blocking
@@ -60,39 +60,39 @@ unit-tests:
- export FLAMENET_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/protocol-v1.json"
- export FLAMENET_PQ_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/pqx3dh-v2.json"
- export FLAMENET_ACCOUNT_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/account-kdf-v1.json"
# ⚠️ Checked before the suite runs. A path that silently does not exist puts
# Checked before the suite runs. A path that silently does not exist puts
# the tests straight back where they were, and the failure would read as a
# protocol bug rather than a missing file.
- |
for f in "$FLAMENET_VECTORS" "$FLAMENET_PQ_VECTORS" "$FLAMENET_ACCOUNT_VECTORS"; do
[ -f "$f" ] || { echo "GATE: vector file missing: $f"; exit 1; }
done
# 🔴 **Clear old results before running, or the gate counts ghosts.** The
# Clear old results before running, or the gate counts ghosts. The
# runner reuses its build directory, so XML from a previous pipeline
# survives — and a suite that stopped running entirely would still be
# survives, and a suite that stopped running entirely would still be
# counted, by the very check meant to notice that it had stopped.
- rm -rf ./*/build/test-results
# 🔴 **`:obfs4:test` was missing from this list.** The module shipped in !77
# with ~100 tests — the Elligator2 port, the framing, the handshake, the
# socket factory — and not one of them ran in CI. A break in the transport
# `:obfs4:test` was missing from this list. The module shipped in !77
# with ~100 tests, the Elligator2 port, the framing, the handshake, the
# socket factory, and not one of them ran in CI. A break in the transport
# would have merged green.
#
# 🔴 **`--rerun-tasks` because this runner is the developer's own Mac.**
# `--rerun-tasks` because this runner is the developer's own Mac.
# Gradle's build cache is shared with local runs, so a task whose inputs a
# local run already covered comes back FROM-CACHE and CI replays a result it
# never computed. That is not independent verification, and it is invisible:
# a cached pass and a real pass print the same "BUILD SUCCESSFUL".
- ./gradlew --no-daemon --rerun-tasks :engine:test :client:test :obfs4:test :app:testDebugUnitTest
- |
# 🔴 **Assert on the RESULTS, never on Gradle's exit status.** `:app` prints
# Assert on the RESULTS, never on Gradle's exit status. `:app` prints
# no per-test names at all, so a filter matching nothing and a full green
# run are indistinguishable in the log — and a skipped-as-up-to-date task
# run are indistinguishable in the log, and a skipped-as-up-to-date task
# is likewise just "BUILD SUCCESSFUL". The XML is the only thing that says
# what actually executed.
python3 - <<'PY'
import glob, re, sys
# ⚠️ Floors, not exact counts. Set around 90% of the suite as it stands
# Floors, not exact counts. Set around 90% of the suite as it stands
# (539 / 403 / 75 / 145 on 2026-09-06) so a module that loses most of its
# tests to a bad filter is caught, without a gate that has to be edited
# every time somebody adds one.
@@ -113,11 +113,11 @@ unit-tests:
for module, (tests, failures, skipped) in totals.items():
print(f"GATE: {module}: {tests} tests, {failures} failures, {skipped} skipped")
if tests < MINIMUMS[module]:
print(f"GATE: {module} reported {tests} tests — the suite did not run")
print(f"GATE: {module} reported {tests} tests, the suite did not run")
bad = True
if failures:
bad = True
# ⚠️ A floor across the whole build as well as per module: a module can be
# A floor across the whole build as well as per module: a module can be
# present and still have lost most of its suite to a bad filter.
grand = sum(t for t, _, _ in totals.values())
print(f"GATE: {grand} tests in total")
@@ -128,9 +128,9 @@ unit-tests:
PY
timeout: 30m
# 🔴 **The pin's conscience.** Runs the vector suites against flamenet-e2e's
# The pin's conscience. Runs the vector suites against flamenet-e2e's
# actual HEAD, and is allowed to fail. Without it, `.e2e-ref` is permission to
# fall behind forever with a green board — which is the failure mode a pin
# fall behind forever with a green board, which is the failure mode a pin
# introduces and the reason the relay pairs its pin with the same job.
#
# A red run here means an update is waiting. Take it per `.e2e-ref.md`.
@@ -155,7 +155,7 @@ vectors-head:
- export FLAMENET_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/protocol-v1.json"
- export FLAMENET_PQ_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/pqx3dh-v2.json"
- export FLAMENET_ACCOUNT_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/account-kdf-v1.json"
# ⚠️ Only the module that reads vectors. The rest of the suite has nothing
# Only the module that reads vectors. The rest of the suite has nothing
# to say about whether flamenet-e2e moved, and running it here would double
# the board's cost to answer a question it cannot answer.
- ./gradlew --no-daemon --rerun-tasks :engine:test
+15 -9
View File
@@ -2,7 +2,7 @@
The Android client for Flamenet Messenger, in Kotlin and Compose. Three modules:
`engine` (the encryption primitives), `client` (protocol and transport), `app`
(UI). Feature parity with iOS is tracked in [PARITY.md](PARITY.md) — read it
(UI). Feature parity with iOS is tracked in [PARITY.md](PARITY.md), read it
before assuming something is missing by accident.
## Security bugs do not go in an issue
@@ -11,8 +11,8 @@ Email **security@flamenetmessenger.com**. See [SECURITY.md](SECURITY.md).
## Building
JDK 21 and the Android SDK. On a machine with no default Java — which is the
usual state of a Mac — Gradle reports "Unable to locate a Java Runtime" rather
JDK 21 and the Android SDK. On a machine with no default Java, which is the
usual state of a Mac. Gradle reports "Unable to locate a Java Runtime" rather
than anything about JDKs, so set it explicitly:
```bash
@@ -23,8 +23,8 @@ export ANDROID_HOME=$HOME/Library/Android/sdk
Those three suites are the merge gate.
🔴 **JVM crypto tests prove nothing about Android.** The JVM's JCA and Android's
Conscrypt are different providers with different opinions — X25519/X448 need DER
**JVM crypto tests prove nothing about Android.** The JVM's JCA and Android's
Conscrypt are different providers with different opinions. X25519/X448 need DER
key specs and API 33 on device, and code that passes `:engine:test` on the JVM
can throw on a handset. Anything touching key agreement or signatures needs an
instrumented test, which is why `interop/` exists.
@@ -36,8 +36,8 @@ Instrumented tests are not in the gate: they need an emulator and a local relay.
- **Touching the wire?** iOS and the relay move with you. A field this client
required and the relay never sent broke groups completely while every unit
test stayed green. **Diff the wire types first.**
- **Touching state that arrives incrementally** — avatars, rosters, device
lists — merge, never replace. Absent from a fetch means "not loaded yet", not
- **Touching state that arrives incrementally**: avatars, rosters, device
lists, merge, never replace. Absent from a fetch means "not loaded yet", not
"deleted".
- **Touching WebRTC callbacks?** Never let an exception escape one. It does not
surface as a caught error, it `SIGABRT`s the process. Calls have crashed this
@@ -51,7 +51,7 @@ Instrumented tests are not in the gate: they need an emulator and a local relay.
## Releases
The signed release APK is built with a keystore that is **not in this
repository and never will be** — it is public. Signing keys are permanent for
repository and never will be**, it is public. Signing keys are permanent for
the life of an installed app: a key change means every existing user must
uninstall and lose their local history, so the certificate identity is not
something to fix later.
@@ -62,5 +62,11 @@ something to fix later.
are refusals, and the user is told which.
- Never log message contents, keys, or tokens.
- No secrets, keystores, or passphrases in the repository.
- Match the surrounding style. Comments explain why a line exists — usually,
- Match the surrounding style. Comments explain why a line exists, usually,
which attack or which platform quirk it accounts for.
## Comments and prose
Plain text. No emoji or symbol markers, no markdown bold inside code comments,
no em-dashes. Say what the code does and why, briefly; the history of how it
got that way belongs in the commit message, not in the file.
+315 -319
View File
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -1,4 +1,4 @@
# Flamenet Messenger — Android
# Flamenet Messenger. Android
The Android app, and the two libraries under it.
@@ -12,7 +12,7 @@ interop/ the cross-engine driver; not shipped.
`engine` and `client` are deliberately plain JVM libraries. The protocol has
nothing to do with Android, and keeping it off the platform means their tests
run anywhere a JVM does — no SDK, no emulator, no device, which is also what
run anywhere a JVM does, no SDK, no emulator, no device, which is also what
lets them gate in CI on Linux:
```
@@ -28,15 +28,15 @@ The app's own unit tests need the Android SDK but still no emulator
## Where it stands
**[PARITY.md](PARITY.md) is the definition of done for this app — read that,
**[PARITY.md](PARITY.md) is the definition of done for this app, read that,
not this.** Every row there names the iOS file it came from so it can be
re-checked rather than trusted. Headlines as of 2026-08-28: messaging, groups,
attachments, voice notes, stories, calls, sealed sender, key transparency and
the encrypted vault are built and tested; a cross-platform round trip against
iOS was verified on 2026-08-26.
⚠️ **This file said "Nothing here is an app yet" until 2026-08-28**, above a
tree containing the finished app — the README described the repository as it
**This file said "Nothing here is an app yet" until 2026-08-28**, above a
tree containing the finished app, the README described the repository as it
was when the engine landed and nothing updated it as the app grew around it. If
a claim here ever disagrees with PARITY.md or with the tree, believe those, and
fix this.
@@ -45,11 +45,11 @@ fix this.
An Android client needs the protocol implemented a third time, and the third
implementation is the one that finds the places where the specification says
less than the two existing engines quietly agreed on — but only if it is
less than the two existing engines quietly agreed on, but only if it is
written against the spec and the published vectors rather than against a
reading of our own Swift and JavaScript. So the engine is held to
`test/vectors/protocol-v1.json` in
[flamenet-e2e](https://gitlab.com/paulhitt/flamenet-e2e) — the same file the
[flamenet-e2e](https://gitlab.com/paulhitt/flamenet-e2e), the same file the
other two engines are checked against, read from that repository rather than
copied here, because a copied vector file drifts and a drifting vector file
stops being a check. `PublishedVectorsTest` looks for it beside this checkout
@@ -64,14 +64,14 @@ Kotlin tests assert both that the published path verifies and that the same
path reversed does not.
If this engine ever disagrees with the file, the interesting question is which
of the three is wrong — not this one by default.
of the three is wrong, not this one by default.
## Contributing and security
- [CONTRIBUTING.md](CONTRIBUTING.md) — building (JDK 21, `JAVA_HOME` is not
- [CONTRIBUTING.md](CONTRIBUTING.md), building (JDK 21, `JAVA_HOME` is not
optional on a Mac), the test gate, and the platform traps that cost an hour
each.
- [PARITY.md](PARITY.md) — what iOS does, what this does, and what is left. It
- [PARITY.md](PARITY.md), what iOS does, what this does, and what is left. It
is the definition of done for this app.
- [SECURITY.md](SECURITY.md) — threat model and where to send a vulnerability
- [SECURITY.md](SECURITY.md), threat model and where to send a vulnerability
report. **Not an issue tracker matter:** security@flamenetmessenger.com.
+2 -2
View File
@@ -14,7 +14,7 @@ cross-platform interop tests that run the real engine on both sides.
An internal adversarial audit on 2026-08-29 found and fixed a family of
sender-attribution flaws: the ratchet authenticates a *device*, and the user id
inside a sealed envelope is a claim its author chooses — the two were not bound.
inside a sealed envelope is a claim its author chooses, the two were not bound.
Devices now bind to their user on first proven use, control traffic (delivery
keys, avatars) is refused from an unproven sender, and the key-transparency log
head is verified against a pinned issuer key. Finding that class of bug in-house
@@ -26,7 +26,7 @@ Private keys are generated on the device and live in the Android Keystore, which
sealed at rest. The app defends against a **malicious or compromised relay**
reading messages, and against a passive adversary recording traffic to decrypt
after a quantum computer exists (when both ends publish a post-quantum
identity — the safety number says which guarantee it covers).
identity, the safety number says which guarantee it covers).
It does **not** defend against:
+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.
*/
+1 -1
View File
@@ -3,7 +3,7 @@
// They have to be in one place: `:engine` and `:client` are Kotlin/JVM while
// `:app` is Kotlin/Android, and declaring a version in a subproject when the
// same plugin is already on the root classpath fails with "already on the
// classpath with an unknown version" — which reads as a dependency conflict
// classpath with an unknown version", which reads as a dependency conflict
// rather than as a misplaced version number.
plugins {
kotlin("jvm") version "2.4.0" apply false
@@ -9,7 +9,7 @@ import io.flamenet.e2e.FNID
* ## Why this exists at all
*
* A relay's `user_id` is a row number it assigned itself. Register at one relay
* and everything about you — your name there, your devices, your mailbox — is
* and everything about you, your name there, your devices, your mailbox, is
* that relay's to keep or to lose. This makes the *identity* the user's
* instead: an Ed25519 key stretched from the passphrase, whose public half is
* the address. No server issues it, so no server can take it away.
@@ -35,9 +35,9 @@ object AccountIdentity {
* Claim the address at [relay], learning which audience to bind from the
* relay itself.
*
* ⚠️ **The audience is not the caller's to guess.** It is the string the
* The audience is not the caller's to guess. It is the string the
* relay checks the signature against, so a caller that inferred it from a
* URL would produce a claim that verifies nowhere — silently, because at
* URL would produce a claim that verifies nowhere, silently, because at
* the point of failure a wrong audience and a wrong key look identical.
* Reading it from the descriptor means the only way to get it wrong is for
* the relay to lie about its own name, which costs it the claim rather than
@@ -45,7 +45,7 @@ object AccountIdentity {
*
* Self-certifying: the signature is made with the very key being
* registered, so the relay confirms possession and forms no opinion about
* whose key it is — the property that lets a hostile relay be tolerated.
* whose key it is, the property that lets a hostile relay be tolerated.
*
* Returns the address the relay recorded.
*/
@@ -65,7 +65,7 @@ object AccountIdentity {
* for the record would be handed a stale list and would skip the others.
* Delivering the record everywhere is what keeps them from disagreeing.
*
* Takes a client **per home**, and that is the shape of the feature rather
* Takes a client per home, and that is the shape of the feature rather
* than an implementation detail: a relay will not accept a record from
* somebody with no account there, and a token minted by one relay is
* rejected by every other because the audience is bound. So homing
@@ -95,7 +95,7 @@ object AccountIdentity {
* than from local state.
*
* A device that was offline while another added a relay would otherwise
* reuse a serial and have its record refused as a rollback — by the very
* reuse a serial and have its record refused as a rollback, by the very
* check that exists to stop an attacker doing the same thing.
*/
fun nextSerial(fnid: String, clients: Collection<RelayClient>): Int {
@@ -110,7 +110,7 @@ object AccountIdentity {
/**
* Check a home record against the address it claims to be for.
*
* Needs nothing but the record, because the address *is* the public key —
* Needs nothing but the record, because the address *is* the public key,
* which is the point. A sender must not have to ask a relay whether that
* relay is telling the truth about where to deliver.
*/
@@ -124,11 +124,11 @@ object AccountIdentity {
* Relays named by a record, but only once it verifies AND is not older than
* one already seen.
*
* 🔴 **A signature alone does not stop a rollback, and that is the whole
* **A signature alone does not stop a rollback, and that is the whole
* reason the serial exists.** An older record is *genuinely signed*: it was
* published by the account holder, before they added a second home. A relay
* that wants to be somebody's only mailbox does not have to forge anything
* — it serves the record it saw first, that record verifies perfectly, and
* it serves the record it saw first, that record verifies perfectly, and
* the sender delivers to one relay believing it is delivering to all of
* them.
*
@@ -136,11 +136,11 @@ object AccountIdentity {
* clients checked only the signature. The relay refuses a lower serial on
* WRITE; nothing refused one on READ.
*
* ⚠️ [floor] is the highest serial this device has already accepted for
* this address, or null on first sight. Equal is fine — the same record
* [floor] is the highest serial this device has already accepted for
* this address, or null on first sight. Equal is fine, the same record
* re-read is the ordinary case. Only strictly lower is a rollback.
*
* ⚠️ **A refusal here is not proof of an attack**, and the caller must not
* A refusal here is not proof of an attack, and the caller must not
* present it as one. A relay that has genuinely fallen behind serves an old
* record for honest reasons; what the client can say is that it has seen a
* newer one, and that it will keep using it.
@@ -10,17 +10,17 @@ import org.json.JSONObject
import java.security.SecureRandom
/**
* The shared FlameNet account service — one credential across Messenger and
* The shared FlameNet account service, one credential across Messenger and
* Files.
*
* ⚠️ **The passphrase never leaves the device.** It is stretched here and only
* The passphrase never leaves the device. It is stretched here and only
* the derived secret is sent, which is why sign-in is two round trips rather
* than one: the salt and iteration count are per account, and the service
* cannot derive the secret itself. That is the entire point, and collapsing it
* to one call would mean sending the passphrase.
*
* Used only for relays whose accounts live in the shared service. A self-hosted
* relay authenticates against its own table and never touches this — see
* relay authenticates against its own table and never touches this, see
* [SiteConfig], which decides from the hostname rather than from anything the
* relay says about itself.
*/
@@ -34,7 +34,7 @@ class AccountsClient(
/**
* The product a session is scoped to.
*
* ⚠️ **Sessions are per product, and that is the privacy property.** The
* Sessions are per product, and that is the privacy property. The
* token carries a pseudonym derived for this product, so Messenger and
* Files receive different opaque ids for the same person and cannot
* correlate their users with each other.
@@ -44,9 +44,9 @@ class AccountsClient(
/**
* What the service advertises for a handle.
*
* ⚠️ An unknown handle gets a **deterministic decoy** salt, not an error,
* An unknown handle gets a deterministic decoy salt, not an error,
* so this route cannot be used to test whether somebody has an account.
* Treat a success as "here are parameters", never as "this handle exists" —
* Treat a success as "here are parameters", never as "this handle exists",
* a UI that says "no such account" here undoes the whole design.
*/
data class Params(val salt: ByteArray, val kdfId: Int, val iterations: Int)
@@ -54,7 +54,7 @@ class AccountsClient(
/**
* @property accountKeySeed the 32-byte Ed25519 seed whose public half is
* this account's address. Derived from the same stretch as the auth
* secret and never sent anywhere — see [AccountKdf.signInSecrets] for
* secret and never sent anywhere, see [AccountKdf.signInSecrets] for
* why it comes back here rather than being asked for separately.
*/
data class Session(
@@ -69,23 +69,23 @@ class AccountsClient(
object BadCredentials : Failure("that handle or passphrase isn't right")
/**
* The session is no longer accepted — expired, or revoked elsewhere.
* The session is no longer accepted, expired, or revoked elsewhere.
*
* 🔴 **Not the same as a wrong passphrase, and saying so matters.**
* Not the same as a wrong passphrase, and saying so matters.
* Every 401 and 403 used to become [BadCredentials], so a session that
* simply aged out told the user "that handle or passphrase isn't
* right" — in the middle of sending a message, hours after they signed
* right", in the middle of sending a message, hours after they signed
* in successfully. The natural reading is that their password stopped
* working or was changed by someone else, and the natural response is
* to change a password that was never wrong.
*/
object SessionExpired : Failure("your session has expired — sign in again")
object SessionExpired : Failure("your session has expired, sign in again")
object HandleTaken : Failure("that handle is already taken")
/**
* 🔴 **A 409 is not always a taken handle, and saying so was wrong.**
* A 409 is not always a taken handle, and saying so was wrong.
* The relay answers 409 for a taken handle, a registered email, AND a
* handle too close to an existing one — three different problems with
* handle too close to an existing one, three different problems with
* three different fixes. Collapsing them told somebody whose EMAIL was
* already registered to pick a different handle, which cannot work and
* gives them no way to discover why.
@@ -95,7 +95,7 @@ class AccountsClient(
*/
object EmailTaken : Failure("an account with that email already exists")
/** The handle folds onto an existing one — see the relay's
/** The handle folds onto an existing one, see the relay's
* `User.confusableSkeleton`. Changing a digit will not help. */
object HandleConfusable : Failure("that handle is too easily mistaken for another")
@@ -103,7 +103,7 @@ class AccountsClient(
object BadHandle : Failure("that handle isn't allowed")
object BadEmail : Failure("that doesn't look like an email address")
object WeakPassword : Failure("that passphrase is too short")
object RateLimited : Failure("too many attempts — wait a few minutes")
object RateLimited : Failure("too many attempts, wait a few minutes")
class Service(val status: Int) : Failure("the account service returned $status")
class Transport(cause: String) : Failure("couldn't reach the account service: $cause")
}
@@ -121,9 +121,9 @@ class AccountsClient(
fun signIn(handle: String, passphrase: String, product: String = defaultProduct): Session {
val params = parameters(handle)
// ⚠️ The ADVERTISED iteration count wins over the local constant. An
// The ADVERTISED iteration count wins over the local constant. An
// account created under an older count must still be derivable, and the
// service is the one that knows which — hardcoding it here silently
// service is the one that knows which, hardcoding it here silently
// locks out every older account.
val effective = AccountKdf.Params(
id = params.kdfId,
@@ -131,7 +131,7 @@ class AccountsClient(
saltBytes = params.salt.size,
)
// Both secrets from ONE stretch. Asking for the account key separately
// would re-run 650,000 iterations for a value already in hand — several
// would re-run 650,000 iterations for a value already in hand, several
// seconds on a phone, spent twice, to sign in once.
val (secret, accountKeySeed) = AccountKdf.signInSecrets(passphrase, params.salt, effective)
@@ -184,16 +184,16 @@ class AccountsClient(
} catch (_: Exception) {
throw Failure.Transport("unreadable response")
}
// ⚠️ The SAME status means different things depending on what
// The SAME status means different things depending on what
// was sent. Rejecting a passphrase is BadCredentials; refusing
// a token the user cannot see is an expired session.
401, 403 ->
throw if (carriesCredentials) Failure.BadCredentials
else Failure.SessionExpired
// 🔴 **Read the relay's own code, not just the status.** A
// Read the relay's own code, not just the status. A
// 400 became a bare "the account service returned 400" and a
// 409 became "that handle is already taken" whatever the
// actual reason — so a weak passphrase, an unusable email and
// actual reason, so a weak passphrase, an unusable email and
// an already-registered address were all reported as something
// the person could not act on, or as the wrong problem
// entirely. The relay has always sent a specific code; nothing
@@ -208,7 +208,7 @@ class AccountsClient(
/**
* The relay's error code, or null when the body is not its error shape.
*
* ⚠️ Never throws. A body that cannot be parsed is a transport problem, not
* Never throws. A body that cannot be parsed is a transport problem, not
* a reason to lose the status we already have.
*/
private fun code(body: String): String? = runCatching {
@@ -216,7 +216,7 @@ class AccountsClient(
}.getOrNull()
/**
* ⚠️ Falls back to the STATUS when the code is unknown, so a refusal this
* Falls back to the STATUS when the code is unknown, so a refusal this
* build has never heard of still reports as a refusal rather than as a
* success or a crash. A relay newer than the app is the normal case, not
* the exception.
@@ -10,13 +10,13 @@ import org.json.JSONObject
/**
* Talking to a chain node, for the wallet.
*
* 🔴 **The node is somebody else's server and it is not trusted with
* **The node is somebody else's server and it is not trusted with
* anything.** It is asked what a public address holds and handed transactions
* that are already signed; it never sees a key, and it cannot move money. What
* it CAN do is lie about a balance or withhold a history, so nothing here
* treats an answer as authoritative beyond "this is what that node said".
*
* ⚠️ **Which node somebody points at is a privacy decision, and the app has to
* **Which node somebody points at is a privacy decision, and the app has to
* say so.** Asking a public RPC endpoint for the balance of an address ties
* that address to this device's IP, on somebody else's logs. Running your own
* node is the only answer that removes it; the default exists so the wallet
@@ -61,9 +61,9 @@ class ChainRpc(
val json = runCatching { JSONObject(text) }.getOrNull()
?: throw Failure.Malformed("not an object")
// 🔴 **A JSON-RPC error arrives with HTTP 200.** Reading only the status
// A JSON-RPC error arrives with HTTP 200. Reading only the status
// code takes a refusal for an answer, and then something fails further
// down with the node's own explanation already thrown away — which is
// down with the node's own explanation already thrown away, which is
// the difference between "insufficient funds for rent" on screen and a
// parse error nobody can act on.
json.optJSONObject("error")?.let { error ->
@@ -76,9 +76,9 @@ class ChainRpc(
// ---- Reading ---------------------------------------------------------
/**
* The balance in lamports — the chain's smallest unit.
* The balance in lamports, the chain's smallest unit.
*
* ⚠️ Lamports, not SOL, all the way to the edge of the UI. Converting early
* Lamports, not SOL, all the way to the edge of the UI. Converting early
* means carrying a fractional value through code that compares and
* subtracts, and a rounding error in a balance is money.
*/
@@ -102,7 +102,7 @@ class ChainRpc(
/**
* One line of an address's history.
*
* 🔴 **A FAILED transaction still appears in an address's history.** Not
* A FAILED transaction still appears in an address's history. Not
* showing that it failed would be the worst kind of wrong: somebody would
* believe a payment went through.
*/
@@ -110,7 +110,7 @@ class ChainRpc(
val signature: String,
val slot: Long,
/**
* Absent while a transaction is very new — the node has it but has not
* Absent while a transaction is very new, the node has it but has not
* dated it yet. Null means "just now", never 1970.
*/
val blockTimeSeconds: Long?,
@@ -125,7 +125,7 @@ class ChainRpc(
slot = json.optLong("slot", 0),
blockTimeSeconds = if (json.isNull("blockTime")) null
else json.optLong("blockTime"),
// ⚠️ `err` is an object or an array depending on the
// `err` is an object or an array depending on the
// failure, and `null` when there was none. Testing for
// "not null" rather than for a shape is what makes both
// kinds of failure visible.
File diff suppressed because it is too large Load Diff
@@ -2,19 +2,19 @@ package io.flamenet.client
/**
* What the client already knows about delivery, kept where a person can read
* it (ios#21, ios#23 — the Android half).
* it (ios#21, ios#23, the Android half).
*
* 🔴 **The client has always known this; it only ever told the log.** Every
* The client has always known this; it only ever told the log. Every
* refusal went to `System.err` and nowhere else, so a send blocked by the
* identity guard reached the person sending it as "Something went wrong
* reaching that server" — which was neither true nor actionable.
* reaching that server", which was neither true nor actionable.
*
* ⚠️ **Shape only, never content.** Counts, reasons and timestamps. No message
* text, no key material, no payloads, and no identifiers — see
* Shape only, never content. Counts, reasons and timestamps. No message
* text, no key material, no payloads, and no identifiers, see
* [normalisedReason]. A diagnostic reading that leaks what it is diagnosing is
* worse than none, and this one is meant to be safe to screenshot.
*
* ⚠️ In memory, deliberately. It describes the session you are in; persisting
* In memory, deliberately. It describes the session you are in; persisting
* it would be a growing record of who you talked to and when, which is the
* metadata this project spends its effort not keeping.
*/
@@ -33,8 +33,8 @@ object DeliveryDiagnostics {
val dropTotal: Int get() = drops.values.sum()
/**
* 🔴 A client that repeatedly repairs is a symptom, not a cure. One is
* ordinary — a contact reinstalled. Three against the same peer means
* A client that repeatedly repairs is a symptom, not a cure. One is
* ordinary, a contact reinstalled. Three against the same peer means
* something is not settling, and that is worth saying out loud.
*/
val repairsLookAbnormal: Boolean get() = repairs >= 3
@@ -83,12 +83,12 @@ object DeliveryDiagnostics {
/**
* Strip anything identifying before a reason is counted.
*
* ⚠️ Refusal messages name the thing that was wrong, which sometimes means
* naming a device id — "device … has published keys, but none match".
* Refusal messages name the thing that was wrong, which sometimes means
* naming a device id, "device … has published keys, but none match".
* A log may carry that; a screen the user is invited to screenshot may not.
* Runs of digits and hex become "…".
*
* ⚠️ Short hex ids are the ones that slip through: `9f0e` is neither a long
* Short hex ids are the ones that slip through: `9f0e` is neither a long
* hex run nor an all-digit run. Hence "four or more hex characters
* containing at least one digit", which catches truncated device ids
* without eating ordinary words like `base64`.
@@ -10,10 +10,10 @@ import kotlin.math.roundToInt
/**
* What the time is, when the device's own answer cannot be trusted (ios#45).
*
* 🔴 **A wrong device clock breaks a promise made to somebody else.** Almost
* A wrong device clock breaks a promise made to somebody else. Almost
* everything time-dependent here is a commitment: a story disappears after an
* hour, a message clears itself after a day. Those are promises the SENDER
* made, and they are enforced on the READER's device — so a reader whose clock
* made, and they are enforced on the READER's device, so a reader whose clock
* is running behind keeps a message the sender was told would be gone, and
* nobody involved can tell.
*
@@ -21,18 +21,18 @@ import kotlin.math.roundToInt
* the reader something they were going to lose anyway; being wrong in the other
* direction breaks somebody else's expectation silently. So:
*
* 🔴 **For expiry, take the LATEST plausible time.** Never the device's alone,
* never the server's alone — whichever is further ahead. A device clock set back
* For expiry, take the LATEST plausible time. Never the device's alone,
* never the server's alone, whichever is further ahead. A device clock set back
* cannot extend a message's life, and neither can a relay that lies about the
* time, because the other source overrules it. The cost is that a device clock
* set FORWARD expires things early, which is the failure worth having.
*
* ⚠️ **The relay is a hint, not an authority.** It is told the time by nobody we
* The relay is a hint, not an authority. It is told the time by nobody we
* trust, and a hostile one would like to move time backwards so that
* disappearing messages linger. Taking the later of the two is exactly what
* stops that being useful to it.
*
* ⚠️ **Process-wide and synchronised, not a singleton object with plain vars.**
* Process-wide and synchronised, not a singleton object with plain vars.
* The Swift version can be `nonisolated(unsafe) static var` because the places
* that write it are on one actor; here the observation is written from whatever
* OkHttp thread finished a call and read from the UI thread, so the fields are
@@ -46,7 +46,7 @@ object DeviceClock {
* Difference between the last server time seen and this device's clock, in
* milliseconds. Positive means the device is BEHIND.
*
* ⚠️ Not persisted. A skew learned yesterday says nothing about today — the
* Not persisted. A skew learned yesterday says nothing about today, the
* clock may have been corrected, or the phone rebooted with a fresh answer
* from the network.
*/
@@ -62,9 +62,9 @@ object DeviceClock {
/**
* How far out the clock has to be before it is worth saying anything.
*
* ⚠️ Five minutes. Ordinary drift is seconds; a phone whose clock is out by
* more than this will have visible symptoms — stories that vanish, messages
* that will not send — and being told why is better than guessing.
* Five minutes. Ordinary drift is seconds; a phone whose clock is out by
* more than this will have visible symptoms, stories that vanish, messages
* that will not send, and being told why is better than guessing.
*/
const val SUSPECT_THRESHOLD_MS = 300_000L
@@ -74,7 +74,7 @@ object DeviceClock {
/**
* Learn the skew from a response's `Date` header.
*
* ⚠️ Free: every HTTP response already carries one, so this costs no extra
* Free: every HTTP response already carries one, so this costs no extra
* request and no extra round trip.
*/
fun observe(serverMillis: Long, deviceNow: Long = System.currentTimeMillis()) {
@@ -116,7 +116,7 @@ object DeviceClock {
/**
* What to say about it, or null when there is nothing to say.
*
* ⚠️ Names the symptom before the cause. Somebody whose stories keep
* Names the symptom before the cause. Somebody whose stories keep
* vanishing does not think "my clock is wrong"; they think the app is
* broken.
*/
@@ -134,12 +134,12 @@ object DeviceClock {
/**
* The time to judge an expiry against, in milliseconds.
*
* 🔴 **The later of the device's clock and the server's.** Neither alone: a
* The later of the device's clock and the server's. Neither alone: a
* device set back would keep a disappearing message past the moment its
* sender was told it would go, and a relay that lied about the time could
* arrange the same thing. Taking whichever is further ahead means both have
* to be wrong in the same direction to extend anything, and the remaining
* failure — expiring early — costs the reader something they were about to
* failure, expiring early, costs the reader something they were about to
* lose anyway.
*/
fun nowForExpiry(deviceNow: Long = System.currentTimeMillis()): Long {
@@ -150,7 +150,7 @@ object DeviceClock {
/**
* The same in seconds, which is how the vault stores expiries.
*
* ⚠️ A Double of seconds, matching `Vault.Story.expires` — converting to a
* A Double of seconds, matching `Vault.Story.expires`, converting to a
* Long of seconds here would floor away sub-second precision on a boundary
* that decides whether a story is still on screen.
*/
@@ -160,8 +160,8 @@ object DeviceClock {
/**
* RFC 7231 `Date`, which is what an HTTP header carries.
*
* ⚠️ Fixed locale and zone. A formatter that picked those up from the device
* parses nothing on a phone set to most of the world — and a `SimpleDateFormat`
* Fixed locale and zone. A formatter that picked those up from the device
* parses nothing on a phone set to most of the world, and a `SimpleDateFormat`
* is not thread-safe, so one is built per call rather than shared. This runs
* once per HTTP response, which is nowhere near hot enough to care.
*/
@@ -8,15 +8,15 @@ import okhttp3.Request
/**
* Fetches a preview for a link the user is about to send (spec §6.12).
*
* 🔴 **This runs on the SENDER's device, and the result travels inside the
* ciphertext.** The obvious implementation — each recipient fetching the URL to
* draw a card — is the one that must not be built: it tells the site the
* **This runs on the SENDER's device, and the result travels inside the
* ciphertext.** The obvious implementation, each recipient fetching the URL to
* draw a card, is the one that must not be built: it tells the site the
* reader's IP address, that they received a link, and roughly when they opened
* it, once per member on a group message. A messenger that hides the message
* and then has every reader announce its contents to a third party has given
* away what it was protecting.
*
* ⚠️ Sender-side is a smaller leak, not no leak: the site learns somebody is
* Sender-side is a smaller leak, not no leak: the site learns somebody is
* about to share that URL, from this address, before the message is sent. That
* is what the setting is for.
*/
@@ -38,8 +38,8 @@ object LinkPreviewFetcher {
fun firstUrl(text: String): String? = URL_RE.find(text)?.value?.trimEnd('.', ',', ')')
/**
* Fetch and parse Open Graph tags. Null for anything unusual — no title, a
* refusal, a timeout — each of which leaves a message with a plain URL in
* Fetch and parse Open Graph tags. Null for anything unusual, no title, a
* refusal, a timeout, each of which leaves a message with a plain URL in
* it, which is a perfectly good thing to be.
*/
fun fetch(url: String): Content.LinkPreview? = runCatching {
@@ -64,7 +64,7 @@ object LinkPreviewFetcher {
}
}.getOrNull()
// ⚠️ Regex over a bounded prefix, deliberately. A full HTML parser for two
// Regex over a bounded prefix, deliberately. A full HTML parser for two
// attributes is a dependency and an attack surface; this reads what it
// recognises and gives up otherwise, which is the correct failure here.
@@ -84,7 +84,7 @@ object LinkPreviewFetcher {
/**
* The handful of entities that actually appear in titles. Anything else is
* left alone — a literal `&copy;` is a cosmetic flaw, and expanding
* left alone, a literal `&copy;` is a cosmetic flaw, and expanding
* arbitrary entities is how a parser grows teeth.
*/
private fun clean(s: String): String = s
@@ -22,7 +22,7 @@ import org.json.JSONObject
/**
* Files that live on this phone.
*
* 🔴 **The server is not storage any more.** It is a courier: it holds
* The server is not storage any more. It is a courier: it holds
* ciphertext for as long as it takes to reach the other device, and then it does
* not. Everything a person owns is here, in this app's own container, and a
* phone that never syncs still has all of it.
@@ -30,9 +30,9 @@ import org.json.JSONObject
* What that costs is worth stating plainly, because it changed and it changed
* silently everywhere else:
*
* ⚠️ **Read-down is no longer enforced by anything that can refuse.** When the
* Read-down is no longer enforced by anything that can refuse. When the
* rows lived in Postgres, `session_dominates(classification, compartments)` was
* an RLS predicate — the database itself would not return a row above your
* an RLS predicate, the database itself would not return a row above your
* clearance, whatever the client asked for. There is no database here. A label
* still gates *decryption*, absolutely: without the label key the bytes are
* bytes. But nothing stops a sender transferring a file above a recipient's
@@ -40,7 +40,7 @@ import org.json.JSONObject
* device holds", not "what a server will show you". That is a deliberate trade,
* and the interface must not keep using the old words for the new thing.
*
* ⚠️ **The index file is device-local and is NOT an interchange format.** The
* The index file is device-local and is NOT an interchange format. The
* node *fields* match iOS's exactly, deliberately, so that a courier transfer is
* a copy rather than a translation. The JSON around them is not: iOS encodes
* dates as Swift's reference-date doubles, which is an accident of `JSONEncoder`
@@ -48,8 +48,8 @@ import org.json.JSONObject
* format. Anybody building vault migration between phones should move nodes
* through a declared wire shape, not by copying this file.
*
* ⚠️ **Synchronous, and it touches the disk.** Call it off the main thread. The
* lock below makes concurrent calls safe, not fast — a `ReentrantLock` rather
* Synchronous, and it touches the disk. Call it off the main thread. The
* lock below makes concurrent calls safe, not fast, a `ReentrantLock` rather
* than a coroutine `Mutex` because this module deliberately has no coroutines
* dependency, which is what lets its tests run without an emulator.
*/
@@ -60,8 +60,8 @@ class LocalVault(
/**
* How a referenced file is reached.
*
* 🔴 Required, with no default. A no-op default would make every reference
* row read as "missing" in an app that simply forgot to wire this up — a
* Required, with no default. A no-op default would make every reference
* row read as "missing" in an app that simply forgot to wire this up, a
* vault that looks like it has lost half its contents, with nothing
* anywhere saying why.
*/
@@ -71,14 +71,14 @@ class LocalVault(
/**
* Where a row's bytes actually are.
*
* 🔴 The two are not equally protected and the interface must not present
* The two are not equally protected and the interface must not present
* them as if they were.
*
* - [VAULT]: copied in and encrypted under the label key. A stolen backup or
* container yields ciphertext, and the classification is a control.
* - [REFERENCE]: a pointer to a file that stays where it is on the phone,
* encrypted only at the moment it is sent. Nothing is duplicated, and the
* file keeps whatever protection the platform gives it — but we are not
* file keeps whatever protection the platform gives it, but we are not
* adding any, the label is advisory until it is sent, and the row breaks
* if the file is moved or deleted.
*/
@@ -88,9 +88,9 @@ class LocalVault(
companion object {
/**
* ⚠️ Absence means [VAULT], and that is not a nicety. iOS shipped a
* Absence means [VAULT], and that is not a nicety. iOS shipped a
* `storage` field with a Swift default and discovered that a default
* does not make a `Codable` key optional — every index written
* does not make a `Codable` key optional, every index written
* before referencing existed failed to decode, which presents as a
* vault that has lost everything. Reading an unknown or missing
* value as `vault` is what those rows actually are.
@@ -110,7 +110,7 @@ class LocalVault(
*/
interface ReferenceResolver {
/**
* ⚠️ Whether the file is still there — asked separately from reading it,
* Whether the file is still there, asked separately from reading it,
* because a row that silently does nothing when tapped is worse than one
* that says the file has moved.
*/
@@ -147,18 +147,18 @@ class LocalVault(
val label: Label get() = Label(classification, compartments.toSet())
/**
* 🔴 **Every field, with the byte arrays compared by CONTENT.**
* Every field, with the byte arrays compared by CONTENT.
*
* This was `other.id == id` — written to dodge the fact that a data
* This was `other.id == id`, written to dodge the fact that a data
* class compares `ByteArray` by identity, and wrong in a way that broke
* a whole feature. Promoting a link to the vault keeps the node's id and
* changes its `storage`, so the new row compared EQUAL to the old one:
* the state object it was in compared equal too, `StateFlow` conflated
* the update away, and Compose never saw it. On screen the file vanished
* from Linked and did not appear in the vault until the app was
* restarted — which reads as losing somebody's file.
* restarted, which reads as losing somebody's file.
*
* ⚠️ The identity-vs-content trap is real, so it is handled rather than
* The identity-vs-content trap is real, so it is handled rather than
* avoided: `contentEquals` for the four byte arrays, ordinary equality
* for the rest.
*/
@@ -202,7 +202,7 @@ class LocalVault(
val node: Node,
val name: String?,
/**
* ⚠️ True when this is a reference whose file is no longer where it was.
* True when this is a reference whose file is no longer where it was.
* Surfaced rather than hidden.
*/
val isMissing: Boolean,
@@ -237,13 +237,13 @@ class LocalVault(
/**
* The key for a label, derived rather than fetched.
*
* 🔴 Deterministic from the identity key, so every device holding the
* Deterministic from the identity key, so every device holding the
* identity derives the same one and a vault restored from a courier transfer
* opens without a grant round-trip. Sharing an area means sending this
* value, wrapped to the recipient — the same grant the server used to
* value, wrapped to the recipient, the same grant the server used to
* broker, minus the broker.
*
* ⚠️ **Binds the whole canonical label encoding, not just the
* **Binds the whole canonical label encoding, not just the
* classification.** Deriving from the level alone would give one key to
* every compartment at that level, which is precisely the separation
* compartments exist for.
@@ -258,7 +258,7 @@ class LocalVault(
/**
* The name, or null when this device cannot read the row.
*
* ⚠️ On a local vault every label the identity can derive is readable, so
* On a local vault every label the identity can derive is readable, so
* this is non-null for anything this device filed itself. It exists for the
* case that is not that: a node that arrived from somebody else, filed under
* a label whose key was never granted. Those decrypt to null and the row
@@ -284,8 +284,8 @@ class LocalVault(
}
/**
* 🔴 Written to a temporary file and moved into place. An index truncated by
* a crash mid-write is a vault that has lost every file at once — and the
* Written to a temporary file and moved into place. An index truncated by
* a crash mid-write is a vault that has lost every file at once, and the
* blobs would still be sitting there, unreferenced, taking up the space.
*/
private fun save() {
@@ -322,13 +322,13 @@ class LocalVault(
.put("bookmark", node.bookmark ?: JSONObject.NULL)
/**
* 🔴 **`optString` is not safe on a nullable field, and the JVM will not
* **`optString` is not safe on a nullable field, and the JVM will not
* tell you.** Android's bundled `org.json` coerces a JSON `null` to the
* four-character string `"null"`; the Maven `org.json` these tests run
* against returns `""`. Every nullable field below therefore passed on a
* laptop and broke on a phone:
*
* - `parentID` threw `Invalid UUID string: null` — so a vault holding any
* - `parentID` threw `Invalid UUID string: null`, so a vault holding any
* top-level file failed to load AT ALL. Adding the first file worked,
* because it was already in memory; reopening the app lost everything,
* with an error nobody could act on.
@@ -338,7 +338,7 @@ class LocalVault(
* - `bookmark` produced a reference pointing at `"null"`, permanently
* missing.
*
* ⚠️ So absence is decided by [JSONObject.isNull], never by comparing a
* So absence is decided by [JSONObject.isNull], never by comparing a
* decoded string to a sentinel. The literal `"null"` is rejected as well:
* it is not a valid value for any of these fields, and tolerating it costs
* nothing next to reintroducing this.
@@ -397,9 +397,9 @@ class LocalVault(
/**
* Every id in a subtree, so a screen can offer "and everything in it".
*
* 🔴 **Bounded by what it has already seen, not by a step counter.** A cycle
* Bounded by what it has already seen, not by a step counter. A cycle
* cannot be built through this API, but the index is a file on disk and
* something else could write one — so this has to terminate. iOS bounds it
* something else could write one, so this has to terminate. iOS bounds it
* with `guardRail < 10_000`, which terminates and then hands back ten
* thousand copies of the same two ids: a screen offering to delete a folder
* "and everything in it" would say 10,000 things. Refusing to revisit a node
@@ -424,14 +424,14 @@ class LocalVault(
* Every file in the whole tree, for a picker that has no business showing
* folders.
*
* 🔴 **The whole tree, not the top of it.** iOS's picker lists `list(nil)`,
* The whole tree, not the top of it. iOS's picker lists `list(nil)`,
* so a document filed into a folder cannot be sent from a conversation at
* all — and nothing says so; the file is simply absent from the list, which
* all, and nothing says so; the file is simply absent from the list, which
* reads as "it isn't there". Filed as ios#68.
*
* ⚠️ Unreadable and missing rows are dropped here rather than shown
* Unreadable and missing rows are dropped here rather than shown
* disabled. Everywhere else this class insists on showing a row you cannot
* open — because hiding it would leave somebody unable to see that anything
* open, because hiding it would leave somebody unable to see that anything
* is there. A picker is the exception: its only purpose is choosing
* something to send, and a row that can never be chosen is furniture.
*/
@@ -451,7 +451,7 @@ class LocalVault(
val bookmark = node.bookmark
if (bookmark == null || !references.exists(bookmark)) {
throw 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.")
}
return references.read(bookmark)
}
@@ -464,9 +464,9 @@ class LocalVault(
}
/**
* The bytes, encrypted for sending — whichever way they are stored.
* The bytes, encrypted for sending, whichever way they are stored.
*
* 🔴 **This is where a referenced file first becomes ciphertext.** It is
* This is where a referenced file first becomes ciphertext. It is
* sealed under a fresh content key bound to the label, so the courier
* carries something it cannot read and the classification finally gates
* something. Returns the sealed blob and the key to hand the recipient.
@@ -487,7 +487,7 @@ class LocalVault(
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(label), label)
val contentKey = Random.bytes(32)
// 🔴 Bytes to disk BEFORE the index. The other order leaves an index
// Bytes to disk BEFORE the index. The other order leaves an index
// entry for a file that is not there, which reads as corruption;
// this order leaves at worst an unreferenced blob, which is a
// cleanup job nobody notices.
@@ -511,7 +511,7 @@ class LocalVault(
/**
* File a document that stays where it is.
*
* 🔴 Nothing is copied and nothing is encrypted here — see [Storage]. The
* Nothing is copied and nothing is encrypted here, see [Storage]. The
* bytes get their content key at send time, not now, so until then this
* row's label is a statement of intent rather than a lock.
*/
@@ -529,15 +529,15 @@ class LocalVault(
/**
* Turn a reference into an encrypted copy, in place.
*
* 🔴 **The bridge between the two ways of holding a file, and the reason
* **The bridge between the two ways of holding a file, and the reason
* both exist.** Referencing avoids duplicating anything; vaulting is what
* makes a classification a control rather than a note. Most files want the
* first and a few want the second, and nobody knows which is which until
* after they have added them — so this promotes one without re-picking it.
* after they have added them, so this promotes one without re-picking it.
*
* ⚠️ It does NOT delete the person's original: that is their file, and
* It does NOT delete the person's original: that is their file, and
* removing it is a separate decision the interface asks about afterwards.
* Until they do, this genuinely is two copies — the honest trade for having
* Until they do, this genuinely is two copies, the honest trade for having
* the encrypted one.
*
* Returns the bookmark the original was read from, so a caller can offer
@@ -550,7 +550,7 @@ class LocalVault(
if (node.storage != Storage.REFERENCE) return@withLock null
val origin = node.bookmark
if (origin == null || !references.exists(origin)) {
throw VaultException("That file isn't where it was — it may have been moved or deleted.")
throw VaultException("That file isn't where it was, it may have been moved or deleted.")
}
val plaintext = readContents(node)
@@ -563,9 +563,9 @@ class LocalVault(
nodes[position] = node.copy(
storage = Storage.VAULT,
// 🔴 The bookmark goes. Keeping it would leave a vaulted row still
// The bookmark goes. Keeping it would leave a vaulted row still
// able to read through to a plaintext original, so the encrypted
// copy would not be the only way in — which is the entire point of
// copy would not be the only way in, which is the entire point of
// vaulting it.
bookmark = null,
wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, node.label),
@@ -590,7 +590,7 @@ class LocalVault(
isFolder = isFolder,
classification = label.classification,
compartments = label.compartments.sorted(),
// 🔴 Wrapped to the node's OWN label key, never the parent's. The
// Wrapped to the node's OWN label key, never the parent's. The
// tree allows a Restricted file inside a Confidential folder, so a
// folder-derived key would hand every folder-key holder content
// above their clearance.
@@ -617,7 +617,7 @@ class LocalVault(
/**
* Removes a node and its bytes.
*
* ⚠️ Refuses a folder that still has anything in it. Deleting a folder and
* Refuses a folder that still has anything in it. Deleting a folder and
* orphaning its contents would leave rows nothing can reach and bytes
* nothing will ever free.
*/
@@ -630,22 +630,22 @@ class LocalVault(
}
nodes.removeAt(position)
save()
// Never the person's own file — see the note in the bulk version.
// Never the person's own file, see the note in the bulk version.
if (node.storage == Storage.VAULT) blobFile(id).delete()
}
/**
* Delete several things at once.
*
* 🔴 **This reports rather than returning nothing.** Selecting eleven things
* This reports rather than returning nothing. Selecting eleven things
* and having nine disappear is a data-loss-shaped event in the other
* direction: the two that survived are the ones somebody now thinks are
* gone. The screen says which, and why.
*
* ⚠️ Deletes deepest-first, so selecting a folder AND everything in it
* Deletes deepest-first, so selecting a folder AND everything in it
* works. A folder whose contents were NOT all selected is kept, because the
* alternative is a bulk gesture that silently takes rows the person never
* selected — including ones filed above what this device can even read,
* selected, including ones filed above what this device can even read,
* which they could not have reviewed before tapping.
*/
fun remove(ids: List<UUID>): Removal = lock.withLock {
@@ -683,13 +683,13 @@ class LocalVault(
removed += id
}
// 🔴 One save for the whole batch, after the index is consistent. A save
// One save for the whole batch, after the index is consistent. A save
// per row would leave a half-deleted tree on disk if the process died
// mid-loop; the blobs go afterwards, so a crash costs orphaned bytes
// rather than rows pointing at files that are gone.
save()
// 🔴 Only ever our own blobs. A referenced row's bytes are the person's
// own file, sitting where they put it — deleting the row must not delete
// Only ever our own blobs. A referenced row's bytes are the person's
// own file, sitting where they put it, deleting the row must not delete
// their document, and `blobFile` for a reference names a file that was
// never written.
removedVaultIds.forEach { blobFile(it).delete() }
@@ -8,15 +8,15 @@ import io.flamenet.e2e.Vault
*
* ## What this buys, and what it does not
*
* Your **address** already survives a relay disappearing: it comes from your
* Your address already survives a relay disappearing: it comes from your
* passphrase, so signing in elsewhere produces the same one. What does not
* survive is your **mailbox** — mail sitting at a relay that goes away is gone,
* survive is your mailbox, mail sitting at a relay that goes away is gone,
* and contacts holding only that relay's name have nowhere to write.
*
* A second home fixes both, and only in advance. Senders deliver a copy to
* every relay in a signed record, so losing one loses no message, and a contact
* whose delivery fails at the first home finds the second in the same record.
* **Added after a relay disappears, none of that helps**: there is nothing left
* Added after a relay disappears, none of that helps: there is nothing left
* to publish an updated record from and no way to tell anybody where you went.
*
* ## Why it is several requests
@@ -25,12 +25,12 @@ import io.flamenet.e2e.Vault
* mailbox record from a stranger, and a token minted by one relay is refused by
* every other because the audience is bound. So: get a credential at the new
* relay, register this device so there is a mailbox to deliver into, claim the
* same address so a sender can find it — and only then publish the record, at
* **every** home rather than only the new one.
* same address so a sender can find it, and only then publish the record, at
* every home rather than only the new one.
*
* That last part is not tidiness. A home that does not know it is one still
* accepts mail, but a sender asking *it* for the record is handed a stale list
* and skips the others — so the redundancy quietly depends on which relay the
* and skips the others, so the redundancy quietly depends on which relay the
* sender happened to ask.
*/
class MailboxHoming(
@@ -47,9 +47,9 @@ class MailboxHoming(
/**
* Normalise what somebody typed into a hostname, or return null.
*
* ⚠️ Refuses a URL rather than parsing one out. A relay is a hostname,
* Refuses a URL rather than parsing one out. A relay is a hostname,
* and accepting `https://x/y` here would let a pasted string point this
* device at a path on somebody else's host — and then send it a signed
* device at a path on somebody else's host, and then send it a signed
* record naming that host as a mailbox.
*/
fun hostname(typed: String): String? {
@@ -67,9 +67,9 @@ class MailboxHoming(
* Sign in at [typed], register this device, claim the address, and publish
* the record everywhere. Returns the home list as it now stands.
*
* ⚠️ Nothing is written to the vault until the relay has actually accepted
* a registration. A half-added home — stored locally, unknown to the relay
* — would be polled forever and would show on screen as a mailbox that
* Nothing is written to the vault until the relay has actually accepted
* a registration. A half-added home, stored locally, unknown to the relay
* would be polled forever and would show on screen as a mailbox that
* never has anything in it.
*/
fun add(
@@ -83,8 +83,8 @@ class MailboxHoming(
* How to reach [typed], which is a separate question from what it is
* called.
*
* ⚠️ The signed record names a **hostname** — it is inside the
* signature, so it can carry no scheme and no port — while this is how
* The signed record names a hostname, it is inside the
* signature, so it can carry no scheme and no port, while this is how
* this device dials that host. `https://<host>` is the only correct
* answer in production and the default; naming the two separately is
* what lets a self-hosted or test deployment reachable some other way
@@ -94,7 +94,7 @@ class MailboxHoming(
): List<String> {
val host = hostname(typed) ?: throw Refused(
"That does not look like a relay's address. It is a hostname, like " +
"relay.example.com — no https:// and no path.")
"relay.example.com, no https:// and no path.")
if (host == primaryRelay || host in existingHomes) {
throw Refused("That relay already holds a mailbox for you.")
}
@@ -103,12 +103,12 @@ class MailboxHoming(
val account = try {
RelayAccounts(base).signIn(handle, passphrase)
} catch (e: Exception) {
// ⚠️ Says which of the two things went wrong. "No account there" and
// Says which of the two things went wrong. "No account there" and
// "wrong passphrase" call for opposite actions, and one message
// covering both leaves somebody retyping a correct passphrase.
throw Refused(
"$host did not accept those details. Sign up there with the same " +
"passphrase first — that is what gives you the same address.")
"passphrase first, that is what gives you the same address.")
}
val client = RelayClient(base, account.username, account.appPassword)
@@ -116,9 +116,9 @@ class MailboxHoming(
// A mailbox to deliver into.
//
// ⚠️ **No one-time prekeys here.** A one-time prekey is only usable if
// No one-time prekeys here. A one-time prekey is only usable if
// this device kept its private half, and those live in the identity
// archive keyed to no particular relay — publishing keys at a second
// archive keyed to no particular relay, publishing keys at a second
// home whose privates this device would not offer back makes every
// session opened with one undecryptable, which is worse than offering
// none. Without them a sender falls back to the signed prekey: it costs
@@ -131,7 +131,7 @@ class MailboxHoming(
// The same address here as everywhere. A sender resolving `fnid@host`
// asks that relay, and until the claim lands the answer is "no such
// address" — an unclaimed home is a mailbox nobody can address.
// address", an unclaimed home is a mailbox nobody can address.
try {
AccountIdentity.claim(client, accountKeySeed, account.userId)
} catch (e: Exception) {
@@ -152,7 +152,7 @@ class MailboxHoming(
val failures = AccountIdentity.publishHomes(
accountKeySeed, fnid, serial, homes.associateWith { clients[it] ?: primary })
// 🔴 The NEW relay accepting is what decides. A record every old home
// The NEW relay accepting is what decides. A record every old home
// took but the new one refused describes a mailbox that does not exist,
// and a sender told to deliver there gets nothing.
failures[host]?.let { throw Refused("$host refused the mailbox: ${it.message}") }
@@ -166,12 +166,12 @@ class MailboxHoming(
/**
* Stop keeping a mailbox at [relay].
*
* ⚠️ **The shorter list is published first, and the local credential goes
* **The shorter list is published first, and the local credential goes
* only if that worked.** Forgetting locally while senders are still told to
* deliver there leaves mail arriving at a mailbox this device no longer
* reads — indistinguishable, from the sender's side, from delivery.
* reads, indistinguishable, from the sender's side, from delivery.
*
* 🔴 The relay being removed is **not** asked to accept the new record. It
* The relay being removed is not asked to accept the new record. It
* is the one party with a reason to refuse, and it is not in the list any
* more so it would refuse on those grounds regardless. Its copy simply goes
* stale, which costs nothing: the record is self-certifying and every other

Some files were not shown because too many files have changed in this diff Show More