Merge branch 'chore/read-like-a-codebase' into 'main'
Read like a codebase See merge request paulhitt/flamenet-messenger-android!97
This commit was merged in pull request #124.
This commit is contained in:
+5
-5
@@ -4,22 +4,22 @@
|
||||
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
|
||||
is `allow_failure: true`.
|
||||
|
||||
**Being behind is LOUD and NON-BLOCKING. Being broken is a hard stop.** A red
|
||||
Being behind is LOUD and NON-BLOCKING. Being broken is a hard stop. A red
|
||||
`vectors-head` means an update is waiting, not that anything is wrong.
|
||||
|
||||
## Taking an update
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
+39
-39
@@ -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
|
||||
// `./gradlew test` runs BOTH variants.**
|
||||
// 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,13 +178,13 @@ 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
|
||||
// so the app uses these types; borrowing them through another
|
||||
// module's classpath would make `:client` unable to change its own
|
||||
// dependencies without breaking a screen that never mentioned it.
|
||||
implementation(project(":obfs4"))
|
||||
// 🔴 BitmapFactory ignores EXIF orientation, so a photo from a phone
|
||||
// BitmapFactory ignores EXIF orientation, so a photo from a phone
|
||||
// camera decodes on its side. This reads the tag so the rotation can be
|
||||
// applied before re-encoding drops it. See ImagePrep.orientedFrom.
|
||||
implementation("androidx.exifinterface:exifinterface:1.3.7")
|
||||
@@ -197,12 +197,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.
|
||||
@@ -217,18 +217,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")
|
||||
@@ -241,7 +241,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;
|
||||
@@ -249,14 +249,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")
|
||||
|
||||
@@ -10,9 +10,9 @@ import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextClearance
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.test.printToString
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
@@ -21,60 +21,38 @@ import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* A conversation with somebody who is not this app.
|
||||
*
|
||||
* ⚠️ **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:
|
||||
*
|
||||
* ./gradlew :interop:peer -Pargs="peer1.json create https://relay1.flamenet.io peerjvmone"
|
||||
* ./gradlew :app:connectedDebugAndroidTest \
|
||||
* -Pandroid.testInstrumentationRunnerArguments.class=…ChatFlowTest
|
||||
* ./gradlew :interop:peer -Pargs="peer1.json poll"
|
||||
*
|
||||
* Skipped, not failed, when the relay is unreachable.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ChatFlowTest {
|
||||
|
||||
/**
|
||||
* 🔴 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
|
||||
* focus, so the Compose rule attaches to a window that has no hierarchy
|
||||
* yet and every test dies with "No compose hierarchies found in the app".
|
||||
*
|
||||
* `order = 0` puts this outside the Compose rule, so the grant happens
|
||||
* first and no dialog ever appears.
|
||||
/**
|
||||
* Granted BEFORE the activity launches, and that ordering is the point.
|
||||
*/
|
||||
@get:Rule(order = 0)
|
||||
val permissions: GrantPermissionRule = GrantPermissionRule.grant(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
val permissions: GrantPermissionRule =
|
||||
GrantPermissionRule.grant(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
|
||||
@get:Rule(order = 1)
|
||||
val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
/**
|
||||
* Which relay to drive.
|
||||
*
|
||||
* 🔴 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
|
||||
* production for every run is a cost nobody chose.
|
||||
*
|
||||
* ./gradlew :app:connectedDebugAndroidTest \
|
||||
* -Pandroid.testInstrumentationRunnerArguments.relay=10.0.2.2:18810
|
||||
*/
|
||||
private val relay: String
|
||||
get() = InstrumentationRegistry.getArguments().getString("relay")
|
||||
?: "relay1.flamenet.io"
|
||||
get() =
|
||||
InstrumentationRegistry.getArguments().getString("relay")
|
||||
?: "relay1.flamenet.io"
|
||||
|
||||
/** A local relay is plain HTTP; anything else is not. */
|
||||
private val relayScheme: String get() = if (relay.startsWith("10.0.2.2") ||
|
||||
relay.startsWith("127.0.0.1") || relay.startsWith("localhost")) "http" else "https"
|
||||
private val relayScheme: String get() =
|
||||
if (relay.startsWith("10.0.2.2") ||
|
||||
relay.startsWith("127.0.0.1") || relay.startsWith("localhost")
|
||||
) {
|
||||
"http"
|
||||
} else {
|
||||
"https"
|
||||
}
|
||||
private val peerHandle = "peerjvmone"
|
||||
private lateinit var handle: String
|
||||
|
||||
@@ -90,7 +68,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)
|
||||
@@ -118,20 +96,12 @@ class ChatFlowTest {
|
||||
|
||||
android.util.Log.i("fnchat", "sent from $handle to $peerHandle: $text")
|
||||
|
||||
// The return trip. Requires the peer running `autoreply`; without it
|
||||
// this is a wait that legitimately times out, so it is its own test
|
||||
// rather than a silent extra assertion on the one above.
|
||||
// The return trip.
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* broken app. Run:
|
||||
*
|
||||
* ./gradlew :interop:peer -Pargs="peer1.json autoreply 240"
|
||||
*/
|
||||
@Test
|
||||
fun aReplyFromSomebodyElseAppearsInTheThread() {
|
||||
@@ -154,21 +124,22 @@ class ChatFlowTest {
|
||||
compose.onNodeWithTag("send").performClick()
|
||||
awaitNode(hasText(text, substring = true), 60_000)
|
||||
|
||||
// The app polls every few seconds; the peer echoes every couple. This
|
||||
// is the first thing that proves the RECEIVE path works in the UI --
|
||||
// everything before it only proved sending.
|
||||
// The app polls every few seconds; the peer echoes every couple.
|
||||
val echo = "echo: $text"
|
||||
val arrived = runCatching { awaitNode(hasText(echo, substring = true), 90_000); true }
|
||||
.getOrDefault(false)
|
||||
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()
|
||||
android.util.Log.i("fnchat", "round trip complete: $echo")
|
||||
}
|
||||
|
||||
// -- Helpers -------------------------------------------------------------
|
||||
// -- Helpers -------------------------------------------------------------.
|
||||
|
||||
private fun signUp() {
|
||||
compose.onNodeWithTag("relay").performTextClearance()
|
||||
@@ -177,13 +148,7 @@ 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
|
||||
// `enabled = … && (!registering || birthYear.length == 4)`, so without
|
||||
// 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.
|
||||
// The age gate, which nothing here filled.
|
||||
compose.onNodeWithTag("birth-year").performTextInput("1990")
|
||||
compose.onNodeWithTag("submit").performClick()
|
||||
awaitTag("device-id", 120_000)
|
||||
@@ -191,25 +156,12 @@ 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 —
|
||||
* 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
|
||||
// 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
|
||||
// sign-in field. This race is why the suite passed one test at a time
|
||||
// and failed as a set.
|
||||
// Wait for the app to settle before deciding anything.
|
||||
awaitAny(listOf("relay", "nav-contacts", "nav-account", "back"), 30_000)
|
||||
|
||||
// Close any open thread. The chat screen replaces the bottom nav, so a
|
||||
// test inheriting an open conversation cannot reach Account at all.
|
||||
// Close any open thread.
|
||||
if (nodesWithTag("back").isNotEmpty()) {
|
||||
compose.onNodeWithTag("back").performClick()
|
||||
awaitTag("nav-contacts", 20_000)
|
||||
@@ -221,9 +173,11 @@ class ChatFlowTest {
|
||||
awaitTag("relay", 30_000)
|
||||
}
|
||||
|
||||
|
||||
/** Wait until any one of these tags exists. */
|
||||
private fun awaitAny(tags: List<String>, timeoutMillis: Long) {
|
||||
private fun awaitAny(
|
||||
tags: List<String>,
|
||||
timeoutMillis: Long,
|
||||
) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
try {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
@@ -237,19 +191,18 @@ class ChatFlowTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun nodesWithTag(tag: String): List<Any> = runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes()
|
||||
}.getOrDefault(emptyList())
|
||||
private fun nodesWithTag(tag: String): List<Any> =
|
||||
runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun awaitTag(tag: String, timeoutMillis: Long) =
|
||||
awaitNode(hasTestTag(tag), timeoutMillis)
|
||||
private fun awaitTag(
|
||||
tag: String,
|
||||
timeoutMillis: Long,
|
||||
) = awaitNode(hasTestTag(tag), timeoutMillis)
|
||||
|
||||
/**
|
||||
* ⚠️ 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
|
||||
* opens on a spinner while it reads the vault, so nothing would compose.
|
||||
* Auto-advance is turned off only while waiting.
|
||||
*/
|
||||
private fun awaitNode(
|
||||
matcher: androidx.compose.ui.test.SemanticsMatcher,
|
||||
@@ -260,26 +213,30 @@ class ChatFlowTest {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
compose.mainClock.advanceTimeBy(100)
|
||||
val found = runCatching {
|
||||
compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()
|
||||
}.getOrDefault(false)
|
||||
val found =
|
||||
runCatching {
|
||||
compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()
|
||||
}.getOrDefault(false)
|
||||
if (found) return
|
||||
Thread.sleep(100)
|
||||
}
|
||||
throw AssertionError(
|
||||
"nothing matched within ${timeoutMillis}ms. On screen: ${visibleText()}")
|
||||
throw AssertionError("nothing matched within ${timeoutMillis}ms. On screen: ${visibleText()}")
|
||||
} finally {
|
||||
compose.mainClock.autoAdvance = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun visibleText(): String = runCatching {
|
||||
compose.onRoot().printToString(maxDepth = 30)
|
||||
}.getOrElse { "(could not read the screen: $it)" }
|
||||
private fun visibleText(): String =
|
||||
runCatching {
|
||||
compose.onRoot().printToString(maxDepth = 30)
|
||||
}.getOrElse { "(could not read the screen: $it)" }
|
||||
|
||||
private fun relayReachable(): Boolean = runCatching {
|
||||
(java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection)
|
||||
.apply { connectTimeout = 8000; readTimeout = 8000 }
|
||||
.responseCode in 200..299
|
||||
}.getOrDefault(false)
|
||||
private fun relayReachable(): Boolean =
|
||||
runCatching {
|
||||
(java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection)
|
||||
.apply {
|
||||
connectTimeout = 8000
|
||||
readTimeout = 8000
|
||||
}.responseCode in 200..299
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
@@ -10,45 +10,26 @@ import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextClearance
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.test.printToString
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CrossPlatformTest {
|
||||
|
||||
/**
|
||||
* 🔴 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
|
||||
* focus, so the Compose rule attaches to a window that has no hierarchy
|
||||
* yet and every test dies with "No compose hierarchies found in the app".
|
||||
*
|
||||
* `order = 0` puts this outside the Compose rule, so the grant happens
|
||||
* first and no dialog ever appears.
|
||||
/**
|
||||
* Granted BEFORE the activity launches, and that ordering is the point.
|
||||
*/
|
||||
@get:Rule(order = 0)
|
||||
val permissions: GrantPermissionRule = GrantPermissionRule.grant(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
val permissions: GrantPermissionRule =
|
||||
GrantPermissionRule.grant(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
|
||||
@get:Rule(order = 1)
|
||||
val compose = createAndroidComposeRule<MainActivity>()
|
||||
@@ -60,7 +41,7 @@ class CrossPlatformTest {
|
||||
fun signUpUnderAKnownHandle() {
|
||||
val handle = args.getString("handle") ?: return
|
||||
settle()
|
||||
if (nodesWithTag("nav-contacts").isNotEmpty()) return // already signed in
|
||||
if (nodesWithTag("nav-contacts").isNotEmpty()) return // already signed in
|
||||
|
||||
compose.onNodeWithTag("relay").performTextClearance()
|
||||
compose.onNodeWithTag("relay").performTextInput(relay)
|
||||
@@ -68,13 +49,7 @@ 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
|
||||
// `enabled = … && (!registering || birthYear.length == 4)`, so without
|
||||
// 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.
|
||||
// The age gate, which nothing here filled.
|
||||
compose.onNodeWithTag("birth-year").performTextInput("1990")
|
||||
compose.onNodeWithTag("submit").performClick()
|
||||
awaitTag("nav-contacts", 150_000)
|
||||
@@ -106,17 +81,13 @@ class CrossPlatformTest {
|
||||
android.util.Log.i("fnxplat", "ANDROID_SENT=$body")
|
||||
}
|
||||
|
||||
// -- Helpers -------------------------------------------------------------
|
||||
// -- Helpers -------------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* 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
|
||||
* class is designed to be run repeatedly against a surviving account, so
|
||||
* "add" has to be conditional rather than assumed.
|
||||
*/
|
||||
private fun openThreadWith(peer: String) {
|
||||
if (nodesWithTag("back").isNotEmpty()) return // already in a thread
|
||||
if (nodesWithTag("back").isNotEmpty()) return // already in a thread
|
||||
if (compose.onAllNodes(hasText(peer, substring = true)).fetchSemanticsNodes().isEmpty()) {
|
||||
compose.onNodeWithTag("add-contact").performClick()
|
||||
compose.onNodeWithTag("new-contact").performTextInput(peer)
|
||||
@@ -148,12 +119,15 @@ class CrossPlatformTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun nodesWithTag(tag: String): List<Any> = runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes()
|
||||
}.getOrDefault(emptyList())
|
||||
private fun nodesWithTag(tag: String): List<Any> =
|
||||
runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun awaitTag(tag: String, timeoutMillis: Long) =
|
||||
awaitNode(hasTestTag(tag), timeoutMillis)
|
||||
private fun awaitTag(
|
||||
tag: String,
|
||||
timeoutMillis: Long,
|
||||
) = awaitNode(hasTestTag(tag), timeoutMillis)
|
||||
|
||||
private fun awaitNode(
|
||||
matcher: androidx.compose.ui.test.SemanticsMatcher,
|
||||
@@ -164,20 +138,21 @@ class CrossPlatformTest {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
compose.mainClock.advanceTimeBy(100)
|
||||
val found = runCatching {
|
||||
compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()
|
||||
}.getOrDefault(false)
|
||||
val found =
|
||||
runCatching {
|
||||
compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()
|
||||
}.getOrDefault(false)
|
||||
if (found) return
|
||||
Thread.sleep(100)
|
||||
}
|
||||
throw AssertionError(
|
||||
"nothing matched within ${timeoutMillis}ms. On screen: " + visibleText())
|
||||
throw AssertionError("nothing matched within ${timeoutMillis}ms. On screen: " + visibleText())
|
||||
} finally {
|
||||
compose.mainClock.autoAdvance = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun visibleText(): String = runCatching {
|
||||
compose.onRoot().printToString(maxDepth = 40)
|
||||
}.getOrElse { "(could not read the screen: $it)" }
|
||||
private fun visibleText(): String =
|
||||
runCatching {
|
||||
compose.onRoot().printToString(maxDepth = 40)
|
||||
}.getOrElse { "(could not read the screen: $it)" }
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextClearance
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.test.printToString
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
@@ -21,59 +21,38 @@ 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 —
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* `MainActivity.onCreate` asks for POST_NOTIFICATIONS before it calls
|
||||
* `setContent`. The permission dialog is a separate activity and takes
|
||||
* focus, so the Compose rule attaches to a window that has no hierarchy
|
||||
* yet and every test dies with "No compose hierarchies found in the app".
|
||||
*
|
||||
* `order = 0` puts this outside the Compose rule, so the grant happens
|
||||
* first and no dialog ever appears.
|
||||
/**
|
||||
* Granted BEFORE the activity launches, and that ordering is the point.
|
||||
*/
|
||||
@get:Rule(order = 0)
|
||||
val permissions: GrantPermissionRule = GrantPermissionRule.grant(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
val permissions: GrantPermissionRule =
|
||||
GrantPermissionRule.grant(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
|
||||
@get:Rule(order = 1)
|
||||
val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
/**
|
||||
* Which relay to drive.
|
||||
*
|
||||
* 🔴 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
|
||||
* production for every run is a cost nobody chose.
|
||||
*
|
||||
* ./gradlew :app:connectedDebugAndroidTest \
|
||||
* -Pandroid.testInstrumentationRunnerArguments.relay=10.0.2.2:18810
|
||||
*/
|
||||
private val relay: String
|
||||
get() = InstrumentationRegistry.getArguments().getString("relay")
|
||||
?: "relay1.flamenet.io"
|
||||
get() =
|
||||
InstrumentationRegistry.getArguments().getString("relay")
|
||||
?: "relay1.flamenet.io"
|
||||
|
||||
/** A local relay is plain HTTP; anything else is not. */
|
||||
private val relayScheme: String get() = if (relay.startsWith("10.0.2.2") ||
|
||||
relay.startsWith("127.0.0.1") || relay.startsWith("localhost")) "http" else "https"
|
||||
private val relayScheme: String get() =
|
||||
if (relay.startsWith("10.0.2.2") ||
|
||||
relay.startsWith("127.0.0.1") || relay.startsWith("localhost")
|
||||
) {
|
||||
"http"
|
||||
} else {
|
||||
"https"
|
||||
}
|
||||
private val peerHandle = "peerjvmone"
|
||||
private lateinit var handle: String
|
||||
|
||||
@@ -92,10 +71,7 @@ class CrudSurfaceTest {
|
||||
compose.onNodeWithTag("submit").assertExists()
|
||||
compose.onNodeWithTag("toggle-register").assertExists()
|
||||
|
||||
// ⚠️ `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
|
||||
// where it currently sits.
|
||||
// `assertExists`, not `assertIsDisplayed`.
|
||||
compose.onNodeWithTag("toggle-reset").performClick()
|
||||
compose.onNodeWithTag("send-reset-code").assertExists()
|
||||
compose.onNodeWithTag("reset-code").assertExists()
|
||||
@@ -130,11 +106,6 @@ class CrudSurfaceTest {
|
||||
awaitNode(hasText(peerHandle, substring = true), 60_000)
|
||||
|
||||
// Read the id the row was given, then open the contact screen.
|
||||
//
|
||||
// ⚠️ 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.
|
||||
val peerId = peerIdFromTags()
|
||||
compose.onNodeWithTag("contact-menu-$peerId").performClick()
|
||||
compose.onNodeWithTag("contact-message").assertExists()
|
||||
@@ -152,8 +123,8 @@ 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
|
||||
// feature nobody discovers.
|
||||
// when there are none, an empty strip with no explanation is a feature
|
||||
// nobody discovers.
|
||||
compose.onNodeWithTag("post-story").assertExists()
|
||||
|
||||
compose.onNodeWithTag("nav-alerts").performClick()
|
||||
@@ -169,10 +140,9 @@ class CrudSurfaceTest {
|
||||
compose.onNodeWithTag("sign-out").assertExists()
|
||||
compose.onNodeWithTag("delete-account").assertExists()
|
||||
// §19 enforcement is a user-facing choice, so it has to have a control.
|
||||
// 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,10 +170,7 @@ 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.
|
||||
// Voice notes are reachable from the thread, and the control says what
|
||||
// it does rather than being a bare icon.
|
||||
// settings screen.
|
||||
compose.onNodeWithTag("record-voice").assertExists()
|
||||
// Calling is reachable from the thread it applies to.
|
||||
compose.onNodeWithTag("start-call").assertExists()
|
||||
@@ -214,13 +181,14 @@ class CrudSurfaceTest {
|
||||
compose.onNodeWithTag("toggle-verified").assertExists()
|
||||
}
|
||||
|
||||
// -- Helpers -------------------------------------------------------------
|
||||
// -- Helpers -------------------------------------------------------------.
|
||||
|
||||
/** The contact row carries its user id in its tag; read it back from the tree. */
|
||||
private fun peerIdFromTags(): Long {
|
||||
val dump = visibleText()
|
||||
val match = Regex("contact-menu-(\\d+)").find(dump)
|
||||
?: throw AssertionError("no contact row found. On screen: $dump")
|
||||
val match =
|
||||
Regex("contact-menu-(\\d+)").find(dump)
|
||||
?: throw AssertionError("no contact row found. On screen: $dump")
|
||||
return match.groupValues[1].toLong()
|
||||
}
|
||||
|
||||
@@ -231,13 +199,7 @@ 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
|
||||
// `enabled = … && (!registering || birthYear.length == 4)`, so without
|
||||
// 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.
|
||||
// The age gate, which nothing here filled.
|
||||
compose.onNodeWithTag("birth-year").performTextInput("1990")
|
||||
compose.onNodeWithTag("submit").performClick()
|
||||
awaitTag("nav-contacts", 120_000)
|
||||
@@ -245,25 +207,12 @@ 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 —
|
||||
* 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
|
||||
// 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
|
||||
// sign-in field. This race is why the suite passed one test at a time
|
||||
// and failed as a set.
|
||||
// Wait for the app to settle before deciding anything.
|
||||
awaitAny(listOf("relay", "nav-contacts", "nav-account", "back"), 30_000)
|
||||
|
||||
// Close any open thread. The chat screen replaces the bottom nav, so a
|
||||
// test inheriting an open conversation cannot reach Account at all.
|
||||
// Close any open thread.
|
||||
if (nodesWithTag("back").isNotEmpty()) {
|
||||
compose.onNodeWithTag("back").performClick()
|
||||
awaitTag("nav-contacts", 20_000)
|
||||
@@ -277,9 +226,11 @@ class CrudSurfaceTest {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Wait until any one of these tags exists. */
|
||||
private fun awaitAny(tags: List<String>, timeoutMillis: Long) {
|
||||
private fun awaitAny(
|
||||
tags: List<String>,
|
||||
timeoutMillis: Long,
|
||||
) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
try {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
@@ -293,12 +244,15 @@ class CrudSurfaceTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun nodesWithTag(tag: String): List<Any> = runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes()
|
||||
}.getOrDefault(emptyList())
|
||||
private fun nodesWithTag(tag: String): List<Any> =
|
||||
runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun awaitTag(tag: String, timeoutMillis: Long) =
|
||||
awaitNode(hasTestTag(tag), timeoutMillis)
|
||||
private fun awaitTag(
|
||||
tag: String,
|
||||
timeoutMillis: Long,
|
||||
) = awaitNode(hasTestTag(tag), timeoutMillis)
|
||||
|
||||
/** See ChatFlowTest: the busy spinner never lets the test clock go idle. */
|
||||
private fun awaitNode(
|
||||
@@ -310,26 +264,30 @@ class CrudSurfaceTest {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
compose.mainClock.advanceTimeBy(100)
|
||||
val found = runCatching {
|
||||
compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()
|
||||
}.getOrDefault(false)
|
||||
val found =
|
||||
runCatching {
|
||||
compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()
|
||||
}.getOrDefault(false)
|
||||
if (found) return
|
||||
Thread.sleep(100)
|
||||
}
|
||||
throw AssertionError(
|
||||
"nothing matched within ${timeoutMillis}ms. On screen: ${visibleText()}")
|
||||
throw AssertionError("nothing matched within ${timeoutMillis}ms. On screen: ${visibleText()}")
|
||||
} finally {
|
||||
compose.mainClock.autoAdvance = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun visibleText(): String = runCatching {
|
||||
compose.onRoot().printToString(maxDepth = 40)
|
||||
}.getOrElse { "(could not read the screen: $it)" }
|
||||
private fun visibleText(): String =
|
||||
runCatching {
|
||||
compose.onRoot().printToString(maxDepth = 40)
|
||||
}.getOrElse { "(could not read the screen: $it)" }
|
||||
|
||||
private fun relayReachable(): Boolean = runCatching {
|
||||
(java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection)
|
||||
.apply { connectTimeout = 8000; readTimeout = 8000 }
|
||||
.responseCode in 200..299
|
||||
}.getOrDefault(false)
|
||||
private fun relayReachable(): Boolean =
|
||||
runCatching {
|
||||
(java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection)
|
||||
.apply {
|
||||
connectTimeout = 8000
|
||||
readTimeout = 8000
|
||||
}.responseCode in 200..299
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
@@ -20,32 +20,29 @@ import kotlin.system.measureTimeMillis
|
||||
|
||||
/**
|
||||
* The engine on a real Android runtime.
|
||||
*
|
||||
* ⚠️ **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
|
||||
* has already happened once to this engine's sibling.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class EngineOnDeviceTest {
|
||||
|
||||
private fun bundleFor(identity: DeviceIdentity): PeerBundle {
|
||||
val oneTime = identity.generateOneTimePreKeys(1).first()
|
||||
return PeerBundle.fromWire(identity.registrationWire().put("opk",
|
||||
JSONObject().put("id", oneTime.id)
|
||||
.put("pub", android.util.Base64.encodeToString(
|
||||
oneTime.publicKey, android.util.Base64.NO_WRAP))))
|
||||
return PeerBundle.fromWire(
|
||||
identity.registrationWire().put(
|
||||
"opk",
|
||||
JSONObject()
|
||||
.put("id", oneTime.id)
|
||||
.put(
|
||||
"pub",
|
||||
android.util.Base64.encodeToString(oneTime.publicKey, android.util.Base64.NO_WRAP),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theProviderInUseIsWorthKnowing() {
|
||||
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
|
||||
// it, and Keystore only makes keys it keeps. See Ed25519.kt.
|
||||
// XDH comes from Conscrypt and is fine.
|
||||
assertNotNull(java.security.KeyPairGenerator.getInstance("XDH"))
|
||||
}
|
||||
|
||||
@@ -54,22 +51,19 @@ class EngineOnDeviceTest {
|
||||
val a = X25519.generate()
|
||||
val b = X25519.generate()
|
||||
assertEquals(
|
||||
android.util.Base64.encodeToString(
|
||||
X25519.agree(a.private, X25519.rawFromPublicKey(b.public)), 0),
|
||||
android.util.Base64.encodeToString(
|
||||
X25519.agree(b.private, X25519.rawFromPublicKey(a.public)), 0),
|
||||
android.util.Base64.encodeToString(X25519.agree(a.private, X25519.rawFromPublicKey(b.public)), 0),
|
||||
android.util.Base64.encodeToString(X25519.agree(b.private, X25519.rawFromPublicKey(a.public)), 0),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The RFC 8032 vector, on device. Keygen-from-seed is our own arithmetic,
|
||||
* so a platform difference in `BigInteger` or in the provider's PKCS#8
|
||||
* handling would change the account address a passphrase produces.
|
||||
* The RFC 8032 vector, on device.
|
||||
*/
|
||||
@Test
|
||||
fun ed25519MatchesTheRfcVectorOnDevice() {
|
||||
val seed = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"
|
||||
val expected = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
|
||||
|
||||
fun hex(s: String) = ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
|
||||
|
||||
val pair = Ed25519.fromSeed(hex(seed))
|
||||
@@ -109,20 +103,14 @@ class EngineOnDeviceTest {
|
||||
|
||||
/**
|
||||
* How long a sign-in actually costs.
|
||||
*
|
||||
* ⚠️ 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
|
||||
* assumed. It is generous on purpose: this runs on an emulator in CI, which
|
||||
* is slower than any phone.
|
||||
*/
|
||||
@Test
|
||||
fun theAccountKdfFinishesInSomethingLikeAReasonableTime() {
|
||||
val salt = ByteArray(16) { it.toByte() }
|
||||
val elapsed = measureTimeMillis {
|
||||
AccountKdf.authSecret("a reasonably long passphrase", salt)
|
||||
}
|
||||
val elapsed =
|
||||
measureTimeMillis {
|
||||
AccountKdf.authSecret("a reasonably long passphrase", salt)
|
||||
}
|
||||
android.util.Log.i("fnmsg", "AccountKdf.authSecret took ${elapsed}ms")
|
||||
assertTrue(
|
||||
"deriving the account secret took ${elapsed}ms; a sign-in nobody waits for is broken",
|
||||
|
||||
@@ -1,93 +1,62 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTextClearance
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.test.printToString
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assume.assumeTrue
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Voice notes, stories, groups and calls — **used**, not just reachable.
|
||||
*
|
||||
* ⚠️ **`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
|
||||
* now nothing pressed them. Four features shipped in an APK on that basis.
|
||||
*
|
||||
* So this presses them and asserts the state that follows: the recorder shows a
|
||||
* timer, discarding puts the composer back, a group appears in the list under
|
||||
* 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
|
||||
* 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
|
||||
* be worse than the gap.
|
||||
* Voice notes, stories, groups and calls, used, not just reachable.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FeatureUseTest {
|
||||
|
||||
/**
|
||||
* 🔴 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
|
||||
* focus, so the Compose rule attaches to a window that has no hierarchy
|
||||
* yet and every test dies with "No compose hierarchies found in the app".
|
||||
*
|
||||
* `order = 0` puts this outside the Compose rule, so the grant happens
|
||||
* first and no dialog ever appears.
|
||||
/**
|
||||
* Granted BEFORE the activity launches, and that ordering is the point.
|
||||
*/
|
||||
@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 raises the system microphone dialog, which is not part of the
|
||||
// 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,
|
||||
)
|
||||
val permissions: GrantPermissionRule =
|
||||
GrantPermissionRule.grant(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
// RECORD_AUDIO, or the recorder test cannot see the app.
|
||||
android.Manifest.permission.RECORD_AUDIO,
|
||||
)
|
||||
|
||||
@get:Rule(order = 1)
|
||||
val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
/**
|
||||
* Which relay to drive.
|
||||
*
|
||||
* 🔴 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
|
||||
* production for every run is a cost nobody chose.
|
||||
*
|
||||
* ./gradlew :app:connectedDebugAndroidTest \
|
||||
* -Pandroid.testInstrumentationRunnerArguments.relay=10.0.2.2:18810
|
||||
*/
|
||||
private val relay: String
|
||||
get() = InstrumentationRegistry.getArguments().getString("relay")
|
||||
?: "relay1.flamenet.io"
|
||||
get() =
|
||||
InstrumentationRegistry.getArguments().getString("relay")
|
||||
?: "relay1.flamenet.io"
|
||||
|
||||
/** A local relay is plain HTTP; anything else is not. */
|
||||
private val relayScheme: String get() = if (relay.startsWith("10.0.2.2") ||
|
||||
relay.startsWith("127.0.0.1") || relay.startsWith("localhost")) "http" else "https"
|
||||
private val relayScheme: String get() =
|
||||
if (relay.startsWith("10.0.2.2") ||
|
||||
relay.startsWith("127.0.0.1") || relay.startsWith("localhost")
|
||||
) {
|
||||
"http"
|
||||
} else {
|
||||
"https"
|
||||
}
|
||||
private lateinit var handle: String
|
||||
|
||||
@Before
|
||||
@@ -96,13 +65,10 @@ class FeatureUseTest {
|
||||
signOutIfSignedIn()
|
||||
}
|
||||
|
||||
// -- Voice notes ---------------------------------------------------------
|
||||
// -- Voice notes ---------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* The recorder starts, runs, and can be thrown away.
|
||||
*
|
||||
* The discard half matters as much as the start: a recorder you cannot back
|
||||
* out of holds the microphone open and leaves the composer unusable.
|
||||
*/
|
||||
@Test
|
||||
fun aVoiceNoteRecordsAndCanBeDiscarded() {
|
||||
@@ -112,7 +78,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()
|
||||
@@ -125,22 +91,17 @@ class FeatureUseTest {
|
||||
)
|
||||
}
|
||||
|
||||
// -- Groups --------------------------------------------------------------
|
||||
// -- Groups --------------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* 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
|
||||
* relay refuses a group whose members have no device (`requireReachable`).
|
||||
*/
|
||||
@Test
|
||||
fun aGroupIsCreatedAndListed() {
|
||||
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
|
||||
// the stories strip; the conversation list and the groups moved when
|
||||
// the tabs were split.
|
||||
// Groups live on the MESSAGES tab, not Home.
|
||||
compose.onNodeWithTag("nav-messages").performClick()
|
||||
awaitTag("new-group", 20_000)
|
||||
compose.onNodeWithTag("new-group").performScrollTo()
|
||||
@@ -149,44 +110,39 @@ class FeatureUseTest {
|
||||
compose.onNodeWithTag("group-name").performTextInput("Test Crew")
|
||||
|
||||
// Pick the one contact there is, by the tag the row carries.
|
||||
val memberTag = Regex("group-member-(\\d+)").find(visibleText())?.value
|
||||
?: throw AssertionError(
|
||||
"nobody offered as a group member, so the contact did not stick. " +
|
||||
"On screen: ${visibleText()}")
|
||||
val memberTag =
|
||||
Regex("group-member-(\\d+)").find(visibleText())?.value
|
||||
?: throw AssertionError(
|
||||
"nobody offered as a group member, so the contact did not stick. " +
|
||||
"On screen: ${visibleText()}",
|
||||
)
|
||||
compose.onNodeWithTag(memberTag).performClick()
|
||||
compose.onNodeWithTag("create-group").performClick()
|
||||
|
||||
// The group list carries a `group-<id>` row once the relay answers.
|
||||
awaitNodeMatching(Regex("group-\\d+"), 150_000,
|
||||
"the group never appeared in the list after Create")
|
||||
awaitNodeMatching(
|
||||
Regex("group-\\d+"),
|
||||
150_000,
|
||||
"the group never appeared in the list after Create",
|
||||
)
|
||||
}
|
||||
|
||||
// -- Stories -------------------------------------------------------------
|
||||
// -- Stories -------------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
@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
|
||||
// nothing and reported it as the strip being absent.
|
||||
// The stories surface is its own tab.
|
||||
compose.onNodeWithTag("nav-stories").performClick()
|
||||
// 🔴 **`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
|
||||
// screen was right there, headed "Stories", saying "Nothing here yet".
|
||||
// `new-story`, not `stories`.
|
||||
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(
|
||||
@@ -196,15 +152,11 @@ class FeatureUseTest {
|
||||
)
|
||||
}
|
||||
|
||||
// -- Calls ---------------------------------------------------------------
|
||||
// -- Calls ---------------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* 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
|
||||
* real devices and is honestly out of reach here.
|
||||
*/
|
||||
@Test
|
||||
fun placingACallRaisesTheBannerAndHangingUpClearsIt() {
|
||||
@@ -223,49 +175,42 @@ class FeatureUseTest {
|
||||
awaitTag("call-banner", 30_000)
|
||||
compose.onNodeWithTag("end-call").performClick()
|
||||
|
||||
val gone = (1..40).any {
|
||||
Thread.sleep(250)
|
||||
!nodeExists("call-banner")
|
||||
}
|
||||
val gone =
|
||||
(1..40).any {
|
||||
Thread.sleep(250)
|
||||
!nodeExists("call-banner")
|
||||
}
|
||||
assertTrue("hanging up did not clear the call banner", gone)
|
||||
}
|
||||
|
||||
// -- Helpers -------------------------------------------------------------
|
||||
// -- Helpers -------------------------------------------------------------.
|
||||
|
||||
private fun signUpAndAddPeer() {
|
||||
signUp()
|
||||
// 🔴 **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
|
||||
// with `confirm-contact`.
|
||||
// The add-contact flow, as it exists.
|
||||
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
|
||||
// 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.
|
||||
// Look up, THEN add.
|
||||
compose.onNodeWithTag("lookup-contact").performScrollTo()
|
||||
compose.onNodeWithTag("lookup-contact").performClick()
|
||||
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
|
||||
// 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
|
||||
// name for the same thing.
|
||||
awaitNodeMatching(Regex("buddy-(circle-)?\\d+"), 150_000,
|
||||
"'$PEER' never became a contact — does that account exist on $relay?")
|
||||
// `buddy-circle-N` as well as `buddy-N`.
|
||||
awaitNodeMatching(
|
||||
Regex("buddy-(circle-)?\\d+"),
|
||||
150_000,
|
||||
"'$PEER' never became a contact, does that account exist on $relay?",
|
||||
)
|
||||
}
|
||||
|
||||
private fun signUpAndOpenAThread() {
|
||||
signUpAndAddPeer()
|
||||
val buddyTag = Regex("buddy-(circle-)?\\d+").find(visibleText())?.value
|
||||
?: throw AssertionError("no contact row to open")
|
||||
val buddyTag =
|
||||
Regex("buddy-(circle-)?\\d+").find(visibleText())?.value
|
||||
?: throw AssertionError("no contact row to open")
|
||||
compose.onNodeWithTag(buddyTag).performClick()
|
||||
awaitTag("draft", 20_000)
|
||||
}
|
||||
@@ -277,61 +222,35 @@ 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
|
||||
// `enabled = … && (!registering || birthYear.length == 4)`, so without
|
||||
// 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.
|
||||
// The age gate, which nothing here filled.
|
||||
compose.onNodeWithTag("birth-year").performTextInput("1990")
|
||||
// 🔴 **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
|
||||
// nothing. The app's own logging proved it: `signIn` was never called,
|
||||
// through 450 seconds of waiting for a screen nobody had asked for.
|
||||
// Scroll to it first.
|
||||
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 " +
|
||||
((System.currentTimeMillis() - began) / 1000) + "s")
|
||||
android.util.Log.e(
|
||||
"FNTEST",
|
||||
"sign-up completed in " +
|
||||
((System.currentTimeMillis() - began) / 1000) + "s",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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**:
|
||||
* 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
|
||||
* the next test's failure the visible one, three screens away from the
|
||||
* cause.
|
||||
* Get back to the signed-out screen, or establish that we are already
|
||||
* there.
|
||||
*/
|
||||
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
|
||||
// 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
|
||||
// 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.
|
||||
// Sign out and back in to keep going."
|
||||
// `sign-out-erase`, not `sign-out`.
|
||||
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 —
|
||||
// this cannot be undone", and the second performs it. One tap only
|
||||
// armed it.
|
||||
// Confirm in place: the first tap changes the row to "Really erase,
|
||||
// this cannot be undone", and the second performs it.
|
||||
if (awaitTagOrNull("sign-out-erase", 3_000)) {
|
||||
compose.onNodeWithTag("sign-out-erase").performClick()
|
||||
}
|
||||
@@ -341,20 +260,28 @@ class FeatureUseTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun nodeExists(tag: String) = runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().isNotEmpty()
|
||||
}.getOrDefault(false)
|
||||
private fun nodeExists(tag: String) =
|
||||
runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().isNotEmpty()
|
||||
}.getOrDefault(false)
|
||||
|
||||
/** See ChatFlowTest: the busy spinner never lets the test clock go idle. */
|
||||
private fun awaitTag(tag: String, timeoutMillis: Long) {
|
||||
private fun awaitTag(
|
||||
tag: String,
|
||||
timeoutMillis: Long,
|
||||
) {
|
||||
if (!awaitTagOrNull(tag, timeoutMillis)) {
|
||||
throw AssertionError(
|
||||
"'$tag' did not appear within ${timeoutMillis}ms. " +
|
||||
"On screen: ${visibleText()}")
|
||||
"On screen: ${visibleText()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun awaitTagOrNull(tag: String, timeoutMillis: Long): Boolean {
|
||||
private fun awaitTagOrNull(
|
||||
tag: String,
|
||||
timeoutMillis: Long,
|
||||
): Boolean {
|
||||
compose.mainClock.autoAdvance = false
|
||||
try {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
@@ -369,8 +296,12 @@ class FeatureUseTest {
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait for any tag matching a pattern — ids are assigned by the relay. */
|
||||
private fun awaitNodeMatching(pattern: Regex, timeoutMillis: Long, whenMissing: String) {
|
||||
/** 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 {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
@@ -385,15 +316,19 @@ class FeatureUseTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun visibleText(): String = runCatching {
|
||||
compose.onRoot().printToString(maxDepth = 40)
|
||||
}.getOrElse { "(could not read the screen: $it)" }
|
||||
private fun visibleText(): String =
|
||||
runCatching {
|
||||
compose.onRoot().printToString(maxDepth = 40)
|
||||
}.getOrElse { "(could not read the screen: $it)" }
|
||||
|
||||
private fun relayReachable(): Boolean = runCatching {
|
||||
(java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection)
|
||||
.apply { connectTimeout = 8000; readTimeout = 8000 }
|
||||
.responseCode in 200..299
|
||||
}.getOrDefault(false)
|
||||
private fun relayReachable(): Boolean =
|
||||
runCatching {
|
||||
(java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection)
|
||||
.apply {
|
||||
connectTimeout = 8000
|
||||
readTimeout = 8000
|
||||
}.responseCode in 200..299
|
||||
}.getOrDefault(false)
|
||||
|
||||
private companion object {
|
||||
/** A standing account on relay1 that other on-device tests also use. */
|
||||
|
||||
@@ -9,9 +9,9 @@ import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextClearance
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.test.printToString
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
@@ -20,52 +20,17 @@ 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
|
||||
* 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 —
|
||||
* 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
|
||||
* ./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**
|
||||
* (`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 —
|
||||
* 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.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LiveSignUpFleetTest {
|
||||
|
||||
/**
|
||||
* 🔴 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
|
||||
* focus, so the Compose rule attaches to a window that has no hierarchy
|
||||
* yet and every test dies with "No compose hierarchies found in the app".
|
||||
*
|
||||
* `order = 0` puts this outside the Compose rule, so the grant happens
|
||||
* first and no dialog ever appears.
|
||||
/**
|
||||
* Granted BEFORE the activity launches, and that ordering is the point.
|
||||
*/
|
||||
@get:Rule(order = 0)
|
||||
val permissions: GrantPermissionRule = GrantPermissionRule.grant(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
val permissions: GrantPermissionRule =
|
||||
GrantPermissionRule.grant(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
|
||||
@get:Rule(order = 1)
|
||||
val compose = createAndroidComposeRule<MainActivity>()
|
||||
@@ -77,10 +42,6 @@ class LiveSignUpFleetTest {
|
||||
|
||||
/**
|
||||
* Somebody else's relay, keeping its accounts in the shared service.
|
||||
*
|
||||
* The shape that was unreachable before discovery existed: it can never be
|
||||
* on a list compiled into the app, so reaching it at all depends on the
|
||||
* relay being asked what it uses and believed.
|
||||
*/
|
||||
private val sharedSelfHosted get() = args.getString("shared") ?: "relay2.flamenet.io"
|
||||
|
||||
@@ -93,17 +54,13 @@ class LiveSignUpFleetTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 `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
|
||||
* the whole reason to run it. Assumed-out shows as skipped, so the count
|
||||
* tells the truth about what was actually exercised.
|
||||
* `assumeTrue`, never a bare `return`.
|
||||
*/
|
||||
private fun requireLiveOptIn() =
|
||||
assumeTrue(
|
||||
"pass -e live true to run these — they create real accounts on accounts.flamenet.io",
|
||||
args.getString("live") == "true")
|
||||
"pass -e live true to run these, they create real accounts on accounts.flamenet.io",
|
||||
args.getString("live") == "true",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun signUpOnTheManagedRelay() {
|
||||
@@ -119,16 +76,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
|
||||
* 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
|
||||
* different account from the same passphrase, and the person finds out
|
||||
* later, on another device, when their credential does not work.
|
||||
*/
|
||||
private fun signUpLive(relay: String, tag: String) {
|
||||
private fun signUpLive(
|
||||
relay: String,
|
||||
tag: String,
|
||||
) {
|
||||
if (!reachable(relay)) return
|
||||
|
||||
val name = "$handle$tag"
|
||||
@@ -136,10 +88,7 @@ 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
|
||||
// descriptor lands a moment later. Asserting immediately tests the
|
||||
// placeholder rather than the answer.
|
||||
// Wait for discovery BEFORE deciding anything about the form.
|
||||
awaitTag("identity-mode", 30_000)
|
||||
awaitNoTag("email", 30_000)
|
||||
|
||||
@@ -158,12 +107,6 @@ class LiveSignUpFleetTest {
|
||||
|
||||
/**
|
||||
* Get to a signed-out app.
|
||||
*
|
||||
* ⚠️ 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
|
||||
* field that was never going to be there.
|
||||
*/
|
||||
private fun signOutIfSignedIn() {
|
||||
awaitAny(listOf("relay", "nav-contacts", "nav-account", "back"), 30_000)
|
||||
@@ -178,43 +121,48 @@ class LiveSignUpFleetTest {
|
||||
awaitTag("relay", 30_000)
|
||||
}
|
||||
|
||||
private fun nodesWithTag(tag: String): List<Any> = runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes()
|
||||
}.getOrDefault(emptyList())
|
||||
private fun nodesWithTag(tag: String): List<Any> =
|
||||
runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun awaitAny(tags: List<String>, timeoutMillis: Long) {
|
||||
private fun awaitAny(
|
||||
tags: List<String>,
|
||||
timeoutMillis: Long,
|
||||
) {
|
||||
pumping(timeoutMillis) { tags.any { nodesWithTag(it).isNotEmpty() } }
|
||||
}
|
||||
|
||||
private fun awaitTag(tag: String, timeoutMillis: Long) {
|
||||
private fun awaitTag(
|
||||
tag: String,
|
||||
timeoutMillis: Long,
|
||||
) {
|
||||
if (!pumping(timeoutMillis) { nodesWithTag(tag).isNotEmpty() }) {
|
||||
throw AssertionError(
|
||||
"'$tag' never appeared within ${timeoutMillis}ms. On screen: ${visibleText()}")
|
||||
throw AssertionError("'$tag' never appeared within ${timeoutMillis}ms. On screen: ${visibleText()}")
|
||||
}
|
||||
}
|
||||
|
||||
/** The inverse, and the one this test actually turns on. */
|
||||
private fun awaitNoTag(tag: String, timeoutMillis: Long) {
|
||||
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()}")
|
||||
"On screen: ${visibleText()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a condition, advancing the clock by hand.
|
||||
*
|
||||
* ⚠️ 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
|
||||
* `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
|
||||
* while it reads the vault, so nothing would ever compose.
|
||||
*/
|
||||
private fun pumping(timeoutMillis: Long, condition: () -> Boolean): Boolean {
|
||||
private fun pumping(
|
||||
timeoutMillis: Long,
|
||||
condition: () -> Boolean,
|
||||
): Boolean {
|
||||
compose.mainClock.autoAdvance = false
|
||||
try {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
@@ -229,13 +177,17 @@ class LiveSignUpFleetTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun visibleText(): String = runCatching {
|
||||
compose.onRoot().printToString(maxDepth = 30)
|
||||
}.getOrElse { "(could not read the screen: $it)" }
|
||||
private fun visibleText(): String =
|
||||
runCatching {
|
||||
compose.onRoot().printToString(maxDepth = 30)
|
||||
}.getOrElse { "(could not read the screen: $it)" }
|
||||
|
||||
private fun reachable(relay: String): Boolean = runCatching {
|
||||
(java.net.URL("https://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection)
|
||||
.apply { connectTimeout = 8000; readTimeout = 8000 }
|
||||
.responseCode in 200..299
|
||||
}.getOrDefault(false)
|
||||
private fun reachable(relay: String): Boolean =
|
||||
runCatching {
|
||||
(java.net.URL("https://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection)
|
||||
.apply {
|
||||
connectTimeout = 8000
|
||||
readTimeout = 8000
|
||||
}.responseCode in 200..299
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
@@ -7,26 +7,30 @@ import java.security.Security
|
||||
|
||||
/**
|
||||
* What this Android build actually offers, algorithm by algorithm.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ProviderProbeTest {
|
||||
|
||||
@Test
|
||||
fun whoServesWhat() {
|
||||
val interesting = listOf(
|
||||
"KeyPairGenerator.XDH", "KeyFactory.XDH", "KeyAgreement.XDH",
|
||||
"KeyPairGenerator.Ed25519", "KeyFactory.Ed25519", "Signature.Ed25519",
|
||||
"KeyPairGenerator.EdDSA", "KeyFactory.EdDSA", "Signature.EdDSA",
|
||||
"SecretKeyFactory.PBKDF2WithHmacSHA256",
|
||||
)
|
||||
val interesting =
|
||||
listOf(
|
||||
"KeyPairGenerator.XDH",
|
||||
"KeyFactory.XDH",
|
||||
"KeyAgreement.XDH",
|
||||
"KeyPairGenerator.Ed25519",
|
||||
"KeyFactory.Ed25519",
|
||||
"Signature.Ed25519",
|
||||
"KeyPairGenerator.EdDSA",
|
||||
"KeyFactory.EdDSA",
|
||||
"Signature.EdDSA",
|
||||
"SecretKeyFactory.PBKDF2WithHmacSHA256",
|
||||
)
|
||||
for (service in interesting) {
|
||||
val providers = Security.getProviders()
|
||||
.filter { p -> p.services.any { "${it.type}.${it.algorithm}".equals(service, true) } }
|
||||
.map { it.name }
|
||||
val providers =
|
||||
Security
|
||||
.getProviders()
|
||||
.filter { p -> p.services.any { "${it.type}.${it.algorithm}".equals(service, true) } }
|
||||
.map { it.name }
|
||||
android.util.Log.i("fnprobe", "$service -> ${providers.ifEmpty { listOf("NONE") }}")
|
||||
}
|
||||
android.util.Log.i("fnprobe", "sdk=${android.os.Build.VERSION.SDK_INT}")
|
||||
|
||||
@@ -5,13 +5,13 @@ import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.printToString
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextClearance
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.compose.ui.test.printToString
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
@@ -20,101 +20,56 @@ 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
|
||||
* 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
|
||||
* as a product bug. The handle is derived from the clock, so a repeat run is a
|
||||
* repeat test rather than a different one.
|
||||
*
|
||||
* Skipped, not failed, when the relay is unreachable: a laptop with no network
|
||||
* is not a broken app.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SignUpFlowTest {
|
||||
|
||||
/**
|
||||
* 🔴 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
|
||||
* focus, so the Compose rule attaches to a window that has no hierarchy
|
||||
* yet and every test dies with "No compose hierarchies found in the app".
|
||||
*
|
||||
* `order = 0` puts this outside the Compose rule, so the grant happens
|
||||
* first and no dialog ever appears.
|
||||
/**
|
||||
* Granted BEFORE the activity launches, and that ordering is the point.
|
||||
*/
|
||||
@get:Rule(order = 0)
|
||||
val permissions: GrantPermissionRule = GrantPermissionRule.grant(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
val permissions: GrantPermissionRule =
|
||||
GrantPermissionRule.grant(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
|
||||
@get:Rule(order = 1)
|
||||
val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
/**
|
||||
* Which relay to drive.
|
||||
*
|
||||
* 🔴 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
|
||||
* production for every run is a cost nobody chose.
|
||||
*
|
||||
* ./gradlew :app:connectedDebugAndroidTest \
|
||||
* -Pandroid.testInstrumentationRunnerArguments.relay=10.0.2.2:18810
|
||||
*/
|
||||
private val relay: String
|
||||
get() = InstrumentationRegistry.getArguments().getString("relay")
|
||||
?: "relay1.flamenet.io"
|
||||
get() =
|
||||
InstrumentationRegistry.getArguments().getString("relay")
|
||||
?: "relay1.flamenet.io"
|
||||
|
||||
/** A local relay is plain HTTP; anything else is not. */
|
||||
private val relayScheme: String get() = if (relay.startsWith("10.0.2.2") ||
|
||||
relay.startsWith("127.0.0.1") || relay.startsWith("localhost")) "http" else "https"
|
||||
private val relayScheme: String get() =
|
||||
if (relay.startsWith("10.0.2.2") ||
|
||||
relay.startsWith("127.0.0.1") || relay.startsWith("localhost")
|
||||
) {
|
||||
"http"
|
||||
} else {
|
||||
"https"
|
||||
}
|
||||
private lateinit var handle: String
|
||||
|
||||
@Before
|
||||
fun startSignedOut() {
|
||||
handle = "andt" + (System.currentTimeMillis() % 100_000_000)
|
||||
|
||||
// ⚠️ 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
|
||||
// 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
|
||||
// a person does, and the view model reopens the vault afterwards.
|
||||
// Do NOT wipe the Keystore here.
|
||||
signOutIfSignedIn()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 —
|
||||
* 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
|
||||
// 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
|
||||
// sign-in field. This race is why the suite passed one test at a time
|
||||
// and failed as a set.
|
||||
// Wait for the app to settle before deciding anything.
|
||||
awaitAny(listOf("relay", "nav-contacts", "nav-account", "back"), 30_000)
|
||||
|
||||
// Close any open thread. The chat screen replaces the bottom nav, so a
|
||||
// test inheriting an open conversation cannot reach Account at all.
|
||||
// Close any open thread.
|
||||
if (nodesWithTag("back").isNotEmpty()) {
|
||||
compose.onNodeWithTag("back").performClick()
|
||||
awaitTag("nav-contacts", 20_000)
|
||||
@@ -126,9 +81,11 @@ class SignUpFlowTest {
|
||||
awaitTag("relay", 30_000)
|
||||
}
|
||||
|
||||
|
||||
/** Wait until any one of these tags exists. */
|
||||
private fun awaitAny(tags: List<String>, timeoutMillis: Long) {
|
||||
private fun awaitAny(
|
||||
tags: List<String>,
|
||||
timeoutMillis: Long,
|
||||
) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
try {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
@@ -142,61 +99,52 @@ class SignUpFlowTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun nodesWithTag(tag: String): List<Any> = runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes()
|
||||
}.getOrDefault(emptyList())
|
||||
private fun nodesWithTag(tag: String): List<Any> =
|
||||
runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
//
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
// Turning it off for the whole test is the obvious next mistake: the
|
||||
// app opens on a spinner while it reads the vault, so nothing would
|
||||
// ever compose and even the first field would be missing.
|
||||
private fun awaitTag(
|
||||
tag: String,
|
||||
timeoutMillis: Long,
|
||||
) {
|
||||
// Auto-advance is turned off HERE, not for the whole test.
|
||||
compose.mainClock.autoAdvance = false
|
||||
try {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
compose.mainClock.advanceTimeBy(100)
|
||||
val found = runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().isNotEmpty()
|
||||
}.getOrDefault(false)
|
||||
val found =
|
||||
runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().isNotEmpty()
|
||||
}.getOrDefault(false)
|
||||
if (found) return
|
||||
Thread.sleep(100)
|
||||
}
|
||||
// ⚠️ 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(
|
||||
"'$tag' never appeared within ${timeoutMillis}ms. On screen: ${visibleText()}")
|
||||
// Report what the screen actually says.
|
||||
throw AssertionError("'$tag' never appeared within ${timeoutMillis}ms. On screen: ${visibleText()}")
|
||||
} finally {
|
||||
compose.mainClock.autoAdvance = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything the screen is showing, for a failure message worth reading. */
|
||||
private fun visibleText(): String = runCatching {
|
||||
compose.onRoot().printToString(maxDepth = 30)
|
||||
}.getOrElse { "(could not read the screen: $it)" }
|
||||
private fun visibleText(): String =
|
||||
runCatching {
|
||||
compose.onRoot().printToString(maxDepth = 30)
|
||||
}.getOrElse { "(could not read the screen: $it)" }
|
||||
|
||||
private fun relayReachable(): Boolean = runCatching {
|
||||
(java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection)
|
||||
.apply { connectTimeout = 8000; readTimeout = 8000 }
|
||||
.responseCode in 200..299
|
||||
}.getOrDefault(false)
|
||||
private fun relayReachable(): Boolean =
|
||||
runCatching {
|
||||
(java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection)
|
||||
.apply {
|
||||
connectTimeout = 8000
|
||||
readTimeout = 8000
|
||||
}.responseCode in 200..299
|
||||
}.getOrDefault(false)
|
||||
|
||||
@Test
|
||||
fun aPersonCanCreateAnAccountOnASelfHostedRelay() {
|
||||
@@ -207,8 +155,7 @@ class SignUpFlowTest {
|
||||
compose.onNodeWithTag("toggle-register").performClick()
|
||||
|
||||
// A relay with its own account table asks for an email; the shared
|
||||
// service does not. If this node is missing, the hostname routing that
|
||||
// decides which provider is in use has gone wrong.
|
||||
// service does not.
|
||||
compose.onNodeWithTag("email").assertIsDisplayed()
|
||||
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
|
||||
compose.onNodeWithTag("handle").performTextInput(handle)
|
||||
@@ -223,9 +170,6 @@ 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.
|
||||
*/
|
||||
@Test
|
||||
fun signingOutReturnsToSignIn() {
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color as AndroidColor
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -14,38 +13,33 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.e2e.QrCode
|
||||
import android.graphics.Color as AndroidColor
|
||||
|
||||
/**
|
||||
* Your address, as a code somebody can scan and a string they can copy.
|
||||
*
|
||||
* 🔴 **Android had no way to show this at all**, which made the cross-relay
|
||||
* work iOS-only in practice: an address you cannot display is an address
|
||||
* nobody can send to. The engine could encode an fnid and had no way to derive
|
||||
* one, so there was nothing to put on a screen even if a screen had existed.
|
||||
*
|
||||
* ⚠️ **The string stays visible under the code.** An fnid is a derived key, not
|
||||
* a name — an address that is nearly right addresses somebody else — and a
|
||||
* scanner is not always to hand. Copy is there because retyping 52 characters
|
||||
* off a screen is how the wrong person gets messaged.
|
||||
*/
|
||||
@Composable
|
||||
fun AddressCard(fnid: String?, relay: String, handle: String? = null) {
|
||||
fun AddressCard(
|
||||
fnid: String?,
|
||||
relay: String,
|
||||
handle: String? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
@@ -53,12 +47,10 @@ 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
|
||||
// cannot be reached from another relay, and a blank space would
|
||||
// read as the feature not existing rather than as it not working.
|
||||
// Said plainly rather than hidden.
|
||||
Text(
|
||||
"This device could not derive your address. You can still message "
|
||||
+ "people on ${relay}, but nobody on another relay can reach you.",
|
||||
"This device could not derive your address. You can still message " +
|
||||
"people on $relay, but nobody on another relay can reach you.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.testTag("address-missing"),
|
||||
)
|
||||
@@ -66,12 +58,12 @@ 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 —
|
||||
// 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.
|
||||
val encoded = "flamenet:$address" +
|
||||
(handle?.takeIf { it.isNotBlank() }?.let { "?u=$it" } ?: "")
|
||||
// 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.
|
||||
val encoded =
|
||||
"flamenet:$address" +
|
||||
(handle?.takeIf { it.isNotBlank() }?.let { "?u=$it" } ?: "")
|
||||
val bitmap = remember(encoded) { qrBitmap(encoded) }
|
||||
|
||||
Column(
|
||||
@@ -82,23 +74,16 @@ 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 —
|
||||
// and a QR only has to be big enough for a phone camera a
|
||||
// foot away, which 140dp at this density is.
|
||||
// 140, not 180.
|
||||
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
|
||||
// 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.
|
||||
// Broken at the `@`, not wherever the line runs out.
|
||||
Text(
|
||||
fnid,
|
||||
fontSize = 12.sp,
|
||||
@@ -125,27 +110,30 @@ 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 "
|
||||
+ "lets it outlive any one relay.",
|
||||
"Derived from your passphrase, not issued by a server. Sign in anywhere " +
|
||||
"with the same passphrase and this stays the same, which is what " +
|
||||
"lets it outlive any one relay.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun copy(context: Context, label: String, text: String) {
|
||||
private fun copy(
|
||||
context: Context,
|
||||
label: String,
|
||||
text: String,
|
||||
) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(label, text))
|
||||
}
|
||||
|
||||
/**
|
||||
* One module per pixel plus a quiet zone; Compose scales it up.
|
||||
*
|
||||
* ⚠️ The four-module quiet zone is not decoration. A QR printed hard against
|
||||
* other content does not scan, and the margin has to be part of the bitmap
|
||||
* because the surrounding card has a background of its own.
|
||||
*/
|
||||
private fun qrBitmap(text: String, quiet: Int = 4): Bitmap? {
|
||||
private fun qrBitmap(
|
||||
text: String,
|
||||
quiet: Int = 4,
|
||||
): Bitmap? {
|
||||
val matrix = runCatching { QrCode.encode(text) }.getOrNull() ?: return null
|
||||
val side = matrix.size + quiet * 2
|
||||
val pixels = IntArray(side * side) { AndroidColor.WHITE }
|
||||
|
||||
@@ -3,8 +3,6 @@ package io.flamenet.messenger.app
|
||||
import android.content.Context
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -19,36 +17,15 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* platforms disagreeing about a security guarantee is how one of them ends up
|
||||
* being the one that leaks.
|
||||
*
|
||||
* **What this defends against, stated plainly, because the alternative is
|
||||
* 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
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
class AppLock(private val context: Context) {
|
||||
|
||||
class AppLock(
|
||||
private val context: Context,
|
||||
) {
|
||||
companion object {
|
||||
private const val PREFS = "flamenet-app-lock"
|
||||
private const val ENABLED = "enabled"
|
||||
@@ -61,33 +38,16 @@ 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
|
||||
* is ordinary, and making the first slip cost a wait would punish the
|
||||
* owner far more often than anybody else.
|
||||
*/
|
||||
const val ATTEMPTS_BEFORE_DELAY = 3
|
||||
|
||||
/**
|
||||
* The longest wait this will ever impose.
|
||||
*
|
||||
* 🔴 **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 data is on their own phone.
|
||||
*/
|
||||
const val MAXIMUM_DELAY_MS = 300_000L
|
||||
|
||||
/**
|
||||
* How long to wait after [failures] consecutive failed unlocks.
|
||||
*
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
fun delayMillis(failures: Int): Long {
|
||||
if (failures < ATTEMPTS_BEFORE_DELAY) return 0L
|
||||
@@ -104,10 +64,7 @@ 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
|
||||
* setting could not be read until after sign-in, which is exactly when the
|
||||
* lock needs to already be up.
|
||||
* Ordinary SharedPreferences, NOT the vault.
|
||||
*/
|
||||
var enabled: Boolean
|
||||
get() = prefs.getBoolean(ENABLED, false)
|
||||
@@ -125,25 +82,19 @@ 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
|
||||
* cannot authenticate locks somebody out of their own messages with no way
|
||||
* back, which is worse than not having the feature.
|
||||
*/
|
||||
fun available(): Boolean =
|
||||
BiometricManager.from(context).canAuthenticate(AUTHENTICATORS) ==
|
||||
BiometricManager.BIOMETRIC_SUCCESS
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
* When the app was last sent to the background.
|
||||
*/
|
||||
private var leftAt: Long? = null
|
||||
|
||||
fun noteLeft(now: Long = System.currentTimeMillis()) { leftAt = now }
|
||||
fun noteLeft(now: Long = System.currentTimeMillis()) {
|
||||
leftAt = now
|
||||
}
|
||||
|
||||
fun shouldLock(now: Long = System.currentTimeMillis()): Boolean {
|
||||
if (!enabled) return false
|
||||
@@ -151,28 +102,21 @@ class AppLock(private val context: Context) {
|
||||
return (now - away) >= graceSeconds * 1000L
|
||||
}
|
||||
|
||||
fun noteUnlocked() { leftAt = null }
|
||||
fun noteUnlocked() {
|
||||
leftAt = null
|
||||
}
|
||||
|
||||
// ---- Backing off (ios#41) -------------------------------------------
|
||||
//
|
||||
// 🔴 **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.
|
||||
// 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.
|
||||
|
||||
/** Consecutive failures so far. */
|
||||
val failures: Int get() = prefs.getInt(FAILURES, 0)
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* keystore, exactly as the note at the top of this file says.
|
||||
*/
|
||||
fun waitingUntil(now: Long = System.currentTimeMillis()): Long? {
|
||||
val until = prefs.getLong(LOCKED_UNTIL, 0L)
|
||||
@@ -186,27 +130,28 @@ 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
|
||||
* wait by changing their mind twice. See the error handling in
|
||||
* [authenticate], which is where that distinction is actually made.
|
||||
* Only a wrong answer counts.
|
||||
*/
|
||||
fun recordFailure(now: Long = System.currentTimeMillis()) {
|
||||
val next = failures + 1
|
||||
val wait = delayMillis(next)
|
||||
prefs.edit().apply {
|
||||
putInt(FAILURES, next)
|
||||
if (wait > 0L) putLong(LOCKED_UNTIL, now + wait)
|
||||
}.apply()
|
||||
prefs
|
||||
.edit()
|
||||
.apply {
|
||||
putInt(FAILURES, next)
|
||||
if (wait > 0L) putLong(LOCKED_UNTIL, now + wait)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget the failures. On a successful unlock, and when the feature is
|
||||
* switched off — leaving a wait behind on a disabled lock would strand
|
||||
* somebody behind a screen that is no longer supposed to exist.
|
||||
* Forget the failures.
|
||||
*/
|
||||
fun clearFailures() {
|
||||
prefs.edit().remove(FAILURES).remove(LOCKED_UNTIL).apply()
|
||||
prefs
|
||||
.edit()
|
||||
.remove(FAILURES)
|
||||
.remove(LOCKED_UNTIL)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** How to say the wait, in whichever unit reads naturally. */
|
||||
@@ -223,76 +168,66 @@ 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
|
||||
* fires and the lock screen sits there looking broken.
|
||||
*/
|
||||
fun authenticate(
|
||||
activity: FragmentActivity,
|
||||
onSuccess: () -> Unit,
|
||||
onFailure: (String) -> Unit,
|
||||
) {
|
||||
// 🔴 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 }
|
||||
// Refuse BEFORE showing the prompt.
|
||||
waitMessage()?.let {
|
||||
onFailure(it)
|
||||
return
|
||||
}
|
||||
|
||||
val prompt = BiometricPrompt(
|
||||
activity,
|
||||
ContextCompat.getMainExecutor(activity),
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(
|
||||
result: BiometricPrompt.AuthenticationResult,
|
||||
) {
|
||||
clearFailures()
|
||||
onSuccess()
|
||||
}
|
||||
val prompt =
|
||||
BiometricPrompt(
|
||||
activity,
|
||||
ContextCompat.getMainExecutor(activity),
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
clearFailures()
|
||||
onSuccess()
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ **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
|
||||
* dismissing the prompt cannot back the owner into a wait.
|
||||
*/
|
||||
override fun onAuthenticationFailed() {
|
||||
recordFailure()
|
||||
}
|
||||
/**
|
||||
* The one callback that means a wrong answer.
|
||||
*/
|
||||
override fun onAuthenticationFailed() {
|
||||
recordFailure()
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(code: Int, message: CharSequence) {
|
||||
// ⚠️ 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
|
||||
// ours. Two back-offs stacking would produce a wait nobody
|
||||
// can predict from either rule.
|
||||
onFailure(message.toString())
|
||||
}
|
||||
},
|
||||
)
|
||||
override fun onAuthenticationError(
|
||||
code: Int,
|
||||
message: CharSequence,
|
||||
) {
|
||||
// A cancel is not a failure.
|
||||
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
|
||||
// ours.
|
||||
onFailure(message.toString())
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
prompt.authenticate(
|
||||
BiometricPrompt.PromptInfo.Builder()
|
||||
BiometricPrompt.PromptInfo
|
||||
.Builder()
|
||||
.setTitle("Unlock Flamenet")
|
||||
.setSubtitle("Your messages are locked on this device.")
|
||||
.setAllowedAuthenticators(AUTHENTICATORS)
|
||||
.build()
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What is on screen while the app is locked.
|
||||
*
|
||||
* 🔴 **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.
|
||||
*/
|
||||
@Composable
|
||||
fun LockScreen(onUnlock: () -> Unit) {
|
||||
|
||||
@@ -24,75 +24,56 @@ 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
|
||||
* 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
|
||||
* 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
|
||||
* inherent, and it means the existing install base still has to be reached the
|
||||
* way it always was, by telling them.
|
||||
*/
|
||||
object AppUpdate {
|
||||
|
||||
private const val PREFS = "app-update"
|
||||
private const val LAST_CHECKED = "last-checked-at"
|
||||
private const val DISMISSED_CODE = "dismissed-version-code"
|
||||
|
||||
/**
|
||||
* Six hours. Long enough that opening the app repeatedly costs one request
|
||||
* a quarter of a day, short enough that a fix published in the morning is
|
||||
* offered the same day.
|
||||
* Six hours.
|
||||
*/
|
||||
private const val CHECK_INTERVAL_MS = 6L * 60 * 60 * 1000
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* What is actually installed, asked of the package manager.
|
||||
*
|
||||
* ⚠️ 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.
|
||||
*/
|
||||
fun installedVersionCode(context: Context): Int = runCatching {
|
||||
val info = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
info.longVersionCode.toInt()
|
||||
}.getOrDefault(0)
|
||||
fun installedVersionCode(context: Context): Int =
|
||||
runCatching {
|
||||
val info = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
info.longVersionCode.toInt()
|
||||
}.getOrDefault(0)
|
||||
|
||||
fun installedVersionName(context: Context): String = runCatching {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||
}.getOrNull() ?: "unknown"
|
||||
fun installedVersionName(context: Context): String =
|
||||
runCatching {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||
}.getOrNull() ?: "unknown"
|
||||
|
||||
private fun dueForCheck(context: Context): Boolean =
|
||||
System.currentTimeMillis() - prefs(context).getLong(LAST_CHECKED, 0) > CHECK_INTERVAL_MS
|
||||
|
||||
private fun recordCheck(context: Context) =
|
||||
prefs(context).edit().putLong(LAST_CHECKED, System.currentTimeMillis()).apply()
|
||||
private fun recordCheck(context: Context) = prefs(context).edit().putLong(LAST_CHECKED, System.currentTimeMillis()).apply()
|
||||
|
||||
/**
|
||||
* 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
|
||||
* boolean would silence the feature permanently on the first dismissal,
|
||||
* which is how update prompts end up being useless.
|
||||
*/
|
||||
fun dismiss(context: Context, versionCode: Int) =
|
||||
prefs(context).edit().putInt(DISMISSED_CODE, versionCode).apply()
|
||||
fun dismiss(
|
||||
context: Context,
|
||||
versionCode: Int,
|
||||
) = prefs(context).edit().putInt(DISMISSED_CODE, versionCode).apply()
|
||||
|
||||
fun isDismissed(context: Context, versionCode: Int): Boolean =
|
||||
prefs(context).getInt(DISMISSED_CODE, 0) >= versionCode
|
||||
fun isDismissed(
|
||||
context: Context,
|
||||
versionCode: Int,
|
||||
): Boolean = prefs(context).getInt(DISMISSED_CODE, 0) >= versionCode
|
||||
|
||||
/** Hand the download to the browser, which is where an APK install starts. */
|
||||
fun openDownload(context: Context, url: String) {
|
||||
fun openDownload(
|
||||
context: Context,
|
||||
url: String,
|
||||
) {
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
@@ -103,34 +84,31 @@ object AppUpdate {
|
||||
|
||||
/**
|
||||
* Where releases are published when the relay does not publish them.
|
||||
*
|
||||
* 🔴 **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
|
||||
* user asked, and answering "your relay does not publish updates, so I
|
||||
* cannot tell you" when we plainly could is unhelpful. The screen says
|
||||
* which host answered, so this is never a silent phone-home.
|
||||
*/
|
||||
const val OFFICIAL_SITE = "https://flamenetmessenger.com"
|
||||
|
||||
/** What an explicit check found, including which host answered. */
|
||||
sealed interface CheckResult {
|
||||
data class Available(val release: UpdateCheck.Release, val source: String) : CheckResult
|
||||
data class UpToDate(val source: String) : CheckResult
|
||||
data class Available(
|
||||
val release: UpdateCheck.Release,
|
||||
val source: String,
|
||||
) : CheckResult
|
||||
|
||||
data class UpToDate(
|
||||
val source: String,
|
||||
) : CheckResult
|
||||
|
||||
/** Neither host published a manifest, or neither could be reached. */
|
||||
data object Unknown : CheckResult
|
||||
}
|
||||
|
||||
/**
|
||||
* The check behind the button: this relay first, then the official site.
|
||||
*
|
||||
* ⚠️ 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
|
||||
* do is still the answer for the person who chose that relay.
|
||||
*/
|
||||
suspend fun checkNow(context: Context, baseUrl: String?): CheckResult =
|
||||
suspend fun checkNow(
|
||||
context: Context,
|
||||
baseUrl: String?,
|
||||
): CheckResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val installed = installedVersionCode(context)
|
||||
val hosts = listOfNotNull(baseUrl?.takeIf { it.isNotBlank() }, OFFICIAL_SITE)
|
||||
@@ -146,23 +124,17 @@ object AppUpdate {
|
||||
CheckResult.Unknown
|
||||
}
|
||||
|
||||
/** 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
|
||||
/** 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
|
||||
* 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.
|
||||
*
|
||||
* Returns null when there is nothing to say, which is the overwhelmingly
|
||||
* common answer and must stay silent.
|
||||
*/
|
||||
suspend fun check(context: Context, baseUrl: String?, force: Boolean = false):
|
||||
UpdateCheck.Release? {
|
||||
suspend fun check(
|
||||
context: Context,
|
||||
baseUrl: String?,
|
||||
force: Boolean = false,
|
||||
): UpdateCheck.Release? {
|
||||
if (baseUrl.isNullOrBlank()) return null
|
||||
if (!force && !dueForCheck(context)) return null
|
||||
val installed = installedVersionCode(context)
|
||||
@@ -173,9 +145,6 @@ 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
|
||||
* answer is that relay's, not a global fact.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberAvailable(baseUrl: String?): UpdateCheck.Release? {
|
||||
@@ -188,11 +157,6 @@ 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
|
||||
* to talk about itself trains people to dismiss it without reading, and then
|
||||
* the one update that mattered gets dismissed too.
|
||||
*/
|
||||
@Composable
|
||||
fun UpdateAvailableDialog(baseUrl: String?) {
|
||||
@@ -233,8 +197,7 @@ fun UpdateAvailableDialog(baseUrl: String?) {
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
// Declining means declining THIS version, permanently. The
|
||||
// next one asks again.
|
||||
// Declining means declining THIS version, permanently.
|
||||
AppUpdate.dismiss(context, release.versionCode)
|
||||
showing = false
|
||||
},
|
||||
|
||||
@@ -7,18 +7,12 @@ 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
|
||||
* 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
|
||||
* `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
|
||||
* ago.
|
||||
*/
|
||||
class CallActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
if (intent.action != MessengerService.ACTION_DECLINE) return
|
||||
PendingIncomingCall.clearAll()
|
||||
runCatching { NotificationManagerCompat.from(context).cancel(3) }
|
||||
|
||||
@@ -6,14 +6,10 @@ import org.json.JSONObject
|
||||
import org.webrtc.AudioSource
|
||||
import org.webrtc.AudioTrack
|
||||
import org.webrtc.Camera2Enumerator
|
||||
import org.webrtc.DataChannel
|
||||
import org.webrtc.DefaultVideoDecoderFactory
|
||||
import org.webrtc.DefaultVideoEncoderFactory
|
||||
import org.webrtc.EglBase
|
||||
import org.webrtc.SurfaceTextureHelper
|
||||
import org.webrtc.VideoCapturer
|
||||
import org.webrtc.VideoSource
|
||||
import org.webrtc.VideoTrack
|
||||
import org.webrtc.DataChannel
|
||||
import org.webrtc.IceCandidate
|
||||
import org.webrtc.MediaConstraints
|
||||
import org.webrtc.MediaStream
|
||||
@@ -22,28 +18,14 @@ import org.webrtc.PeerConnectionFactory
|
||||
import org.webrtc.RtpReceiver
|
||||
import org.webrtc.SdpObserver
|
||||
import org.webrtc.SessionDescription
|
||||
import org.webrtc.SurfaceTextureHelper
|
||||
import org.webrtc.VideoCapturer
|
||||
import org.webrtc.VideoSource
|
||||
import org.webrtc.VideoTrack
|
||||
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
|
||||
* 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
|
||||
* "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
|
||||
* that could read signalling would learn the addresses of both parties even on
|
||||
* a call it never carries.
|
||||
*/
|
||||
class CallManager(
|
||||
private val context: Context,
|
||||
@@ -51,11 +33,14 @@ class CallManager(
|
||||
private val signal: (Long, Content.CallSignal) -> Unit,
|
||||
private val onStateChanged: (State) -> Unit,
|
||||
) {
|
||||
|
||||
enum class State { IDLE, DIALLING, RINGING, CONNECTING, ACTIVE, ENDED }
|
||||
|
||||
data class Active(val callId: String, val peerId: Long, val incoming: Boolean,
|
||||
val video: Boolean = false)
|
||||
data class Active(
|
||||
val callId: String,
|
||||
val peerId: Long,
|
||||
val incoming: Boolean,
|
||||
val video: Boolean = false,
|
||||
)
|
||||
|
||||
var current: Active? = null
|
||||
private set
|
||||
@@ -73,7 +58,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.
|
||||
val eglBase: EglBase by lazy { EglBase.create() }
|
||||
private var videoCapturer: VideoCapturer? = null
|
||||
private var videoHelper: SurfaceTextureHelper? = null
|
||||
@@ -82,31 +67,20 @@ class CallManager(
|
||||
private set
|
||||
var remoteVideoTrack: VideoTrack? = null
|
||||
private set
|
||||
|
||||
/** Fired on the main thread when a remote video track appears or goes. */
|
||||
var onVideoTracksChanged: (() -> Unit)? = null
|
||||
|
||||
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
|
||||
* trickle off, gathering starts at `setLocalDescription` and the
|
||||
* 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…"
|
||||
* until someone gave up. The complete SDP exists only after
|
||||
* [PeerConnection.IceGatheringState.COMPLETE], read back from
|
||||
* `localDescription`.
|
||||
*/
|
||||
private var onGathered: ((String) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Sends whatever was gathered if COMPLETE never fires. A TURN server that
|
||||
* is configured but unreachable stalls gathering for tens of seconds;
|
||||
* host + STUN candidates collected by then usually still connect.
|
||||
* Sends whatever was gathered if COMPLETE never fires.
|
||||
*/
|
||||
private var gatherGuard: Runnable? = null
|
||||
|
||||
@@ -115,17 +89,15 @@ 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
|
||||
* the app closed.
|
||||
*/
|
||||
private var connectGuard: Runnable? = null
|
||||
|
||||
private fun armConnectGuard() {
|
||||
connectGuard?.let { main.removeCallbacks(it) }
|
||||
val guard = Runnable {
|
||||
if (state == State.CONNECTING) hangUp(notifyPeer = true, reason = "timeout")
|
||||
}
|
||||
val guard =
|
||||
Runnable {
|
||||
if (state == State.CONNECTING) hangUp(notifyPeer = true, reason = "timeout")
|
||||
}
|
||||
connectGuard = guard
|
||||
main.postDelayed(guard, CONNECT_TIMEOUT_MS)
|
||||
}
|
||||
@@ -136,58 +108,55 @@ 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
|
||||
// then COMPLETE fired before this registration and nothing else will.
|
||||
// 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
|
||||
// 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
|
||||
// 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 " +
|
||||
"gathering (state=${connection?.iceGatheringState()}). " +
|
||||
"The other side will never see this call.")
|
||||
return
|
||||
}
|
||||
android.util.Log.i("fnmsg-call", "sending signal after gathering, sdp=${sdp.length} bytes, " +
|
||||
"state=${connection?.iceGatheringState()}")
|
||||
// This `?: return` sent nobody anything, silently.
|
||||
val sdp =
|
||||
connection?.localDescription?.description ?: run {
|
||||
android.util.Log.w(
|
||||
"fnmsg-call",
|
||||
"NOT SENDING the offer/answer: no localDescription after " +
|
||||
"gathering (state=${connection?.iceGatheringState()}). " +
|
||||
"The other side will never see this call.",
|
||||
)
|
||||
return
|
||||
}
|
||||
android.util.Log.i(
|
||||
"fnmsg-call",
|
||||
"sending signal after gathering, sdp=${sdp.length} bytes, " +
|
||||
"state=${connection?.iceGatheringState()}",
|
||||
)
|
||||
send(sdp)
|
||||
}
|
||||
|
||||
/**
|
||||
* ICE servers, P2P-first.
|
||||
*
|
||||
* ⚠️ 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
|
||||
* saying so is better than pretending a relay exists.
|
||||
*/
|
||||
private fun iceServers(turn: JSONObject?): List<PeerConnection.IceServer> {
|
||||
if (turn == null) {
|
||||
return listOf(
|
||||
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302")
|
||||
.createIceServer()
|
||||
PeerConnection.IceServer
|
||||
.builder("stun:stun.l.google.com:19302")
|
||||
.createIceServer(),
|
||||
)
|
||||
}
|
||||
val urls = turn.optJSONArray("urls") ?: return emptyList()
|
||||
val username = turn.optString("username")
|
||||
val credential = turn.optString("credential")
|
||||
return (0 until urls.length()).map { i ->
|
||||
PeerConnection.IceServer.builder(urls.getString(i))
|
||||
PeerConnection.IceServer
|
||||
.builder(urls.getString(i))
|
||||
.setUsername(username)
|
||||
.setPassword(credential)
|
||||
.createIceServer()
|
||||
@@ -197,95 +166,105 @@ class CallManager(
|
||||
private fun ensureFactory(): PeerConnectionFactory {
|
||||
factory?.let { return it }
|
||||
PeerConnectionFactory.initialize(
|
||||
PeerConnectionFactory.InitializationOptions.builder(context)
|
||||
.createInitializationOptions()
|
||||
PeerConnectionFactory.InitializationOptions
|
||||
.builder(context)
|
||||
.createInitializationOptions(),
|
||||
)
|
||||
return PeerConnectionFactory.builder()
|
||||
// Video codecs even for a voice call: the factory is built once
|
||||
// per process, and a voice-only factory would make the first
|
||||
// video call render nothing while looking connected.
|
||||
.setVideoEncoderFactory(
|
||||
DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true))
|
||||
return PeerConnectionFactory
|
||||
.builder()
|
||||
// Video codecs even for a voice call: the factory is built once per
|
||||
// process, and a voice-only factory would make the first video call
|
||||
// render nothing while looking connected.
|
||||
.setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true))
|
||||
.setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase.eglBaseContext))
|
||||
.createPeerConnectionFactory()
|
||||
.also { factory = it }
|
||||
}
|
||||
|
||||
private fun open(turn: JSONObject?, peerId: Long, callId: String,
|
||||
video: Boolean): PeerConnection? {
|
||||
val config = PeerConnection.RTCConfiguration(iceServers(turn)).apply {
|
||||
// ALL rather than RELAY: direct paths are tried, and TURN is the
|
||||
// fallback. RELAY would force every call through the server, which
|
||||
// is the opposite of the default this product chose.
|
||||
iceTransportsType = PeerConnection.IceTransportsType.ALL
|
||||
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
|
||||
// Trickle is off: candidates are gathered before the offer is sent,
|
||||
// so signalling is ONE encrypted envelope each way rather than a
|
||||
// stream of them. Slower to connect by a second or two, and far
|
||||
// simpler to reason about over a message channel that is polled
|
||||
// rather than pushed.
|
||||
continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_ONCE
|
||||
}
|
||||
val observer = object : PeerConnection.Observer {
|
||||
override fun onIceCandidate(candidate: IceCandidate?) {
|
||||
// 🔴 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.
|
||||
val sdp = candidate?.sdp ?: return
|
||||
val kind = Regex("typ (\\w+)").find(sdp)?.groupValues?.get(1) ?: "?"
|
||||
android.util.Log.i("fnmsg-call", "ice candidate: typ=$kind")
|
||||
private fun open(
|
||||
turn: JSONObject?,
|
||||
peerId: Long,
|
||||
callId: String,
|
||||
video: Boolean,
|
||||
): PeerConnection? {
|
||||
val config =
|
||||
PeerConnection.RTCConfiguration(iceServers(turn)).apply {
|
||||
// ALL rather than RELAY: direct paths are tried, and TURN is
|
||||
// the fallback.
|
||||
iceTransportsType = PeerConnection.IceTransportsType.ALL
|
||||
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
|
||||
// Trickle is off: candidates are gathered before the offer is
|
||||
// sent, so signalling is ONE encrypted envelope each way rather
|
||||
// than a stream of them.
|
||||
continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_ONCE
|
||||
}
|
||||
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
|
||||
// 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:
|
||||
// SIGABRT, no AndroidRuntime trace, nothing to catch.
|
||||
override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) {
|
||||
android.util.Log.i("fnmsg-call", "ice connection -> $newState")
|
||||
runCatching {
|
||||
when (newState) {
|
||||
PeerConnection.IceConnectionState.CONNECTED,
|
||||
PeerConnection.IceConnectionState.COMPLETED -> state = State.ACTIVE
|
||||
// ⚠️ 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
|
||||
// killed the app every single time somebody pressed Hang up.
|
||||
PeerConnection.IceConnectionState.CLOSED -> Unit
|
||||
PeerConnection.IceConnectionState.FAILED -> hangUp(notifyPeer = true)
|
||||
else -> Unit
|
||||
val observer =
|
||||
object : PeerConnection.Observer {
|
||||
override fun onIceCandidate(candidate: IceCandidate?) {
|
||||
// The candidate TYPE is the whole diagnosis.
|
||||
val sdp = candidate?.sdp ?: return
|
||||
val kind = Regex("typ (\\w+)").find(sdp)?.groupValues?.get(1) ?: "?"
|
||||
android.util.Log.i("fnmsg-call", "ice candidate: typ=$kind")
|
||||
}
|
||||
|
||||
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 into
|
||||
// WebRTC from here.
|
||||
override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) {
|
||||
android.util.Log.i("fnmsg-call", "ice connection -> $newState")
|
||||
runCatching {
|
||||
when (newState) {
|
||||
PeerConnection.IceConnectionState.CONNECTED,
|
||||
PeerConnection.IceConnectionState.COMPLETED,
|
||||
-> state = State.ACTIVE
|
||||
|
||||
// CLOSED is what our OWN close() produces.
|
||||
PeerConnection.IceConnectionState.CLOSED -> Unit
|
||||
|
||||
PeerConnection.IceConnectionState.FAILED -> hangUp(notifyPeer = true)
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit
|
||||
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
|
||||
// exceptions, from inside a WebRTC callback.
|
||||
if (newState == PeerConnection.IceGatheringState.COMPLETE) {
|
||||
main.post { runCatching { deliverGatheredSdp() } }
|
||||
|
||||
override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit
|
||||
|
||||
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 exceptions, from inside a WebRTC callback.
|
||||
if (newState == PeerConnection.IceGatheringState.COMPLETE) {
|
||||
main.post { runCatching { deliverGatheredSdp() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun onAddStream(stream: MediaStream?) = Unit
|
||||
override fun onRemoveStream(stream: MediaStream?) = Unit
|
||||
override fun onDataChannel(channel: DataChannel?) = Unit
|
||||
override fun onRenegotiationNeeded() = Unit
|
||||
override fun onAddTrack(receiver: RtpReceiver?, streams: Array<out MediaStream>?) {
|
||||
// The other side's camera. Same JNI rules as above: store the
|
||||
// reference, hop to main, and let the UI attach a renderer.
|
||||
runCatching {
|
||||
val track = receiver?.track()
|
||||
if (track is VideoTrack) {
|
||||
remoteVideoTrack = track
|
||||
main.post { runCatching { onVideoTracksChanged?.invoke() } }
|
||||
|
||||
override fun onAddStream(stream: MediaStream?) = Unit
|
||||
|
||||
override fun onRemoveStream(stream: MediaStream?) = Unit
|
||||
|
||||
override fun onDataChannel(channel: DataChannel?) = Unit
|
||||
|
||||
override fun onRenegotiationNeeded() = Unit
|
||||
|
||||
override fun onAddTrack(
|
||||
receiver: RtpReceiver?,
|
||||
streams: Array<out MediaStream>?,
|
||||
) {
|
||||
// The other side's camera.
|
||||
runCatching {
|
||||
val track = receiver?.track()
|
||||
if (track is VideoTrack) {
|
||||
remoteVideoTrack = track
|
||||
main.post { runCatching { onVideoTracksChanged?.invoke() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val pc = ensureFactory().createPeerConnection(config, observer) ?: return null
|
||||
val source = ensureFactory().createAudioSource(MediaConstraints())
|
||||
@@ -294,13 +273,14 @@ 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
|
||||
// mid-flow, should still connect and say something.
|
||||
// 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)
|
||||
val name = cams.deviceNames.firstOrNull { cams.isFrontFacing(it) }
|
||||
?: cams.deviceNames.firstOrNull()
|
||||
val name =
|
||||
cams.deviceNames.firstOrNull { cams.isFrontFacing(it) }
|
||||
?: cams.deviceNames.firstOrNull()
|
||||
if (name != null) {
|
||||
val cap = cams.createCapturer(name, null)
|
||||
val helper = SurfaceTextureHelper.create("fncap", eglBase.eglBaseContext)
|
||||
@@ -330,45 +310,48 @@ class CallManager(
|
||||
}
|
||||
|
||||
/** Place a call. The offer is gathered, then sent as one encrypted envelope. */
|
||||
fun call(peerId: Long, turn: JSONObject?, video: Boolean = false) {
|
||||
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.
|
||||
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
|
||||
// observer, and the observer reads `current` to decide whether there
|
||||
// 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` BEFORE the state change.
|
||||
current = Active(callId, peerId, incoming = false, video = video)
|
||||
state = State.DIALLING
|
||||
val pc = open(turn, peerId, callId, video) ?: run {
|
||||
current = null
|
||||
state = State.IDLE
|
||||
return
|
||||
}
|
||||
pc.createOffer(object : SimpleSdpObserver() {
|
||||
override fun onCreateSuccess(sdp: SessionDescription) {
|
||||
pc.setLocalDescription(SimpleSdpObserver("offer:setLocal"), sdp)
|
||||
// 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 {
|
||||
sendWhenGathered { full ->
|
||||
signal(peerId, Content.CallSignal(
|
||||
Content.CallSignal.OFFER, callId, full, video = video))
|
||||
val pc =
|
||||
open(turn, peerId, callId, video) ?: run {
|
||||
current = null
|
||||
state = State.IDLE
|
||||
return
|
||||
}
|
||||
pc.createOffer(
|
||||
object : SimpleSdpObserver() {
|
||||
override fun onCreateSuccess(sdp: SessionDescription) {
|
||||
pc.setLocalDescription(SimpleSdpObserver("offer:setLocal"), sdp)
|
||||
// The complete SDP, with candidates, exists only after
|
||||
// gathering; see onGathered.
|
||||
main.post {
|
||||
sendWhenGathered { full ->
|
||||
signal(
|
||||
peerId,
|
||||
Content.CallSignal(Content.CallSignal.OFFER, callId, full, video = video),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
MediaConstraints(),
|
||||
)
|
||||
// Give up out loud.
|
||||
val guard =
|
||||
Runnable {
|
||||
if (state == State.DIALLING) hangUp(notifyPeer = true, reason = "timeout")
|
||||
}
|
||||
}, MediaConstraints())
|
||||
// 🔴 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")
|
||||
}
|
||||
dialGuard = guard
|
||||
main.postDelayed(guard, DIAL_TIMEOUT_MS)
|
||||
}
|
||||
@@ -377,50 +360,62 @@ class CallManager(
|
||||
private var pendingOffer: Pair<Long, Content.CallSignal>? = null
|
||||
|
||||
/**
|
||||
* A signal arrived down the ratchet. [sentAt] is when the relay accepted
|
||||
* it (epoch millis, 0 = unknown).
|
||||
* A signal arrived down the ratchet.
|
||||
*/
|
||||
fun onSignal(fromUser: Long, incoming: Content.CallSignal, turn: JSONObject?,
|
||||
sentAt: Long = 0) {
|
||||
android.util.Log.i("fnmsg-call",
|
||||
"call signal in: ${incoming.action} from $fromUser state=$state")
|
||||
fun onSignal(
|
||||
fromUser: Long,
|
||||
incoming: Content.CallSignal,
|
||||
turn: JSONObject?,
|
||||
sentAt: Long = 0,
|
||||
) {
|
||||
android.util.Log.i(
|
||||
"fnmsg-call",
|
||||
"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 {
|
||||
signal(fromUser, Content.CallSignal(
|
||||
Content.CallSignal.END, incoming.callId, reason = "busy"))
|
||||
signal(
|
||||
fromUser,
|
||||
Content.CallSignal(Content.CallSignal.END, incoming.callId, reason = "busy"),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
// 🔴 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
|
||||
// connects to nothing. The view model files it as missed.
|
||||
// An offer older than the caller's own patience is not a call.
|
||||
if (sentAt > 0 && System.currentTimeMillis() - sentAt > DIAL_TIMEOUT_MS) {
|
||||
android.util.Log.i("fnmsg-call",
|
||||
"offer from $fromUser dropped: ${System.currentTimeMillis() - sentAt}ms old")
|
||||
android.util.Log.i(
|
||||
"fnmsg-call",
|
||||
"offer from $fromUser dropped: ${System.currentTimeMillis() - sentAt}ms old",
|
||||
)
|
||||
onStaleOffer?.invoke(fromUser, sentAt)
|
||||
return
|
||||
}
|
||||
// ⚠️ One call at a time. A second offer while busy is declined
|
||||
// rather than silently dropped, so the caller hears something.
|
||||
// One call at a time.
|
||||
if (state != State.IDLE && state != State.ENDED) {
|
||||
signal(fromUser, Content.CallSignal(
|
||||
Content.CallSignal.END, incoming.callId, reason = "busy"))
|
||||
signal(
|
||||
fromUser,
|
||||
Content.CallSignal(Content.CallSignal.END, incoming.callId, reason = "busy"),
|
||||
)
|
||||
return
|
||||
}
|
||||
pendingOffer = fromUser to incoming
|
||||
current = Active(incoming.callId, fromUser, incoming = true,
|
||||
video = incoming.video)
|
||||
current =
|
||||
Active(
|
||||
incoming.callId,
|
||||
fromUser,
|
||||
incoming = true,
|
||||
video = incoming.video,
|
||||
)
|
||||
state = State.RINGING
|
||||
}
|
||||
|
||||
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) }
|
||||
@@ -432,7 +427,10 @@ class CallManager(
|
||||
SessionDescription(SessionDescription.Type.ANSWER, sdp),
|
||||
)
|
||||
}
|
||||
Content.CallSignal.END -> hangUp(notifyPeer = false)
|
||||
|
||||
Content.CallSignal.END -> {
|
||||
hangUp(notifyPeer = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,29 +438,38 @@ class CallManager(
|
||||
var onStaleOffer: ((Long, Long) -> Unit)? = null
|
||||
|
||||
/** 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.
|
||||
* Build and send the answer.
|
||||
*/
|
||||
private fun answerNow(
|
||||
pc: PeerConnection,
|
||||
fromUser: Long,
|
||||
offer: Content.CallSignal,
|
||||
) {
|
||||
pc.createAnswer(object : SimpleSdpObserver("answer:create") {
|
||||
override fun onCreateSuccess(answer: SessionDescription) {
|
||||
pc.setLocalDescription(SimpleSdpObserver("answer:setLocal"), answer)
|
||||
// Same as the offer: the answer is complete only after
|
||||
// gathering, and one sent early names no address to reach.
|
||||
main.post {
|
||||
sendWhenGathered { full ->
|
||||
signal(fromUser, Content.CallSignal(
|
||||
Content.CallSignal.ANSWER, offer.callId, full,
|
||||
video = offer.video))
|
||||
pc.createAnswer(
|
||||
object : SimpleSdpObserver("answer:create") {
|
||||
override fun onCreateSuccess(answer: SessionDescription) {
|
||||
pc.setLocalDescription(SimpleSdpObserver("answer:setLocal"), answer)
|
||||
// Same as the offer: the answer is complete only after
|
||||
// gathering, and one sent early names no address to reach.
|
||||
main.post {
|
||||
sendWhenGathered { full ->
|
||||
signal(
|
||||
fromUser,
|
||||
Content.CallSignal(
|
||||
Content.CallSignal.ANSWER,
|
||||
offer.callId,
|
||||
full,
|
||||
video = offer.video,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, MediaConstraints())
|
||||
},
|
||||
MediaConstraints(),
|
||||
)
|
||||
}
|
||||
|
||||
fun accept(turn: JSONObject?) {
|
||||
@@ -471,23 +478,13 @@ class CallManager(
|
||||
val sdp = offer.sdp ?: return
|
||||
state = State.CONNECTING
|
||||
armConnectGuard()
|
||||
val pc = open(turn, fromUser, offer.callId, offer.video) ?: run {
|
||||
state = State.IDLE
|
||||
return
|
||||
}
|
||||
val pc =
|
||||
open(turn, fromUser, offer.callId, offer.video) ?: run {
|
||||
state = State.IDLE
|
||||
return
|
||||
}
|
||||
current = Active(offer.callId, fromUser, incoming = true, video = offer.video)
|
||||
// 🔴 **`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
|
||||
// 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
|
||||
// device the description often lands first and the call connects, so
|
||||
// the same build both works and fails depending on load.
|
||||
// `createAnswer` waits for the remote description to be APPLIED.
|
||||
pc.setRemoteDescription(
|
||||
object : SimpleSdpObserver("answer:setRemote") {
|
||||
override fun onSetSuccess() {
|
||||
@@ -502,10 +499,12 @@ class CallManager(
|
||||
fun decline() {
|
||||
val (fromUser, offer) = pendingOffer ?: return
|
||||
pendingOffer = null
|
||||
signal(fromUser, Content.CallSignal(
|
||||
Content.CallSignal.END, offer.callId, reason = "declined"))
|
||||
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
|
||||
@@ -513,19 +512,17 @@ class CallManager(
|
||||
|
||||
/**
|
||||
* End the call and release everything.
|
||||
*
|
||||
* ⚠️ 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* ⚠️ **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.
|
||||
* Re-entrancy-safe on purpose.
|
||||
*/
|
||||
private var tearingDown = false
|
||||
|
||||
fun hangUp(notifyPeer: Boolean, reason: String? = null) {
|
||||
fun hangUp(
|
||||
notifyPeer: Boolean,
|
||||
reason: String? = null,
|
||||
) {
|
||||
if (tearingDown) return
|
||||
tearingDown = true
|
||||
try {
|
||||
@@ -535,7 +532,10 @@ class CallManager(
|
||||
}
|
||||
}
|
||||
|
||||
private fun hangUpInner(notifyPeer: Boolean, reason: String?) {
|
||||
private fun hangUpInner(
|
||||
notifyPeer: Boolean,
|
||||
reason: String?,
|
||||
) {
|
||||
onGathered = null
|
||||
gatherGuard?.let { main.removeCallbacks(it) }
|
||||
gatherGuard = null
|
||||
@@ -545,24 +545,28 @@ class CallManager(
|
||||
connectGuard = null
|
||||
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
|
||||
// us, or simply unreachable.
|
||||
// Best effort.
|
||||
runCatching {
|
||||
signal(active.peerId,
|
||||
Content.CallSignal(Content.CallSignal.END, active.callId, reason = reason))
|
||||
signal(
|
||||
active.peerId,
|
||||
Content.CallSignal(Content.CallSignal.END, active.callId, reason = reason),
|
||||
)
|
||||
}
|
||||
}
|
||||
connection?.let { runCatching { it.close() }; runCatching { it.dispose() } }
|
||||
connection?.let {
|
||||
runCatching { it.close() }
|
||||
runCatching { it.dispose() }
|
||||
}
|
||||
connection = null
|
||||
audioTrack?.let { runCatching { it.dispose() } }
|
||||
audioTrack = null
|
||||
audioSource?.let { runCatching { it.dispose() } }
|
||||
audioSource = null
|
||||
// The camera, released in capture order. Same rule as the mic: the
|
||||
// capturer holds the hardware, not the connection, and a missed
|
||||
// dispose leaves the camera indicator on with no call attached.
|
||||
videoCapturer?.let { runCatching { it.stopCapture() }; runCatching { it.dispose() } }
|
||||
// The camera, released in capture order.
|
||||
videoCapturer?.let {
|
||||
runCatching { it.stopCapture() }
|
||||
runCatching { it.dispose() }
|
||||
}
|
||||
videoCapturer = null
|
||||
videoHelper?.let { runCatching { it.dispose() } }
|
||||
videoHelper = null
|
||||
@@ -572,11 +576,10 @@ class CallManager(
|
||||
remoteVideoTrack = null
|
||||
main.post { runCatching { onVideoTracksChanged?.invoke() } }
|
||||
pendingOffer = null
|
||||
// 🔴 ENDED fires while `current` still says who the call was with —
|
||||
// the observer files the call log entry from it, and nulling `current`
|
||||
// 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.
|
||||
// IDLE follows with `current` cleared so the UI banner comes down.
|
||||
state = State.ENDED
|
||||
current = null
|
||||
state = State.IDLE
|
||||
@@ -599,9 +602,7 @@ class CallManager(
|
||||
const val CONNECT_TIMEOUT_MS = 30_000L
|
||||
|
||||
/**
|
||||
* How long to wait for ICE gathering before sending what we have. An
|
||||
* unreachable TURN server stalls COMPLETE for tens of seconds; the
|
||||
* host and STUN candidates in hand by now usually still connect.
|
||||
* How long to wait for ICE gathering before sending what we have.
|
||||
*/
|
||||
const val GATHER_TIMEOUT_MS = 8_000L
|
||||
}
|
||||
@@ -609,46 +610,43 @@ 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
|
||||
* 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
|
||||
* 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
|
||||
* process, so the handoff is this holder: the service parks the offer, the
|
||||
* ViewModel collects it on its next poll tick and rings properly.
|
||||
*/
|
||||
object PendingIncomingCall {
|
||||
data class Parked(val fromUser: Long, val signal: Content.CallSignal, val at: Long)
|
||||
data class Parked(
|
||||
val fromUser: Long,
|
||||
val signal: Content.CallSignal,
|
||||
val at: Long,
|
||||
)
|
||||
|
||||
@Volatile private var parked: Parked? = null
|
||||
|
||||
fun park(fromUser: Long, signal: Content.CallSignal, sentAt: Long) {
|
||||
parked = Parked(fromUser, signal,
|
||||
if (sentAt > 0) sentAt else System.currentTimeMillis())
|
||||
fun park(
|
||||
fromUser: Long,
|
||||
signal: Content.CallSignal,
|
||||
sentAt: Long,
|
||||
) {
|
||||
parked =
|
||||
Parked(
|
||||
fromUser,
|
||||
signal,
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 —
|
||||
* ringing for a call the user turned down an hour ago.
|
||||
*/
|
||||
fun clearAll() { parked = null }
|
||||
fun clearAll() {
|
||||
parked = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* would drop a call that was still ringing.
|
||||
*/
|
||||
fun isStale(now: Long = System.currentTimeMillis()): Boolean {
|
||||
val current = parked ?: return false
|
||||
@@ -665,23 +663,21 @@ 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.**
|
||||
* `createOffer` or `setLocalDescription` could fail for any reason and nothing
|
||||
* 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"
|
||||
* and "createAnswer failed" send you to different places.
|
||||
* This discarded every SDP failure, and that is why calls were undebuggable.
|
||||
*/
|
||||
private open class SimpleSdpObserver(private val label: String = "sdp") : SdpObserver {
|
||||
private open class SimpleSdpObserver(
|
||||
private val label: String = "sdp",
|
||||
) : SdpObserver {
|
||||
override fun onCreateSuccess(sdp: SessionDescription) = Unit
|
||||
|
||||
override fun onSetSuccess() = Unit
|
||||
|
||||
override fun onCreateFailure(error: String?) {
|
||||
android.util.Log.w("fnmsg-call", "$label: createOffer/createAnswer FAILED: $error")
|
||||
}
|
||||
|
||||
override fun onSetFailure(error: String?) {
|
||||
android.util.Log.w("fnmsg-call", "$label: setLocal/setRemoteDescription FAILED: $error")
|
||||
}
|
||||
|
||||
@@ -32,21 +32,6 @@ 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
|
||||
* 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
|
||||
* 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 —
|
||||
* an animation that never resolves is worse than none.
|
||||
*/
|
||||
@Composable
|
||||
fun CallScreenBody(
|
||||
@@ -56,26 +41,32 @@ fun CallScreenBody(
|
||||
/** Shown under the status when this relay has no TURN server. */
|
||||
warning: String? = null,
|
||||
) {
|
||||
val initials = peerName.trim().split(" ").filter { it.isNotBlank() }
|
||||
.take(2).mapNotNull { it.firstOrNull() }.joinToString("").uppercase().ifEmpty { "?" }
|
||||
val initials =
|
||||
peerName
|
||||
.trim()
|
||||
.split(" ")
|
||||
.filter { it.isNotBlank() }
|
||||
.take(2)
|
||||
.mapNotNull { it.firstOrNull() }
|
||||
.joinToString("")
|
||||
.uppercase()
|
||||
.ifEmpty { "?" }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// ⚠️ 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)
|
||||
.padding(horizontal = Kit.margin)
|
||||
.testTag("call-screen"),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
// 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)
|
||||
.padding(horizontal = Kit.margin)
|
||||
.testTag("call-screen"),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(Modifier.height(Space.xxl))
|
||||
|
||||
// ⚠️ 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
|
||||
// on screen. The avatar carries the centre on its own.
|
||||
// No rings.
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
modifier = Modifier.size(128.dp).clip(CircleShape).background(Palette.AvatarFill),
|
||||
@@ -113,9 +104,7 @@ fun CallScreenBody(
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
// The kit puts the secondaries on an arc around a 72dp primary. A row
|
||||
// above it reproduces the reading order without pretending to
|
||||
// trigonometry the kit did by hand.
|
||||
// The kit puts the secondaries on an arc around a 72dp primary.
|
||||
val primary = actions.firstOrNull { it.isPrimary }
|
||||
val others = actions.filterNot { it.isPrimary }
|
||||
if (others.isNotEmpty()) {
|
||||
@@ -142,16 +131,20 @@ data class CallAction(
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun CallButton(action: CallAction, diameter: androidx.compose.ui.unit.Dp) {
|
||||
private fun CallButton(
|
||||
action: CallAction,
|
||||
diameter: androidx.compose.ui.unit.Dp,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(diameter)
|
||||
.clip(CircleShape)
|
||||
.background(action.tint ?: MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(onClick = action.run)
|
||||
.semantics { contentDescription = action.label }
|
||||
.testTag("call-${action.id}"),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(diameter)
|
||||
.clip(CircleShape)
|
||||
.background(action.tint ?: MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(onClick = action.run)
|
||||
.semantics { contentDescription = action.label }
|
||||
.testTag("call-${action.id}"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
@@ -168,55 +161,101 @@ 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
|
||||
* question asked BEFORE a call exists, and a full screen for it would look
|
||||
* like a call that is already happening.
|
||||
*/
|
||||
@Composable
|
||||
fun LiveCallScreen(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedIn): Boolean {
|
||||
fun LiveCallScreen(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
): Boolean {
|
||||
val call = phase.call ?: return false
|
||||
if (call.state == CallManager.State.IDLE) return false
|
||||
|
||||
val status = when (call.state) {
|
||||
CallManager.State.RINGING ->
|
||||
if (call.incoming) "Incoming call" else "Ringing…"
|
||||
CallManager.State.DIALLING -> "Calling…"
|
||||
CallManager.State.CONNECTING -> "Connecting…"
|
||||
CallManager.State.ACTIVE -> "On a call"
|
||||
else -> "Call ended"
|
||||
}
|
||||
val status =
|
||||
when (call.state) {
|
||||
CallManager.State.RINGING -> {
|
||||
if (call.incoming) "Incoming call" else "Ringing…"
|
||||
}
|
||||
|
||||
val actions = when {
|
||||
call.state == CallManager.State.RINGING && call.incoming -> listOf(
|
||||
CallAction("decline", KitIcon.CallEnd, "Decline",
|
||||
tint = MaterialTheme.colorScheme.error, iconColor = Color.White,
|
||||
run = model::declineCall),
|
||||
CallAction("accept", KitIcon.Call, "Answer", isPrimary = true,
|
||||
tint = Palette.Teal, iconColor = Color.White,
|
||||
run = model::acceptCall),
|
||||
)
|
||||
call.state == CallManager.State.ACTIVE -> listOf(
|
||||
CallAction("mute", if (call.muted) KitIcon.MicOff else KitIcon.Mic,
|
||||
if (call.muted) "Unmute" else "Mute",
|
||||
tint = if (call.muted) MaterialTheme.colorScheme.primary else null,
|
||||
iconColor = if (call.muted) Color.White else null,
|
||||
run = { model.setCallMuted(!call.muted) }),
|
||||
CallAction("end", KitIcon.CallEnd, "End", isPrimary = true,
|
||||
tint = MaterialTheme.colorScheme.error, iconColor = Color.White,
|
||||
run = model::hangUp),
|
||||
)
|
||||
else -> listOf(
|
||||
CallAction("end", KitIcon.CallEnd, "Cancel", isPrimary = true,
|
||||
tint = MaterialTheme.colorScheme.error, iconColor = Color.White,
|
||||
run = model::hangUp),
|
||||
)
|
||||
}
|
||||
CallManager.State.DIALLING -> {
|
||||
"Calling…"
|
||||
}
|
||||
|
||||
// A video call with anything to show renders surfaces instead of the
|
||||
// ring; the same action row overlays the bottom. Voice calls and video
|
||||
// calls still connecting keep the kit's ring screen.
|
||||
CallManager.State.CONNECTING -> {
|
||||
"Connecting…"
|
||||
}
|
||||
|
||||
CallManager.State.ACTIVE -> {
|
||||
"On a call"
|
||||
}
|
||||
|
||||
else -> {
|
||||
"Call ended"
|
||||
}
|
||||
}
|
||||
|
||||
val actions =
|
||||
when {
|
||||
call.state == CallManager.State.RINGING && call.incoming -> {
|
||||
listOf(
|
||||
CallAction(
|
||||
"decline",
|
||||
KitIcon.CallEnd,
|
||||
"Decline",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
iconColor = Color.White,
|
||||
run = model::declineCall,
|
||||
),
|
||||
CallAction(
|
||||
"accept",
|
||||
KitIcon.Call,
|
||||
"Answer",
|
||||
isPrimary = true,
|
||||
tint = Palette.Teal,
|
||||
iconColor = Color.White,
|
||||
run = model::acceptCall,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
call.state == CallManager.State.ACTIVE -> {
|
||||
listOf(
|
||||
CallAction(
|
||||
"mute",
|
||||
if (call.muted) KitIcon.MicOff else KitIcon.Mic,
|
||||
if (call.muted) "Unmute" else "Mute",
|
||||
tint = if (call.muted) MaterialTheme.colorScheme.primary else null,
|
||||
iconColor = if (call.muted) Color.White else null,
|
||||
run = { model.setCallMuted(!call.muted) },
|
||||
),
|
||||
CallAction(
|
||||
"end",
|
||||
KitIcon.CallEnd,
|
||||
"End",
|
||||
isPrimary = true,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
iconColor = Color.White,
|
||||
run = model::hangUp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
listOf(
|
||||
CallAction(
|
||||
"end",
|
||||
KitIcon.CallEnd,
|
||||
"Cancel",
|
||||
isPrimary = true,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
iconColor = Color.White,
|
||||
run = model::hangUp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// A video call with anything to show renders surfaces instead of the ring;
|
||||
// the same action row overlays the bottom.
|
||||
val manager = model.activeCallManager()
|
||||
val showVideo = call.video && call.state == CallManager.State.ACTIVE && manager != null
|
||||
if (showVideo) {
|
||||
@@ -231,21 +270,21 @@ fun LiveCallScreen(model: MessengerViewModel, phase: MessengerViewModel.Phase.Si
|
||||
peerName = call.peerName,
|
||||
status = status,
|
||||
actions = actions,
|
||||
warning = if (call.relayless && call.state != CallManager.State.ACTIVE) {
|
||||
"This server has no call relay, so the call only connects if a " +
|
||||
"direct path exists between the two networks."
|
||||
} else null,
|
||||
warning =
|
||||
if (call.relayless && call.state != CallManager.State.ACTIVE) {
|
||||
"This server has no call relay, so the call only connects if a " +
|
||||
"direct path exists between the two networks."
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* ⚠️ `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.
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
private fun VideoCallBody(
|
||||
@@ -259,8 +298,12 @@ private fun VideoCallBody(
|
||||
val local = manager.localVideoTrack
|
||||
if (remote != null) {
|
||||
key(remote, videoTick) {
|
||||
VideoSurface(manager, remote, mirror = false,
|
||||
modifier = Modifier.fillMaxSize())
|
||||
VideoSurface(
|
||||
manager,
|
||||
remote,
|
||||
mirror = false,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
@@ -272,12 +315,18 @@ private fun VideoCallBody(
|
||||
if (local != null) {
|
||||
key(local, videoTick) {
|
||||
VideoSurface(
|
||||
manager, local, mirror = true,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(Kit.margin)
|
||||
.size(width = 108.dp, height = 144.dp)
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)),
|
||||
manager,
|
||||
local,
|
||||
mirror = true,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(Kit.margin)
|
||||
.size(width = 108.dp, height = 144.dp)
|
||||
.clip(
|
||||
androidx.compose.foundation.shape
|
||||
.RoundedCornerShape(12.dp),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -285,8 +334,12 @@ private fun VideoCallBody(
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = Space.xl),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(peerName, color = Color.White, fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
peerName,
|
||||
color = Color.White,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.lg)) {
|
||||
actions.forEach { CallButton(it, diameter = if (it.isPrimary) 72.dp else 48.dp) }
|
||||
@@ -296,9 +349,7 @@ private fun VideoCallBody(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* One WebRTC renderer.
|
||||
*/
|
||||
@Composable
|
||||
private fun VideoSurface(
|
||||
@@ -324,34 +375,34 @@ 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
|
||||
* asking at the moment of the tap is the only time the question makes sense
|
||||
* to the person being asked.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberVideoCall(model: MessengerViewModel): (Long) -> Unit {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val pending = androidx.compose.runtime.remember {
|
||||
androidx.compose.runtime.mutableStateOf<Long?>(null)
|
||||
}
|
||||
val ask = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
val peer = pending.value
|
||||
pending.value = null
|
||||
// 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)
|
||||
}
|
||||
val pending =
|
||||
androidx.compose.runtime.remember {
|
||||
androidx.compose.runtime.mutableStateOf<Long?>(null)
|
||||
}
|
||||
val ask =
|
||||
androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts
|
||||
.RequestPermission(),
|
||||
) { granted ->
|
||||
val peer = pending.value
|
||||
pending.value = null
|
||||
// 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)
|
||||
}
|
||||
return { peer ->
|
||||
val has = androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context, android.Manifest.permission.CAMERA
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
val has =
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
android.Manifest.permission.CAMERA,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (has) {
|
||||
model.requestCall(peer, video = true)
|
||||
} else {
|
||||
|
||||
@@ -21,28 +21,14 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* 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
|
||||
* puts a video icon at x=255 and a call icon at x=303; only the call icon is
|
||||
* 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
|
||||
* 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
|
||||
* the words while leaving the call log in the clear would be an odd place to
|
||||
* stop.
|
||||
* Calls placed and received, the kit's `08 Calls-Listing`.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.CallsScreen(
|
||||
@@ -50,9 +36,10 @@ fun ColumnScope.CallsScreen(
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
) {
|
||||
val videoCall = rememberVideoCall(model)
|
||||
val showNewCall = androidx.compose.runtime.remember {
|
||||
androidx.compose.runtime.mutableStateOf(false)
|
||||
}
|
||||
val showNewCall =
|
||||
androidx.compose.runtime.remember {
|
||||
androidx.compose.runtime.mutableStateOf(false)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -64,16 +51,16 @@ fun ColumnScope.CallsScreen(
|
||||
fontWeight = FontWeight.Bold,
|
||||
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.
|
||||
// The way IN.
|
||||
Icon(
|
||||
KitIcon.Call,
|
||||
contentDescription = "New call",
|
||||
tint = Palette.Accent,
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clickable { showNewCall.value = true }
|
||||
.testTag("new-call"),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(28.dp)
|
||||
.clickable { showNewCall.value = true }
|
||||
.testTag("new-call"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,19 +70,19 @@ 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 ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
showNewCall.value = false
|
||||
model.requestCall(buddy.userId)
|
||||
}
|
||||
.padding(vertical = 10.dp)
|
||||
.testTag("new-call-${buddy.userId}"),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
showNewCall.value = false
|
||||
model.requestCall(buddy.userId)
|
||||
}.padding(vertical = 10.dp)
|
||||
.testTag("new-call-${buddy.userId}"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
@@ -104,20 +91,24 @@ fun ColumnScope.CallsScreen(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Icon(KitIcon.Call, contentDescription = null,
|
||||
tint = Palette.Accent, modifier = Modifier.size(20.dp))
|
||||
Icon(
|
||||
KitIcon.Call,
|
||||
contentDescription = null,
|
||||
tint = Palette.Accent,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(Modifier.width(Space.md))
|
||||
Icon(
|
||||
KitIcon.Video,
|
||||
contentDescription = "Video call",
|
||||
tint = Palette.Accent,
|
||||
modifier = Modifier
|
||||
.size(22.dp)
|
||||
.clickable {
|
||||
showNewCall.value = false
|
||||
videoCall(buddy.userId)
|
||||
}
|
||||
.testTag("new-video-call-${buddy.userId}"),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(22.dp)
|
||||
.clickable {
|
||||
showNewCall.value = false
|
||||
videoCall(buddy.userId)
|
||||
}.testTag("new-video-call-${buddy.userId}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -133,9 +124,7 @@ fun ColumnScope.CallsScreen(
|
||||
}
|
||||
|
||||
if (phase.calls.isEmpty()) {
|
||||
// ⚠️ 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.
|
||||
// Says what would appear here.
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = Space.xxl),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -149,10 +138,10 @@ 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, "
|
||||
+ "so this list is the only record of them and it is on "
|
||||
+ "this device.",
|
||||
"Calls you place and receive appear here. Start one from a " +
|
||||
"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,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -162,15 +151,18 @@ fun ColumnScope.CallsScreen(
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().testTag("calls")) {
|
||||
phase.calls.forEach { entry ->
|
||||
val name = phase.buddies.firstOrNull { it.userId == entry.peerId }
|
||||
?.let { it.displayName.ifBlank { it.handle } }
|
||||
?: "user ${entry.peerId}"
|
||||
val name =
|
||||
phase.buddies
|
||||
.firstOrNull { it.userId == entry.peerId }
|
||||
?.let { it.displayName.ifBlank { it.handle } }
|
||||
?: "user ${entry.peerId}"
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { model.requestCall(entry.peerId) }
|
||||
.padding(vertical = Kit.rowGap / 2)
|
||||
.testTag("call-${entry.peerId}-${entry.at}"),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { model.requestCall(entry.peerId) }
|
||||
.padding(vertical = Kit.rowGap / 2)
|
||||
.testTag("call-${entry.peerId}-${entry.at}"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
|
||||
) {
|
||||
@@ -189,9 +181,9 @@ fun ColumnScope.CallsScreen(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// 🔴 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
|
||||
// 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.
|
||||
Icon(
|
||||
if (entry.connected) KitIcon.Call else KitIcon.CallEnd,
|
||||
@@ -213,14 +205,15 @@ 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)
|
||||
.clip(CircleShape)
|
||||
.background(Palette.AccentSoft)
|
||||
.clickable { videoCall(entry.peerId) }
|
||||
.testTag("video-back-${entry.peerId}"),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Kit.icon + Space.md)
|
||||
.clip(CircleShape)
|
||||
.background(Palette.AccentSoft)
|
||||
.clickable { videoCall(entry.peerId) }
|
||||
.testTag("video-back-${entry.peerId}"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
@@ -232,12 +225,13 @@ fun ColumnScope.CallsScreen(
|
||||
}
|
||||
Spacer(Modifier.width(Space.sm))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(Kit.icon + Space.md)
|
||||
.clip(CircleShape)
|
||||
.background(Palette.AccentSoft)
|
||||
.clickable { model.requestCall(entry.peerId) }
|
||||
.testTag("call-back-${entry.peerId}"),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Kit.icon + Space.md)
|
||||
.clip(CircleShape)
|
||||
.background(Palette.AccentSoft)
|
||||
.clickable { model.requestCall(entry.peerId) }
|
||||
.testTag("call-back-${entry.peerId}"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,16 +38,6 @@ 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
|
||||
* 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
|
||||
* "personal page" icon; there is no such page here, and a fourth control that
|
||||
* opens nothing is worse than a rail of three.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.ContactDetail(
|
||||
@@ -59,22 +49,31 @@ fun ColumnScope.ContactDetail(
|
||||
var confirmingRemove by remember { mutableStateOf(false) }
|
||||
val isBlocked = buddy.userId in phase.blocked
|
||||
val name = buddy.displayName.ifBlank { buddy.handle }.ifBlank { "user ${buddy.userId}" }
|
||||
val initials = remember(name) {
|
||||
name.trim().split(" ").filter { it.isNotBlank() }
|
||||
.take(2).mapNotNull { it.firstOrNull() }.joinToString("").uppercase().ifEmpty { "?" }
|
||||
}
|
||||
val initials =
|
||||
remember(name) {
|
||||
name
|
||||
.trim()
|
||||
.split(" ")
|
||||
.filter { it.isNotBlank() }
|
||||
.take(2)
|
||||
.mapNotNull { it.firstOrNull() }
|
||||
.joinToString("")
|
||||
.uppercase()
|
||||
.ifEmpty { "?" }
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(Kit.icon + Space.md)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onClose)
|
||||
.semantics { contentDescription = "Back" }
|
||||
.testTag("contact-back"),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Kit.icon + Space.md)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onClose)
|
||||
.semantics { contentDescription = "Back" }
|
||||
.testTag("contact-back"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
@@ -89,20 +88,17 @@ 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
|
||||
// least theirs, rather than a stock face standing in for them.
|
||||
// The cover.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Their picture if they have sent us one; their initials if
|
||||
// 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.
|
||||
// not.
|
||||
val picture = phase.avatars[buddy.userId]
|
||||
if (picture != null) {
|
||||
AttachedImage(picture, model)
|
||||
@@ -118,18 +114,19 @@ fun ColumnScope.ContactDetail(
|
||||
|
||||
// Name plate: 60 tall, radius 16, on the kit's neutral fill.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(60.dp)
|
||||
.clip(RoundedCornerShape(Radius.medium))
|
||||
.background(Palette.SurfaceAlt)
|
||||
.padding(horizontal = Kit.margin),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(60.dp)
|
||||
.clip(RoundedCornerShape(Radius.medium))
|
||||
.background(Palette.SurfaceAlt)
|
||||
.padding(horizontal = Kit.margin),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(name, fontSize = 20.sp, fontWeight = FontWeight.Bold, maxLines = 1)
|
||||
// ⚠️ Presence goes on THIS line, not the trailing edge —
|
||||
// the rail floats over the plate's right-hand third, so
|
||||
// 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) {
|
||||
if (buddy.presence.equals("online", true)) {
|
||||
@@ -138,7 +135,8 @@ fun ColumnScope.ContactDetail(
|
||||
}
|
||||
Text(
|
||||
listOfNotNull(
|
||||
buddy.handle.takeIf { it.isNotBlank() && !it.equals(name, true) }
|
||||
buddy.handle
|
||||
.takeIf { it.isNotBlank() && !it.equals(name, true) }
|
||||
?.let { "@$it" },
|
||||
buddy.presence.takeIf { it.isNotBlank() },
|
||||
).joinToString(" · "),
|
||||
@@ -153,20 +151,22 @@ 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
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 120.dp, end = Kit.margin)
|
||||
.width(60.dp)
|
||||
.clip(RoundedCornerShape(30.dp))
|
||||
.background(
|
||||
if (isBlocked) Palette.SecondaryText else MaterialTheme.colorScheme.primary
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 120.dp, end = Kit.margin)
|
||||
.width(60.dp)
|
||||
.clip(RoundedCornerShape(30.dp))
|
||||
.background(
|
||||
if (isBlocked) Palette.SecondaryText else MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
) {
|
||||
RailButton(KitIcon.Chats, "Message", "contact-message", enabled = !isBlocked) {
|
||||
model.openChat(buddy.userId, buddy.displayName); onClose()
|
||||
model.openChat(buddy.userId, buddy.displayName)
|
||||
onClose()
|
||||
}
|
||||
RailDivider()
|
||||
RailButton(KitIcon.Call, "Call", "contact-call", enabled = !isBlocked) {
|
||||
@@ -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" })
|
||||
}
|
||||
@@ -196,19 +196,26 @@ fun ColumnScope.ContactDetail(
|
||||
"Clear this conversation",
|
||||
"Removes it from this phone only. Their copy is theirs.",
|
||||
tag = "contact-clear",
|
||||
) { model.clearHistory(io.flamenet.e2e.Vault.threadForUser(buddy.userId)) }
|
||||
) {
|
||||
model.clearHistory(
|
||||
io.flamenet.e2e.Vault
|
||||
.threadForUser(buddy.userId),
|
||||
)
|
||||
}
|
||||
|
||||
// ⚠️ 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.
|
||||
// Confirms in place rather than in a dialog.
|
||||
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,
|
||||
) {
|
||||
if (confirmingRemove) { model.removeBuddy(buddy.userId); onClose() }
|
||||
else confirmingRemove = true
|
||||
if (confirmingRemove) {
|
||||
model.removeBuddy(buddy.userId)
|
||||
onClose()
|
||||
} else {
|
||||
confirmingRemove = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,11 +234,12 @@ private fun RailButton(
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(60.dp)
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.semantics { contentDescription = label }
|
||||
.testTag(tag),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(60.dp)
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.semantics { contentDescription = label }
|
||||
.testTag(tag),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
@@ -252,11 +260,12 @@ private fun DetailRow(
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.then(if (tag != null) Modifier.testTag(tag) else Modifier)
|
||||
.padding(vertical = Kit.bubblePadding),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.then(if (tag != null) Modifier.testTag(tag) else Modifier)
|
||||
.padding(vertical = Kit.bubblePadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Space.md),
|
||||
) {
|
||||
|
||||
@@ -22,12 +22,6 @@ import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* How this phone draws things.
|
||||
*
|
||||
* 🔴 **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
|
||||
* renamed on 2026-09-06 and this section is named that way from the start.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.DisplaySection() {
|
||||
@@ -41,25 +35,36 @@ fun ColumnScope.DisplaySection() {
|
||||
Text("Display", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
@Composable
|
||||
fun row(label: String, tag: String, value: Boolean, onChange: (Boolean) -> Unit) {
|
||||
fun row(
|
||||
label: String,
|
||||
tag: String,
|
||||
value: Boolean,
|
||||
onChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(label, fontSize = 16.sp, modifier = Modifier.weight(1f))
|
||||
Switch(checked = value, onCheckedChange = onChange,
|
||||
modifier = Modifier.testTag(tag))
|
||||
Switch(
|
||||
checked = value,
|
||||
onCheckedChange = onChange,
|
||||
modifier = Modifier.testTag(tag),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
row("Show whether contacts are online", "display-online", online) { on ->
|
||||
settings.showOnlineStatus = on; online = on
|
||||
settings.showOnlineStatus = on
|
||||
online = on
|
||||
}
|
||||
row("Show the time on messages", "display-timestamps", times) { on ->
|
||||
settings.showTimestamps = on; times = on
|
||||
settings.showTimestamps = on
|
||||
times = on
|
||||
}
|
||||
row("Larger message text", "display-larger-type", larger) { on ->
|
||||
settings.largerType = on; larger = on
|
||||
settings.largerType = on
|
||||
larger = on
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
@@ -68,7 +73,10 @@ fun ColumnScope.DisplaySection() {
|
||||
DisplaySettings.Appearance.entries.forEach { option ->
|
||||
KitSecondaryButton(
|
||||
text = if (option == appearance) "✓ ${option.label}" else option.label,
|
||||
onClick = { settings.appearance = option; appearance = option },
|
||||
onClick = {
|
||||
settings.appearance = option
|
||||
appearance = option
|
||||
},
|
||||
modifier = Modifier.weight(1f).padding(end = Space.xs),
|
||||
tag = "display-appearance-${option.stored}",
|
||||
)
|
||||
@@ -76,13 +84,13 @@ fun ColumnScope.DisplaySection() {
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
// 🔴 Says the thing the label alone cannot: this hides them from you, not
|
||||
// you from them.
|
||||
// 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 "
|
||||
+ "something the app can do yet.\n\nAppearance takes effect next time the "
|
||||
+ "app starts.",
|
||||
"These change what this phone shows you. Your contacts are still told when " +
|
||||
"you are online whichever way this is set, hiding yourself is not " +
|
||||
"something the app can do yet.\n\nAppearance takes effect next time the " +
|
||||
"app starts.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
|
||||
@@ -3,23 +3,11 @@ package io.flamenet.messenger.app
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* How this phone draws things. Preferences, not privacy.
|
||||
*
|
||||
* 🔴 **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
|
||||
* 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
|
||||
* 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.
|
||||
* How this phone draws things.
|
||||
*/
|
||||
class DisplaySettings(context: Context) {
|
||||
|
||||
class DisplaySettings(
|
||||
context: Context,
|
||||
) {
|
||||
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/** Draw the presence dot on avatars. Hides theirs from you, not you from them. */
|
||||
@@ -39,39 +27,33 @@ 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
|
||||
* what somebody who never touched this setting has.
|
||||
*/
|
||||
var appearance: Appearance
|
||||
get() = Appearance.from(prefs.getString(APPEARANCE, null))
|
||||
set(value) = prefs.edit().putString(APPEARANCE, value.stored).apply()
|
||||
|
||||
enum class Appearance(val stored: String, val label: String) {
|
||||
enum class Appearance(
|
||||
val stored: String,
|
||||
val label: String,
|
||||
) {
|
||||
SYSTEM("system", "Match the phone"),
|
||||
LIGHT("light", "Light"),
|
||||
DARK("dark", "Dark");
|
||||
DARK("dark", "Dark"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun from(stored: String?) =
|
||||
entries.firstOrNull { it.stored == stored } ?: SYSTEM
|
||||
fun from(stored: String?) = entries.firstOrNull { it.stored == stored } ?: SYSTEM
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The message font size, in sp.
|
||||
*
|
||||
* 🔴 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
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
fun messageTextSize(largerType: Boolean): Int = if (largerType) 20 else 16
|
||||
|
||||
|
||||
@@ -41,24 +41,6 @@ import java.util.UUID
|
||||
|
||||
/**
|
||||
* Files, on this phone.
|
||||
*
|
||||
* 🔴 **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
|
||||
* ciphertext until the other device collects it, and then not.
|
||||
*
|
||||
* ⚠️ **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
|
||||
* 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.
|
||||
* 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
|
||||
* open somebody's files.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.FilesScreen(
|
||||
@@ -67,8 +49,12 @@ fun ColumnScope.FilesScreen(
|
||||
) {
|
||||
val files = phase.files ?: MessengerViewModel.FilesState()
|
||||
|
||||
Text("Files", fontWeight = FontWeight.SemiBold, fontSize = 22.sp,
|
||||
modifier = Modifier.padding(bottom = Space.xs))
|
||||
Text(
|
||||
"Files",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 22.sp,
|
||||
modifier = Modifier.padding(bottom = Space.xs),
|
||||
)
|
||||
|
||||
when {
|
||||
!files.hasIdentity -> FilesSetUp(model, files)
|
||||
@@ -88,27 +74,27 @@ private fun ColumnScope.FilesMessages(
|
||||
val message = files.problem ?: files.notice ?: return
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(if (files.problem != null) Palette.AccentSoft else Palette.SurfaceAlt)
|
||||
.clickable { model.filesClearMessages() }
|
||||
.padding(Space.md)
|
||||
.testTag("files-message"),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(if (files.problem != null) Palette.AccentSoft else Palette.SurfaceAlt)
|
||||
.clickable { model.filesClearMessages() }
|
||||
.padding(Space.md)
|
||||
.testTag("files-message"),
|
||||
) {
|
||||
Text(message, fontSize = 13.sp,
|
||||
color = if (files.problem != null) Palette.Danger else Palette.SecondaryText)
|
||||
Text(
|
||||
message,
|
||||
fontSize = 13.sp,
|
||||
color = if (files.problem != null) Palette.Danger else Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Setting up ----------------------------------------------------------
|
||||
// ---- 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
|
||||
* 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.
|
||||
* The one-way door is named before it is walked through, not after.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.FilesSetUp(
|
||||
@@ -118,34 +104,28 @@ 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,
|
||||
// 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
|
||||
// described above this function.
|
||||
//
|
||||
// ⚠️ **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.
|
||||
// "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.
|
||||
Text(
|
||||
"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,
|
||||
"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, "
|
||||
+ "because there's nothing anywhere that could do one.",
|
||||
fontSize = 13.sp, color = Palette.Warning,
|
||||
"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,
|
||||
)
|
||||
Spacer(Modifier.height(Space.md))
|
||||
|
||||
OutlinedTextField(
|
||||
value = passphrase, onValueChange = { passphrase = it },
|
||||
value = passphrase,
|
||||
onValueChange = { passphrase = it },
|
||||
label = { Text("Your Flamenet passphrase") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
@@ -153,13 +133,12 @@ private fun ColumnScope.FilesSetUp(
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
OutlinedTextField(
|
||||
value = again, onValueChange = { again = it },
|
||||
value = again,
|
||||
onValueChange = { again = it },
|
||||
label = { Text("Type it again") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
// ⚠️ 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.
|
||||
// Confirmed rather than trusted.
|
||||
isError = again.isNotEmpty() && again != passphrase,
|
||||
modifier = Modifier.fillMaxWidth().testTag("files-new-passphrase-again"),
|
||||
)
|
||||
@@ -180,14 +159,16 @@ private fun ColumnScope.FilesUnlock(
|
||||
) {
|
||||
var passphrase by remember { mutableStateOf("") }
|
||||
|
||||
// ⚠️ 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.",
|
||||
fontSize = 14.sp, color = Palette.SecondaryText)
|
||||
// Not "your Files passphrase".
|
||||
Text(
|
||||
"Enter your Flamenet passphrase to open them.",
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.md))
|
||||
OutlinedTextField(
|
||||
value = passphrase, onValueChange = { passphrase = it },
|
||||
value = passphrase,
|
||||
onValueChange = { passphrase = it },
|
||||
label = { Text("Passphrase") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
@@ -203,7 +184,7 @@ private fun ColumnScope.FilesUnlock(
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Browsing ------------------------------------------------------------
|
||||
// ---- Browsing ------------------------------------------------------------.
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.FilesBrowser(
|
||||
@@ -215,31 +196,29 @@ private fun ColumnScope.FilesBrowser(
|
||||
var namingFolder by remember { mutableStateOf(false) }
|
||||
|
||||
/**
|
||||
* 🔴 `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.
|
||||
* `OpenDocument`, never `GetContent`.
|
||||
*/
|
||||
val pick = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
val saf = SafReferences(context)
|
||||
pending = PendingFile(uri, saf.displayName(uri) ?: "Untitled")
|
||||
}
|
||||
val pick =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
val saf = SafReferences(context)
|
||||
pending = PendingFile(uri, saf.displayName(uri) ?: "Untitled")
|
||||
}
|
||||
|
||||
FilesSectionSwitch(model, files)
|
||||
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
|
||||
// make anything filed inside one unreachable from there.
|
||||
// Folders show in BOTH sections.
|
||||
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 — " +
|
||||
"nothing is copied, and nothing is encrypted until you send it."
|
||||
}
|
||||
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, " +
|
||||
"nothing is copied, and nothing is encrypted until you send it."
|
||||
}
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Text(empty, fontSize = 13.sp, color = Palette.SecondaryText)
|
||||
}
|
||||
@@ -253,12 +232,14 @@ private fun ColumnScope.FilesBrowser(
|
||||
text = "Add a file",
|
||||
// Everything: the vault does not care what a file is.
|
||||
onClick = { pick.launch(arrayOf("*/*")) },
|
||||
modifier = Modifier.weight(1f), tag = "files-add",
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "files-add",
|
||||
)
|
||||
KitSecondaryButton(
|
||||
text = "New folder",
|
||||
onClick = { namingFolder = true },
|
||||
modifier = Modifier.weight(1f), tag = "files-new-folder",
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "files-new-folder",
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
@@ -266,20 +247,23 @@ private fun ColumnScope.FilesBrowser(
|
||||
KitSecondaryButton(
|
||||
text = if (files.selecting) "Done" else "Select",
|
||||
onClick = { model.filesToggleSelecting() },
|
||||
modifier = Modifier.weight(1f), tag = "files-select",
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "files-select",
|
||||
)
|
||||
if (files.selecting) {
|
||||
KitSecondaryButton(
|
||||
text = "Delete ${files.selected.size}",
|
||||
onClick = { model.filesDeleteSelected() },
|
||||
enabled = files.selected.isNotEmpty(),
|
||||
modifier = Modifier.weight(1f), tag = "files-delete",
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "files-delete",
|
||||
)
|
||||
}
|
||||
KitSecondaryButton(
|
||||
text = "Lock",
|
||||
onClick = { model.lockFiles() },
|
||||
modifier = Modifier.weight(1f), tag = "files-lock",
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "files-lock",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -296,19 +280,25 @@ private fun ColumnScope.FilesBrowser(
|
||||
AddFileSheet(
|
||||
file = file,
|
||||
onCancel = { pending = null },
|
||||
onVault = { name, level -> pending = null; model.filesAdd(file.uri, name, level) },
|
||||
onLink = { name, level -> pending = null; model.filesReference(file.uri, name, level) },
|
||||
onVault = { name, level ->
|
||||
pending = null
|
||||
model.filesAdd(file.uri, name, level)
|
||||
},
|
||||
onLink = { name, level ->
|
||||
pending = null
|
||||
model.filesReference(file.uri, name, level)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class PendingFile(val uri: android.net.Uri, val name: String)
|
||||
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
|
||||
* would be a list where "encrypted" is invisible.
|
||||
* Two sections, not one mixed list.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.FilesSectionSwitch(
|
||||
@@ -341,14 +331,23 @@ private fun ColumnScope.FilesBreadcrumb(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("Files", fontSize = 13.sp, color = Palette.Accent,
|
||||
modifier = Modifier.clickable { model.filesGoTo(0) }.testTag("files-crumb-root"))
|
||||
Text(
|
||||
"Files",
|
||||
fontSize = 13.sp,
|
||||
color = Palette.Accent,
|
||||
modifier = Modifier.clickable { model.filesGoTo(0) }.testTag("files-crumb-root"),
|
||||
)
|
||||
files.breadcrumb.forEachIndexed { index, crumb ->
|
||||
Text(" › ", fontSize = 13.sp, color = Palette.SecondaryText)
|
||||
Text(
|
||||
crumb.name, fontSize = 13.sp,
|
||||
color = if (index == files.breadcrumb.lastIndex) Palette.SecondaryText
|
||||
else Palette.Accent,
|
||||
crumb.name,
|
||||
fontSize = 13.sp,
|
||||
color =
|
||||
if (index == files.breadcrumb.lastIndex) {
|
||||
Palette.SecondaryText
|
||||
} else {
|
||||
Palette.Accent
|
||||
},
|
||||
modifier = Modifier.clickable { model.filesGoTo(index + 1) },
|
||||
)
|
||||
}
|
||||
@@ -362,13 +361,13 @@ private fun ColumnScope.FileRow(
|
||||
entry: LocalVault.Entry,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = entry.isFolder && !files.selecting) {
|
||||
model.filesOpenFolder(entry)
|
||||
}
|
||||
.padding(vertical = Space.sm)
|
||||
.testTag("file-row"),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = entry.isFolder && !files.selecting) {
|
||||
model.filesOpenFolder(entry)
|
||||
}.padding(vertical = Space.sm)
|
||||
.testTag("file-row"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (files.selecting) {
|
||||
@@ -380,33 +379,37 @@ private fun ColumnScope.FileRow(
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
// 🔴 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;
|
||||
// it has to be readable on a row you cannot open.
|
||||
// A row this phone cannot read is still SHOWN, with its
|
||||
// classification.
|
||||
text = entry.name ?: "Encrypted for someone else",
|
||||
fontSize = 15.sp,
|
||||
fontWeight = if (entry.isFolder) FontWeight.Medium else FontWeight.Normal,
|
||||
color = if (entry.name == null) Palette.SecondaryText else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
FileLevels.levelName(entry.label.classification),
|
||||
fontSize = 11.sp, color = Palette.SecondaryText,
|
||||
fontSize = 11.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
if (entry.isMissing) {
|
||||
Text(" · file has moved", fontSize = 11.sp, color = Palette.Warning)
|
||||
} else if (!entry.isFolder && entry.byteCount > 0) {
|
||||
Text(" · ${bytes(entry.byteCount)}",
|
||||
fontSize = 11.sp, color = Palette.SecondaryText)
|
||||
Text(
|
||||
" · ${bytes(entry.byteCount)}",
|
||||
fontSize = 11.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Promoting a link is the one per-row action, and only where it means
|
||||
// something.
|
||||
if (!files.selecting && entry.storage == LocalVault.Storage.REFERENCE
|
||||
&& !entry.isFolder && !entry.isMissing) {
|
||||
if (!files.selecting && entry.storage == LocalVault.Storage.REFERENCE &&
|
||||
!entry.isFolder && !entry.isMissing
|
||||
) {
|
||||
KitSecondaryButton(
|
||||
text = "Encrypt",
|
||||
onClick = { model.filesEncrypt(entry.id) },
|
||||
@@ -416,19 +419,18 @@ private fun ColumnScope.FileRow(
|
||||
}
|
||||
}
|
||||
|
||||
private fun bytes(count: Int): String = when {
|
||||
count >= 1_000_000 -> "%.1f MB".format(count / 1_000_000.0)
|
||||
count >= 1_000 -> "%.0f kB".format(count / 1_000.0)
|
||||
else -> "$count bytes"
|
||||
}
|
||||
private fun bytes(count: Int): String =
|
||||
when {
|
||||
count >= 1_000_000 -> "%.1f MB".format(count / 1_000_000.0)
|
||||
count >= 1_000 -> "%.0f kB".format(count / 1_000.0)
|
||||
else -> "$count bytes"
|
||||
}
|
||||
|
||||
// ---- Sheets --------------------------------------------------------------
|
||||
// ---- Sheets --------------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* 🔴 **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.
|
||||
* The choice between copying and linking is made here, with both consequences
|
||||
* written out, rather than by a default somebody discovers later.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.AddFileSheet(
|
||||
@@ -442,18 +444,21 @@ private fun ColumnScope.AddFileSheet(
|
||||
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.SurfaceAlt)
|
||||
.padding(Space.md)
|
||||
.testTag("files-add-sheet"),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.SurfaceAlt)
|
||||
.padding(Space.md)
|
||||
.testTag("files-add-sheet"),
|
||||
) {
|
||||
Text("Add a file", fontWeight = FontWeight.Medium, fontSize = 16.sp)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
OutlinedTextField(
|
||||
value = name, onValueChange = { name = it },
|
||||
label = { Text("Name") }, singleLine = true,
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("files-add-name"),
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
@@ -461,32 +466,40 @@ private fun ColumnScope.AddFileSheet(
|
||||
Spacer(Modifier.height(Space.md))
|
||||
|
||||
Text(
|
||||
"Copy it in and encrypt it. A stolen phone or backup gives up "
|
||||
+ "nothing but ciphertext.",
|
||||
fontSize = 12.sp, color = Palette.SecondaryText,
|
||||
"Copy it in and encrypt it. A stolen phone or backup gives up " +
|
||||
"nothing but ciphertext.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
KitPrimaryButton(
|
||||
text = "Copy in and encrypt",
|
||||
onClick = { onVault(name.trim().ifEmpty { file.name }, level) },
|
||||
modifier = Modifier.fillMaxWidth(), tag = "files-add-vault",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "files-add-vault",
|
||||
)
|
||||
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 "
|
||||
+ "is a note, not a lock.",
|
||||
fontSize = 12.sp, color = Palette.SecondaryText,
|
||||
"Or link to it where it is. Nothing is copied and nothing is " +
|
||||
"encrypted until you send it, until then the classification " +
|
||||
"is a note, not a lock.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
KitSecondaryButton(
|
||||
text = "Link to it instead",
|
||||
onClick = { onLink(name.trim().ifEmpty { file.name }, level) },
|
||||
modifier = Modifier.fillMaxWidth(), tag = "files-add-link",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "files-add-link",
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
KitSecondaryButton(text = "Cancel", onClick = onCancel,
|
||||
modifier = Modifier.fillMaxWidth(), tag = "files-add-cancel")
|
||||
KitSecondaryButton(
|
||||
text = "Cancel",
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "files-add-cancel",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,45 +513,58 @@ private fun ColumnScope.FolderNameSheet(
|
||||
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.SurfaceAlt)
|
||||
.padding(Space.md)
|
||||
.testTag("files-folder-sheet"),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.SurfaceAlt)
|
||||
.padding(Space.md)
|
||||
.testTag("files-folder-sheet"),
|
||||
) {
|
||||
Text("New folder", fontWeight = FontWeight.Medium, fontSize = 16.sp)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
OutlinedTextField(
|
||||
value = name, onValueChange = { name = it },
|
||||
label = { Text("Name") }, singleLine = true,
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("files-folder-name"),
|
||||
)
|
||||
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 "
|
||||
+ "keeps its own.",
|
||||
fontSize = 12.sp, color = Palette.SecondaryText,
|
||||
"A folder's level doesn't limit what you put inside it, each file " +
|
||||
"keeps its own.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||
KitSecondaryButton(text = "Cancel", onClick = onCancel,
|
||||
modifier = Modifier.weight(1f), tag = "files-folder-cancel")
|
||||
KitSecondaryButton(
|
||||
text = "Cancel",
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "files-folder-cancel",
|
||||
)
|
||||
KitPrimaryButton(
|
||||
text = "Create", onClick = { onCreate(name, level) },
|
||||
text = "Create",
|
||||
onClick = { onCreate(name, level) },
|
||||
enabled = name.isNotBlank(),
|
||||
modifier = Modifier.weight(1f), tag = "files-folder-create",
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "files-folder-create",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.LevelPicker(selected: Short, onPick: (Short) -> Unit) {
|
||||
private fun ColumnScope.LevelPicker(
|
||||
selected: Short,
|
||||
onPick: (Short) -> Unit,
|
||||
) {
|
||||
Text("Classification", fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
Row(
|
||||
@@ -547,7 +573,8 @@ private fun ColumnScope.LevelPicker(selected: Short, onPick: (Short) -> Unit) {
|
||||
) {
|
||||
for ((ordinal, name) in FileLevels.LOCAL_LEVELS) {
|
||||
KitChip(
|
||||
text = name, selected = ordinal == selected,
|
||||
text = name,
|
||||
selected = ordinal == selected,
|
||||
onClick = { onPick(ordinal) },
|
||||
tag = "files-level-$ordinal",
|
||||
)
|
||||
@@ -555,14 +582,10 @@ private fun ColumnScope.LevelPicker(selected: Short, onPick: (Short) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Into and out of a conversation --------------------------------------
|
||||
// ---- Into and out of a conversation --------------------------------------.
|
||||
|
||||
/**
|
||||
* What can be attached to a message.
|
||||
*
|
||||
* ⚠️ 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.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.AttachPanel(
|
||||
@@ -571,36 +594,32 @@ fun ColumnScope.AttachPanel(
|
||||
onFiles: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.SurfaceAlt)
|
||||
.padding(Space.md)
|
||||
.testTag("attach-panel"),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.SurfaceAlt)
|
||||
.padding(Space.md)
|
||||
.testTag("attach-panel"),
|
||||
verticalArrangement = Arrangement.spacedBy(Space.sm),
|
||||
) {
|
||||
KitSecondaryButton(text = "Photo", onClick = onPhoto,
|
||||
modifier = Modifier.fillMaxWidth(), tag = "attach-photo")
|
||||
KitSecondaryButton(
|
||||
text = "Photo",
|
||||
onClick = onPhoto,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "attach-photo",
|
||||
)
|
||||
KitSecondaryButton(
|
||||
text = if (model.filesUnlocked()) "From Files" else "From Files (locked)",
|
||||
onClick = onFiles,
|
||||
modifier = Modifier.fillMaxWidth(), tag = "attach-files",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "attach-files",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose a document from the vault, from inside a conversation.
|
||||
*
|
||||
* 🔴 **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
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.PickFileToSendSheet(
|
||||
@@ -615,67 +634,76 @@ fun ColumnScope.PickFileToSendSheet(
|
||||
val entries = remember(unlocked) { if (unlocked) model.filesForSending() else emptyList() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.SurfaceAlt)
|
||||
.padding(Space.md)
|
||||
.testTag("pick-file-sheet"),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.SurfaceAlt)
|
||||
.padding(Space.md)
|
||||
.testTag("pick-file-sheet"),
|
||||
) {
|
||||
Text("Send a file", fontWeight = FontWeight.Medium, fontSize = 16.sp)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
|
||||
if (!unlocked) {
|
||||
Text(
|
||||
"Files is locked. Open Files from your account and unlock it, "
|
||||
+ "then come back — your passphrase isn't asked for here.",
|
||||
fontSize = 13.sp, color = Palette.SecondaryText,
|
||||
"Files is locked. Open Files from your account and unlock it, " +
|
||||
"then come back, your passphrase isn't asked for here.",
|
||||
fontSize = 13.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
} else if (entries.isEmpty()) {
|
||||
Text("Nothing in your files yet.", fontSize = 13.sp, color = Palette.SecondaryText)
|
||||
} else {
|
||||
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.",
|
||||
fontSize = 12.sp, color = Palette.SecondaryText,
|
||||
"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.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
// 🔴 **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",
|
||||
// and dangerous only because nothing said which path you were on.
|
||||
// The photo button strips location; this one does not (android#17).
|
||||
Text(
|
||||
"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,
|
||||
"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,
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
for (entry in entries) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onPick(entry) }
|
||||
.padding(vertical = Space.sm)
|
||||
.testTag("pick-file-row"),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onPick(entry) }
|
||||
.padding(vertical = Space.sm)
|
||||
.testTag("pick-file-row"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(entry.name ?: "Unnamed", fontSize = 15.sp,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
entry.name ?: "Unnamed",
|
||||
fontSize = 15.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
FileLevels.levelName(entry.label.classification),
|
||||
fontSize = 11.sp, color = Palette.SecondaryText,
|
||||
fontSize = 11.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
KitSecondaryButton(text = "Cancel", onClick = onCancel,
|
||||
modifier = Modifier.fillMaxWidth(), tag = "pick-file-cancel")
|
||||
KitSecondaryButton(
|
||||
text = "Cancel",
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "pick-file-cancel",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,10 +33,6 @@ 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
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
@@ -49,32 +45,35 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
var working by remember { mutableStateOf(false) }
|
||||
var status by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val saveLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/octet-stream")
|
||||
) { uri ->
|
||||
val bytes = pendingExport
|
||||
pendingExport = null
|
||||
if (uri == null || bytes == null) return@rememberLauncherForActivityResult
|
||||
status = runCatching {
|
||||
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." },
|
||||
onFailure = { "Couldn't write the file: ${it.message}" },
|
||||
)
|
||||
}
|
||||
val saveLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/octet-stream"),
|
||||
) { uri ->
|
||||
val bytes = pendingExport
|
||||
pendingExport = null
|
||||
if (uri == null || bytes == null) return@rememberLauncherForActivityResult
|
||||
status =
|
||||
runCatching {
|
||||
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." },
|
||||
onFailure = { "Couldn't write the file: ${it.message}" },
|
||||
)
|
||||
}
|
||||
|
||||
val openLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument()
|
||||
) { uri -> if (uri != null) pendingImport = uri }
|
||||
val openLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument(),
|
||||
) { uri -> if (uri != null) pendingImport = uri }
|
||||
|
||||
Text("History backup", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"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 "
|
||||
+ "opened, by anyone.",
|
||||
"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 " +
|
||||
"opened, by anyone.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -87,7 +86,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,
|
||||
@@ -96,16 +95,21 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
}
|
||||
status?.let {
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
Text(it, fontSize = 12.sp, color = Palette.SecondaryText,
|
||||
modifier = Modifier.testTag("backup-status"))
|
||||
Text(
|
||||
it,
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.testTag("backup-status"),
|
||||
)
|
||||
}
|
||||
|
||||
if (showExportDialog) {
|
||||
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 "
|
||||
+ "never be opened.",
|
||||
explainer =
|
||||
"This passphrase is the only key to the backup. It isn't " +
|
||||
"stored anywhere, a forgotten passphrase means the backup can " +
|
||||
"never be opened.",
|
||||
requireRepeat = true,
|
||||
busy = working,
|
||||
onDismiss = { if (!working) showExportDialog = false },
|
||||
@@ -129,17 +133,19 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
pendingImport?.let { uri ->
|
||||
PassphraseDialog(
|
||||
title = "Import history",
|
||||
explainer = "Anything already on this phone stays — the backup is "
|
||||
+ "merged in, never a replacement.",
|
||||
explainer =
|
||||
"Anything already on this phone stays, the backup is " +
|
||||
"merged in, never a replacement.",
|
||||
requireRepeat = false,
|
||||
busy = working,
|
||||
onDismiss = { if (!working) pendingImport = null },
|
||||
) { phrase ->
|
||||
working = true
|
||||
scope.launch {
|
||||
val bytes = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()
|
||||
val bytes =
|
||||
runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()
|
||||
if (bytes == null) {
|
||||
working = false
|
||||
pendingImport = null
|
||||
@@ -149,17 +155,19 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
val outcome = model.importHistory(bytes, phrase.toCharArray())
|
||||
working = false
|
||||
pendingImport = null
|
||||
status = outcome.fold(
|
||||
onSuccess = { counts ->
|
||||
if (counts.messages + counts.stories == 0)
|
||||
"Backup opened — everything in it was already here."
|
||||
else
|
||||
"Imported ${counts.messages} message${if (counts.messages == 1) "" else "s"}" +
|
||||
" and ${counts.stories} " +
|
||||
if (counts.stories == 1) "story." else "stories."
|
||||
},
|
||||
onFailure = { it.message ?: "That backup couldn't be opened." },
|
||||
)
|
||||
status =
|
||||
outcome.fold(
|
||||
onSuccess = { counts ->
|
||||
if (counts.messages + counts.stories == 0) {
|
||||
"Backup opened, everything in it was already here."
|
||||
} else {
|
||||
"Imported ${counts.messages} message${if (counts.messages == 1) "" else "s"}" +
|
||||
" and ${counts.stories} " +
|
||||
if (counts.stories == 1) "story." else "stories."
|
||||
}
|
||||
},
|
||||
onFailure = { it.message ?: "That backup couldn't be opened." },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,13 +214,19 @@ private fun PassphraseDialog(
|
||||
modifier = Modifier.fillMaxWidth().testTag("backup-passphrase-repeat"),
|
||||
)
|
||||
if (mismatch) {
|
||||
Text("The two passphrases don't match yet.",
|
||||
fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
|
||||
Text(
|
||||
"The two passphrases don't match yet.",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (phrase.isNotEmpty() && phrase.length < 6) {
|
||||
Text("Use at least 6 characters.",
|
||||
fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
Text(
|
||||
"Use at least 6 characters.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,62 +9,51 @@ 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.
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
object ImagePrep {
|
||||
|
||||
/**
|
||||
* How much of the server's cap a prepared image may use.
|
||||
*
|
||||
* Deliberately under the relay's 10 MiB: the blob is encrypted before
|
||||
* upload and the AEAD tag and framing ride on top of these bytes. Sizing to
|
||||
* the exact cap produces a file that passes here and is refused there.
|
||||
*/
|
||||
const val BYTE_BUDGET = 9 * 1024 * 1024
|
||||
|
||||
private const val STANDARD_MAX_SIDE = 2048
|
||||
private const val FULL_MAX_SIDE = 4096
|
||||
|
||||
data class Prepared(val bytes: ByteArray, val mime: String, val width: Int, val height: Int)
|
||||
data class Prepared(
|
||||
val bytes: ByteArray,
|
||||
val mime: String,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* @param fullQuality the user's "send full quality photos" setting.
|
||||
* @return the prepared image, or null if the bytes are not a decodable image.
|
||||
* @return the prepared image, or null if the bytes are not a decodable
|
||||
* image.
|
||||
*/
|
||||
fun prepare(source: ByteArray, fullQuality: Boolean): Prepared? {
|
||||
fun prepare(
|
||||
source: ByteArray,
|
||||
fullQuality: Boolean,
|
||||
): Prepared? {
|
||||
val maxSide = if (fullQuality) FULL_MAX_SIDE else STANDARD_MAX_SIDE
|
||||
|
||||
// Bounds first, so an enormous file is never fully decoded into memory
|
||||
// just to find out how big it is. Decoding a 50MP JPEG at full size is
|
||||
// ~200 MB of bitmap and is how a photo picker gets OOM-killed.
|
||||
// just to find out how big it is.
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(source, 0, source.size, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
|
||||
val decoded = orientedFrom(source, decodeScaled(source, bounds, maxSide) ?: return null)
|
||||
|
||||
// Steps, best first. Each is a real fallback rather than a retry: for a
|
||||
// very large picture quality alone will not reach the budget and the
|
||||
// dimensions have to give as well.
|
||||
val attempts = listOf(
|
||||
maxSide to (if (fullQuality) 95 else 80),
|
||||
maxSide to 70,
|
||||
minOf(maxSide, 2048) to 70,
|
||||
1600 to 60,
|
||||
1024 to 50,
|
||||
)
|
||||
// Steps, best first.
|
||||
val attempts =
|
||||
listOf(
|
||||
maxSide to (if (fullQuality) 95 else 80),
|
||||
maxSide to 70,
|
||||
minOf(maxSide, 2048) to 70,
|
||||
1600 to 60,
|
||||
1024 to 50,
|
||||
)
|
||||
|
||||
var last: Prepared? = null
|
||||
for ((side, quality) in attempts) {
|
||||
@@ -80,30 +69,19 @@ object ImagePrep {
|
||||
}
|
||||
}
|
||||
decoded.recycle()
|
||||
// Every step was still too large. Returning the smallest is better than
|
||||
// null: the caller surfaces the server's refusal, where null here would
|
||||
// look like an unreadable file instead.
|
||||
// Every step was still too large.
|
||||
return last
|
||||
}
|
||||
|
||||
/** Decode with `inSampleSize`, so the full-size bitmap never exists. */
|
||||
|
||||
/**
|
||||
* 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 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
|
||||
* way for the victim to get past it.
|
||||
*
|
||||
* ⚠️ 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.
|
||||
*/
|
||||
fun decodeBounded(source: ByteArray, maxSide: Int = STANDARD_MAX_SIDE): Bitmap? {
|
||||
fun decodeBounded(
|
||||
source: ByteArray,
|
||||
maxSide: Int = STANDARD_MAX_SIDE,
|
||||
): Bitmap? {
|
||||
if (source.isEmpty()) return null
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(source, 0, source.size, bounds)
|
||||
@@ -113,7 +91,11 @@ object ImagePrep {
|
||||
return decodeScaled(source, bounds, maxSide)?.let { orientedFrom(source, it) }
|
||||
}
|
||||
|
||||
private fun decodeScaled(source: ByteArray, bounds: BitmapFactory.Options, maxSide: Int): Bitmap? {
|
||||
private fun decodeScaled(
|
||||
source: ByteArray,
|
||||
bounds: BitmapFactory.Options,
|
||||
maxSide: Int,
|
||||
): Bitmap? {
|
||||
var sample = 1
|
||||
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxSide * 2) sample *= 2
|
||||
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||
@@ -122,57 +104,70 @@ object ImagePrep {
|
||||
|
||||
/**
|
||||
* Turn the pixels the way the EXIF tag says, then forget the tag.
|
||||
*
|
||||
* 🔴 **`BitmapFactory` does not apply EXIF orientation, and re-encoding
|
||||
* throws the tag away.** A phone camera almost never writes upright
|
||||
* pixels: it writes landscape pixels plus "rotate this 90°". Decoding
|
||||
* ignores that, the JPEG we write out carries no EXIF at all — deliberately,
|
||||
* because that block holds the GPS coordinates the shot was taken at — and
|
||||
* so the only thing that knew which way was up is gone. Every portrait
|
||||
* photo arrived on its side.
|
||||
*
|
||||
* ⚠️ **iOS is not affected and that is why this survived**, so a fix
|
||||
* confirmed on one app proves nothing about the other: `UIImage(data:)`
|
||||
* applies the orientation as it decodes, so the same strip-and-re-encode is
|
||||
* safe there. Same design, opposite platform default.
|
||||
*
|
||||
* ⚠️ Returns the bitmap unchanged when there is nothing to do — the common
|
||||
* case, and it avoids copying a large bitmap for no reason.
|
||||
*/
|
||||
internal fun orientedFrom(source: ByteArray, bitmap: Bitmap): Bitmap {
|
||||
val tag = runCatching {
|
||||
ExifInterface(ByteArrayInputStream(source))
|
||||
.getAttributeInt(ExifInterface.TAG_ORIENTATION,
|
||||
ExifInterface.ORIENTATION_NORMAL)
|
||||
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
|
||||
internal fun orientedFrom(
|
||||
source: ByteArray,
|
||||
bitmap: Bitmap,
|
||||
): Bitmap {
|
||||
val tag =
|
||||
runCatching {
|
||||
ExifInterface(ByteArrayInputStream(source))
|
||||
.getAttributeInt(
|
||||
ExifInterface.TAG_ORIENTATION,
|
||||
ExifInterface.ORIENTATION_NORMAL,
|
||||
)
|
||||
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
|
||||
|
||||
val matrix = Matrix()
|
||||
when (tag) {
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
||||
// ⚠️ The mirrored tags are rare but real — a front camera writes
|
||||
// them. Handled rather than ignored, because a selfie coming back
|
||||
// flipped is the same class of bug as one coming back sideways.
|
||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
|
||||
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> {
|
||||
matrix.postRotate(90f)
|
||||
}
|
||||
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> {
|
||||
matrix.postRotate(180f)
|
||||
}
|
||||
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> {
|
||||
matrix.postRotate(270f)
|
||||
}
|
||||
|
||||
// The mirrored tags are rare but real, a front camera writes them.
|
||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
|
||||
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
|
||||
matrix.postScale(1f, -1f)
|
||||
}
|
||||
|
||||
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||
matrix.postRotate(90f); matrix.postScale(-1f, 1f)
|
||||
matrix.postRotate(90f)
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
|
||||
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||
matrix.postRotate(270f); matrix.postScale(-1f, 1f)
|
||||
matrix.postRotate(270f)
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
|
||||
else -> {
|
||||
return bitmap
|
||||
}
|
||||
else -> return bitmap
|
||||
}
|
||||
val turned = runCatching {
|
||||
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||
}.getOrNull() ?: return bitmap
|
||||
val turned =
|
||||
runCatching {
|
||||
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||
}.getOrNull() ?: return bitmap
|
||||
if (turned !== bitmap) bitmap.recycle()
|
||||
return turned
|
||||
}
|
||||
|
||||
/** Scale so the longest side is at most [maxSide]. Never enlarges. */
|
||||
private fun scaleTo(bitmap: Bitmap, maxSide: Int): Bitmap {
|
||||
private fun scaleTo(
|
||||
bitmap: Bitmap,
|
||||
maxSide: Int,
|
||||
): Bitmap {
|
||||
val longest = maxOf(bitmap.width, bitmap.height)
|
||||
if (longest <= maxSide) return bitmap
|
||||
val ratio = maxSide.toDouble() / longest
|
||||
|
||||
@@ -11,73 +11,48 @@ import javax.crypto.SecretKey
|
||||
|
||||
/**
|
||||
* The vault's key, held by the platform, and its records, held as files.
|
||||
*
|
||||
* ⚠️ **The key is never seen by this process.** `AndroidKeyStore` returns a
|
||||
* handle; the bytes stay in hardware-backed storage where the OS keeps them.
|
||||
* That is why [io.flamenet.e2e.Vault] works with a `SecretKey` rather than raw
|
||||
* bytes — a design that needed the bytes would have to keep them somewhere,
|
||||
* which is exactly what this avoids.
|
||||
*
|
||||
* ⚠️ **The key does not leave this device, so neither does the vault.** It is
|
||||
* generated here on first launch and is not exportable or backupable, which is
|
||||
* the Android equivalent of the iOS client's `ThisDeviceOnly` Keychain item. A
|
||||
* consequence worth stating plainly: **uninstalling the app destroys the key,
|
||||
* and every stored conversation with it.** That is the intended behaviour —
|
||||
* "deleted means deleted" — but it means the vault is not a backup, and the
|
||||
* manifest turns `allowBackup` off so the records cannot be restored somewhere
|
||||
* their key can never follow.
|
||||
*/
|
||||
object KeystoreVault {
|
||||
|
||||
private const val KEYSTORE = "AndroidKeyStore"
|
||||
private const val ALIAS = "flamenet.messenger.vault.v1"
|
||||
private const val DIRECTORY = "vault"
|
||||
|
||||
/**
|
||||
* Is there a session worth receiving for?
|
||||
*
|
||||
* ⚠️ 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.
|
||||
*/
|
||||
fun hasSession(context: Context): Boolean = runCatching {
|
||||
val v = open(context)
|
||||
v.loadIdentity() != null && v.loadCredential() != null
|
||||
}.getOrDefault(false)
|
||||
fun hasSession(context: Context): Boolean =
|
||||
runCatching {
|
||||
val v = open(context)
|
||||
v.loadIdentity() != null && v.loadCredential() != null
|
||||
}.getOrDefault(false)
|
||||
|
||||
fun open(context: Context): Vault = Vault(FileStorage(File(context.filesDir, DIRECTORY)), key())
|
||||
|
||||
/**
|
||||
* The vault key, generated on first use.
|
||||
*
|
||||
* ⚠️ No `setUserAuthenticationRequired`. Requiring a device unlock per use
|
||||
* would sound stronger and would break background polling — the app cannot
|
||||
* decrypt an incoming message while the screen is off, so mail silently
|
||||
* stops arriving until the user opens the app. App lock is a separate
|
||||
* feature at the UI layer, where it can be honest about what it protects.
|
||||
*/
|
||||
private fun key(): SecretKey {
|
||||
val store = KeyStore.getInstance(KEYSTORE).apply { load(null) }
|
||||
(store.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
|
||||
|
||||
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE).apply {
|
||||
init(
|
||||
KeyGenParameterSpec.Builder(
|
||||
ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||
return KeyGenerator
|
||||
.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
|
||||
.apply {
|
||||
init(
|
||||
KeyGenParameterSpec
|
||||
.Builder(
|
||||
ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||
).setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
// The vault supplies its own nonce per record, and a
|
||||
// repeated nonce under one key is the catastrophic AES-
|
||||
// GCM failure.
|
||||
.setRandomizedEncryptionRequired(false)
|
||||
.build(),
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
// The vault supplies its own nonce per record, and a
|
||||
// repeated nonce under one key is the catastrophic AES-GCM
|
||||
// failure. Letting the caller provide it is only safe
|
||||
// because that nonce is freshly random every write.
|
||||
.setRandomizedEncryptionRequired(false)
|
||||
.build()
|
||||
)
|
||||
}.generateKey()
|
||||
}.generateKey()
|
||||
}
|
||||
|
||||
/** Erase the key itself. Sign-out, and the only way the records become unrecoverable. */
|
||||
@@ -88,30 +63,31 @@ 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
|
||||
* are encoded rather than trusted: a name with a `/` in it would otherwise
|
||||
* write outside the vault directory. The *unencoded* name is what goes into
|
||||
* the record's AAD, so the binding survives the encoding.
|
||||
*/
|
||||
private class FileStorage(private val root: File) : Vault.Storage {
|
||||
|
||||
init { root.mkdirs() }
|
||||
private class FileStorage(
|
||||
private val root: File,
|
||||
) : Vault.Storage {
|
||||
init {
|
||||
root.mkdirs()
|
||||
}
|
||||
|
||||
private fun fileFor(name: String) =
|
||||
File(root, android.util.Base64.encodeToString(
|
||||
name.toByteArray(Charsets.UTF_8),
|
||||
android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING or android.util.Base64.NO_WRAP,
|
||||
))
|
||||
File(
|
||||
root,
|
||||
android.util.Base64.encodeToString(
|
||||
name.toByteArray(Charsets.UTF_8),
|
||||
android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING or android.util.Base64.NO_WRAP,
|
||||
),
|
||||
)
|
||||
|
||||
override fun read(name: String): ByteArray? =
|
||||
fileFor(name).takeIf { it.exists() }?.readBytes()
|
||||
override fun read(name: String): ByteArray? = fileFor(name).takeIf { it.exists() }?.readBytes()
|
||||
|
||||
override fun write(name: String, bytes: ByteArray) {
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
// Write beside and rename, so a kill mid-write leaves the previous
|
||||
// record intact rather than a truncated one. A half-written session
|
||||
// reads as tampering, which is the worst possible way to report
|
||||
// "the phone ran out of battery".
|
||||
// record intact rather than a truncated one.
|
||||
val target = fileFor(name)
|
||||
val staging = File(root, target.name + ".tmp")
|
||||
staging.writeBytes(bytes)
|
||||
@@ -121,10 +97,14 @@ object KeystoreVault {
|
||||
}
|
||||
}
|
||||
|
||||
override fun delete(name: String) { fileFor(name).delete() }
|
||||
override fun delete(name: String) {
|
||||
fileFor(name).delete()
|
||||
}
|
||||
|
||||
override fun names(): List<String> =
|
||||
root.listFiles().orEmpty()
|
||||
root
|
||||
.listFiles()
|
||||
.orEmpty()
|
||||
.filterNot { it.name.endsWith(".tmp") }
|
||||
.mapNotNull {
|
||||
runCatching {
|
||||
|
||||
@@ -22,21 +22,6 @@ import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* The kit's buttons.
|
||||
*
|
||||
* 🔴 **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
|
||||
* links rather than as a set of controls. That is not an Android convention;
|
||||
* 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
|
||||
* and 16 of horizontal padding. See `DESIGN.md`.
|
||||
*
|
||||
* ⚠️ 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.
|
||||
*/
|
||||
object KitButton {
|
||||
val height = 64.dp
|
||||
@@ -57,9 +42,10 @@ fun KitPrimaryButton(
|
||||
enabled = enabled,
|
||||
shape = RoundedCornerShape(KitButton.radius),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
modifier = modifier
|
||||
.height(KitButton.height)
|
||||
.then(if (tag != null) Modifier.testTag(tag) else Modifier),
|
||||
modifier =
|
||||
modifier
|
||||
.height(KitButton.height)
|
||||
.then(if (tag != null) Modifier.testTag(tag) else Modifier),
|
||||
) {
|
||||
Text(text, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
@@ -84,21 +70,18 @@ fun KitSecondaryButton(
|
||||
border = BorderStroke(1.dp, colour),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = colour),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
modifier = modifier
|
||||
.height(KitButton.height)
|
||||
.then(if (tag != null) Modifier.testTag(tag) else Modifier),
|
||||
modifier =
|
||||
modifier
|
||||
.height(KitButton.height)
|
||||
.then(if (tag != null) Modifier.testTag(tag) else Modifier),
|
||||
) {
|
||||
Text(text, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* can hit it, which the kit's badge does not have to survive.
|
||||
* A small pill for something that sits in a row of its peers, a presence state,
|
||||
* a filter.
|
||||
*/
|
||||
@Composable
|
||||
fun KitChip(
|
||||
@@ -108,10 +91,7 @@ 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
|
||||
* 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.
|
||||
* Optional, and a real vector when present.
|
||||
*/
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector? = null,
|
||||
) {
|
||||
@@ -119,15 +99,25 @@ fun KitChip(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
contentPadding = PaddingValues(horizontal = 14.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (selected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = if (selected) MaterialTheme.colorScheme.onPrimary
|
||||
else Palette.SecondaryText,
|
||||
),
|
||||
modifier = modifier
|
||||
.height(36.dp)
|
||||
.then(if (tag != null) Modifier.testTag(tag) else Modifier),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor =
|
||||
if (selected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
contentColor =
|
||||
if (selected) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
Palette.SecondaryText
|
||||
},
|
||||
),
|
||||
modifier =
|
||||
modifier
|
||||
.height(36.dp)
|
||||
.then(if (tag != null) Modifier.testTag(tag) else Modifier),
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
|
||||
@@ -8,34 +8,33 @@ import androidx.compose.material.icons.filled.ChatBubble
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.material.icons.outlined.AccountBalanceWallet
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.AddComment
|
||||
import androidx.compose.material.icons.outlined.AddCircleOutline
|
||||
import androidx.compose.material.icons.outlined.AddComment
|
||||
import androidx.compose.material.icons.outlined.Block
|
||||
import androidx.compose.material.icons.outlined.Call
|
||||
import androidx.compose.material.icons.outlined.CallEnd
|
||||
import androidx.compose.material.icons.outlined.ChatBubbleOutline
|
||||
import androidx.compose.material.icons.outlined.Circle
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material.icons.outlined.DeleteOutline
|
||||
import androidx.compose.material.icons.outlined.EmojiEmotions
|
||||
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||
import androidx.compose.material.icons.outlined.FolderOpen
|
||||
import androidx.compose.material.icons.outlined.GroupAdd
|
||||
import androidx.compose.material.icons.outlined.Home
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.Mic
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.MicOff
|
||||
import androidx.compose.material.icons.outlined.MoreHoriz
|
||||
import androidx.compose.material.icons.outlined.EmojiEmotions
|
||||
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||
import androidx.compose.material.icons.outlined.Schedule
|
||||
import androidx.compose.material.icons.outlined.StickyNote2
|
||||
import androidx.compose.material.icons.outlined.NotificationsNone
|
||||
import androidx.compose.material.icons.outlined.PersonAdd
|
||||
import androidx.compose.material.icons.outlined.PersonOutline
|
||||
import androidx.compose.material.icons.outlined.QrCodeScanner
|
||||
import androidx.compose.material.icons.outlined.PersonRemove
|
||||
import androidx.compose.material.icons.outlined.Circle
|
||||
import androidx.compose.material.icons.outlined.PhotoCamera
|
||||
import androidx.compose.material.icons.outlined.QrCodeScanner
|
||||
import androidx.compose.material.icons.outlined.Schedule
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material.icons.outlined.StickyNote2
|
||||
import androidx.compose.material.icons.outlined.Verified
|
||||
import androidx.compose.material.icons.outlined.Videocam
|
||||
import androidx.compose.material.icons.outlined.VolumeUp
|
||||
@@ -43,52 +42,35 @@ 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
|
||||
* 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
|
||||
* agree on the 24dp margin, the 48dp row on a 72dp pitch, the search pill and
|
||||
* the story strip. What they did not agree on is that every piece of chrome in
|
||||
* the kit is a drawn line icon and every piece of ours was a glyph in a text
|
||||
* 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
|
||||
* 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` —
|
||||
* so swapping the drawing later is one edit here and nothing at the call sites.
|
||||
*/
|
||||
object KitIcon {
|
||||
// Chrome
|
||||
// Chrome.
|
||||
val Back: ImageVector = Icons.AutoMirrored.Outlined.ArrowBackIos
|
||||
val More: ImageVector = Icons.Outlined.MoreHoriz
|
||||
val Close: ImageVector = Icons.Outlined.Close
|
||||
val Add: ImageVector = Icons.Outlined.Add
|
||||
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.
|
||||
// Tab bar.
|
||||
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
|
||||
val Alerts: ImageVector = Icons.Outlined.NotificationsNone
|
||||
val Account: ImageVector = Icons.Outlined.PersonOutline
|
||||
|
||||
// The two that joined the bar on 2026-09-07. iOS draws `folder.fill` and
|
||||
// `wallet.bifold.fill`; these are the Material shapes closest to them, so
|
||||
// the same destination reads the same on both phones.
|
||||
val Files: ImageVector = Icons.Outlined.FolderOpen
|
||||
val Wallet: ImageVector = Icons.Outlined.AccountBalanceWallet
|
||||
|
||||
// Composer
|
||||
// Composer.
|
||||
val Attach: ImageVector = Icons.Outlined.AddCircleOutline
|
||||
val Camera: ImageVector = Icons.Outlined.PhotoCamera
|
||||
val Send: ImageVector = Icons.AutoMirrored.Outlined.Send
|
||||
@@ -97,18 +79,19 @@ object KitIcon {
|
||||
val Lock: ImageVector = Icons.Outlined.Lock
|
||||
val Sticker: ImageVector = Icons.Outlined.StickyNote2
|
||||
|
||||
// Calls
|
||||
// Calls.
|
||||
val Call: ImageVector = Icons.Outlined.Call
|
||||
val Video: ImageVector = Icons.Outlined.Videocam
|
||||
val CallEnd: ImageVector = Icons.Outlined.CallEnd
|
||||
val MicOff: ImageVector = Icons.Outlined.MicOff
|
||||
val Speaker: ImageVector = Icons.Outlined.VolumeUp
|
||||
|
||||
// Contact actions
|
||||
// Contact actions.
|
||||
val NewGroup: ImageVector = Icons.Outlined.GroupAdd
|
||||
val NewChat: ImageVector = Icons.Outlined.AddComment
|
||||
val Scan: ImageVector = Icons.Outlined.QrCodeScanner
|
||||
val Block: ImageVector = Icons.Outlined.Block
|
||||
|
||||
/** Unblocking: the same row, the opposite verb. */
|
||||
val PersonAdd: ImageVector = Icons.Outlined.PersonAdd
|
||||
val ClearChat: ImageVector = Icons.Outlined.DeleteOutline
|
||||
|
||||
@@ -13,10 +13,10 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -26,7 +26,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import io.flamenet.messenger.app.MessengerViewModel.Phase.Screen
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -62,47 +61,28 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import io.flamenet.messenger.app.MessengerViewModel.Phase.Screen
|
||||
|
||||
/**
|
||||
* ⚠️ `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
|
||||
* there looking broken. `FragmentActivity` extends `ComponentActivity`, so
|
||||
* `setContent` and everything else is unaffected.
|
||||
* silently cannot show, the callback never fires and the lock screen sits there
|
||||
* looking broken.
|
||||
*/
|
||||
class MainActivity : androidx.fragment.app.FragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// 🔴 **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
|
||||
// the rendered thread.
|
||||
//
|
||||
// ⚠️ 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,
|
||||
// and is not something to make optional: a setting here is a setting
|
||||
// people leave off and then get surprised by.
|
||||
// Screenshots off, and out of the recents thumbnail.
|
||||
window.setFlags(
|
||||
android.view.WindowManager.LayoutParams.FLAG_SECURE,
|
||||
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,
|
||||
// no banner, and nothing on screen to say why. Ask once here rather
|
||||
// than discovering it as "notifications do not work".
|
||||
// Runtime permission on API 33+, which is this app's minSdk.
|
||||
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||
!= android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 1001)
|
||||
}
|
||||
|
||||
@@ -110,25 +90,10 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
|
||||
|
||||
setContent {
|
||||
MessengerTheme {
|
||||
// 🔴 **`android:windowSoftInputMode="adjustResize"` stopped doing
|
||||
// anything at targetSdk 35.** Android 15 forces apps that target
|
||||
// it edge-to-edge, and an edge-to-edge window does not resize
|
||||
// for the keyboard — so the manifest line has been a no-op on
|
||||
// every device this ships to (minSdk 33), and the keyboard sat
|
||||
// over the chat and the box being typed into. Reported as
|
||||
// "bringing the chat above the keyboard would help me see what
|
||||
// I'm typing", which is exactly the symptom.
|
||||
//
|
||||
// ⚠️ At the root rather than per screen: every screen with a
|
||||
// text field has this problem, and one that is added later
|
||||
// would have it too. `imePadding` is inset consumption, so the
|
||||
// content shrinks and a scrolled list keeps its position —
|
||||
// adding padding under the keyboard, not scrolling over it.
|
||||
// `android:windowSoftInputMode="adjustResize"` stopped doing
|
||||
// anything at targetSdk 35.
|
||||
Surface(modifier = Modifier.fillMaxSize().imePadding()) {
|
||||
// 🔴 **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
|
||||
// needs. Locked means not composed.
|
||||
// The gate is around Root, not inside it.
|
||||
var locked by remember { mutableStateOf(lock.enabled) }
|
||||
|
||||
if (locked) {
|
||||
@@ -136,18 +101,24 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
|
||||
onUnlock = {
|
||||
lock.authenticate(
|
||||
activity = this@MainActivity,
|
||||
onSuccess = { lock.noteUnlocked(); locked = false },
|
||||
onSuccess = {
|
||||
lock.noteUnlocked()
|
||||
locked = false
|
||||
},
|
||||
onFailure = { /* the screen stays; it says why */ },
|
||||
)
|
||||
},
|
||||
)
|
||||
// ⚠️ 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) {
|
||||
lock.authenticate(
|
||||
activity = this@MainActivity,
|
||||
onSuccess = { lock.noteUnlocked(); locked = false },
|
||||
onSuccess = {
|
||||
lock.noteUnlocked()
|
||||
locked = false
|
||||
},
|
||||
onFailure = {},
|
||||
)
|
||||
}
|
||||
@@ -157,14 +128,22 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
|
||||
|
||||
// Re-arm on the way out, subject to the grace period.
|
||||
DisposableEffect(Unit) {
|
||||
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
androidx.lifecycle.Lifecycle.Event.ON_STOP -> lock.noteLeft()
|
||||
androidx.lifecycle.Lifecycle.Event.ON_START ->
|
||||
if (lock.shouldLock()) locked = true
|
||||
else -> Unit
|
||||
val observer =
|
||||
androidx.lifecycle.LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
androidx.lifecycle.Lifecycle.Event.ON_STOP -> {
|
||||
lock.noteLeft()
|
||||
}
|
||||
|
||||
androidx.lifecycle.Lifecycle.Event.ON_START -> {
|
||||
if (lock.shouldLock()) locked = true
|
||||
}
|
||||
|
||||
else -> {
|
||||
Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(observer)
|
||||
onDispose { lifecycle.removeObserver(observer) }
|
||||
}
|
||||
@@ -173,10 +152,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 **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.
|
||||
// The handover: exactly one poller at a time.
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
MessengerService.stop(this)
|
||||
@@ -184,9 +160,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
// ⚠️ 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.
|
||||
// Only when there is a session to receive for.
|
||||
if (KeystoreVault.hasSession(applicationContext)) MessengerService.start(this)
|
||||
}
|
||||
}
|
||||
@@ -200,7 +174,8 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
||||
// Scrollable, because a keyboard over a form needs somewhere to go.
|
||||
is MessengerViewModel.Phase.Starting,
|
||||
is MessengerViewModel.Phase.SignedOut,
|
||||
is MessengerViewModel.Phase.VaultUnreadable ->
|
||||
is MessengerViewModel.Phase.VaultUnreadable,
|
||||
-> {
|
||||
Column(
|
||||
modifier = frame.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
@@ -212,122 +187,90 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NOT scrollable. A `LazyColumn` inside a vertically scrolling
|
||||
// Column is measured with infinite height and crashes outright —
|
||||
// the lists below manage their own scrolling.
|
||||
is MessengerViewModel.Phase.SignedIn ->
|
||||
// NOT scrollable.
|
||||
is MessengerViewModel.Phase.SignedIn -> {
|
||||
Column(modifier = frame, verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
// 🔴 **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
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// exists, and a full screen for it would look like a call
|
||||
// already happening.
|
||||
// Above the screen switch, not inside one branch.
|
||||
CallBanner(model, current)
|
||||
StoryComposerDialog(model, current)
|
||||
// 🔴 **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
|
||||
// screen that happens to be.
|
||||
// Here, above the screen switch, for the same reason the
|
||||
// call banner is.
|
||||
UpdateAvailableDialog(model.site?.baseUrl)
|
||||
if (LiveCallScreen(model, current)) return@Column
|
||||
|
||||
val chat = current.openChat
|
||||
if (chat != null) {
|
||||
ChatScreen(model, chat, current.error, current.buddies, current.avatars,
|
||||
myUserId = current.userId, groups = current.groups)
|
||||
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
|
||||
// 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
|
||||
// OFF the bottom by the Account tab, which is taller
|
||||
// than the screen. Account also could not scroll, so the
|
||||
// sign-out explanation was unreadable and there was no
|
||||
// way back to another tab.
|
||||
//
|
||||
// 🔴 **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
|
||||
// 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
|
||||
// the bottom of a screen that could not scroll. The
|
||||
// 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
|
||||
// BELOW the lazy list, not just the list itself. And
|
||||
// keep the buddy list bounded — remove that height and
|
||||
// this does crash.
|
||||
// ⚠️ 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
|
||||
// rows sit on a 72dp pitch with nothing extra between
|
||||
// the groups of them.
|
||||
// The content area is weighted, and the tab bar is not.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(Space.sm),
|
||||
) {
|
||||
when (current.screen) {
|
||||
MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current)
|
||||
MessengerViewModel.Phase.Screen.MESSAGES ->
|
||||
MessengerViewModel.Phase.Screen.CONTACTS -> {
|
||||
SignedIn(model, current)
|
||||
}
|
||||
|
||||
MessengerViewModel.Phase.Screen.MESSAGES -> {
|
||||
MessagesScreen(model, current)
|
||||
MessengerViewModel.Phase.Screen.CALLS ->
|
||||
}
|
||||
|
||||
MessengerViewModel.Phase.Screen.CALLS -> {
|
||||
CallsScreen(model, current)
|
||||
MessengerViewModel.Phase.Screen.STORIES ->
|
||||
}
|
||||
|
||||
MessengerViewModel.Phase.Screen.STORIES -> {
|
||||
StoriesFeed(model, current)
|
||||
MessengerViewModel.Phase.Screen.WALLET ->
|
||||
}
|
||||
|
||||
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
|
||||
// implementation detail. What it produced was
|
||||
// somebody pressing "wallet" and arriving at a
|
||||
// file list.
|
||||
MessengerViewModel.Phase.Screen.FILES ->
|
||||
// they share an unlock, true, and an
|
||||
// implementation detail.
|
||||
MessengerViewModel.Phase.Screen.FILES -> {
|
||||
FilesScreen(model, current)
|
||||
MessengerViewModel.Phase.Screen.NOTIFICATIONS ->
|
||||
}
|
||||
|
||||
MessengerViewModel.Phase.Screen.NOTIFICATIONS -> {
|
||||
NotificationsScreen(model, current)
|
||||
MessengerViewModel.Phase.Screen.ACCOUNT ->
|
||||
}
|
||||
|
||||
MessengerViewModel.Phase.Screen.ACCOUNT -> {
|
||||
AccountScreen(model, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
BottomNav(model, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedOut) {
|
||||
private fun ColumnScope.SignIn(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedOut,
|
||||
) {
|
||||
val relay by model.relayInput.collectAsStateWithLifecycle()
|
||||
// Observed, not read once: the account mode is discovered from the relay a
|
||||
// moment after the address is typed, and the label has to catch up.
|
||||
@@ -341,27 +284,18 @@ 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
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// are ours.
|
||||
// The kit's Welcome screen, which is a masthead and not a form heading.
|
||||
Spacer(Modifier.height(Space.xl))
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(Radius.medium))
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(Radius.medium))
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("F", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
@@ -370,8 +304,8 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
|
||||
Text("Hi there!", fontSize = 40.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Text(
|
||||
"Flamenet Messenger. End-to-end encrypted, no phone number, and it "
|
||||
+ "runs on whichever server you point it at.",
|
||||
"Flamenet Messenger. End-to-end encrypted, no phone number, and it " +
|
||||
"runs on whichever server you point it at.",
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -388,9 +322,7 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
|
||||
modifier = Modifier.fillMaxWidth().testTag("relay"),
|
||||
)
|
||||
|
||||
// Say which kind of server this is BEFORE the passphrase is typed. Whether
|
||||
// the passphrase leaves the device differs between the two, and that is not
|
||||
// something to disclose afterwards.
|
||||
// Say which kind of server this is BEFORE the passphrase is typed.
|
||||
site?.let {
|
||||
Text(
|
||||
it.identityDescription,
|
||||
@@ -408,18 +340,16 @@ 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
|
||||
// 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.
|
||||
// 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.
|
||||
if (needsEmail) {
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = { Text("Email") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
|
||||
keyboardOptions =
|
||||
KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
|
||||
modifier = Modifier.fillMaxWidth().testTag("email"),
|
||||
)
|
||||
Text(
|
||||
@@ -434,31 +364,32 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
|
||||
label = { Text("Passphrase") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
|
||||
keyboardOptions =
|
||||
KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
|
||||
modifier = Modifier.fillMaxWidth().testTag("passphrase"),
|
||||
)
|
||||
|
||||
if (registering) {
|
||||
// The age gate. Neutral, and stated honestly: the year is checked on
|
||||
// this device to refuse an under-13 sign-up, then forgotten. Nothing
|
||||
// about age is sent to any server.
|
||||
// The age gate.
|
||||
OutlinedTextField(
|
||||
value = birthYear,
|
||||
onValueChange = { birthYear = it.filter(Char::isDigit).take(4) },
|
||||
label = { Text("Year of birth") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
|
||||
keyboardOptions =
|
||||
KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
|
||||
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 {
|
||||
Text(it, color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.testTag("age-error"))
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.testTag("age-error"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,30 +397,37 @@ 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
|
||||
// the bottom of a form; one that does not reads as a choice.
|
||||
// 240 wide and centred, which is the kit's primary button on this screen,
|
||||
// not a full-width slab.
|
||||
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
|
||||
val age =
|
||||
java.util.Calendar
|
||||
.getInstance()
|
||||
.get(java.util.Calendar.YEAR) - year
|
||||
if (year < 1900 || age < 14) {
|
||||
ageError = if (year < 1900) "Enter your year of birth."
|
||||
else "You must be at least 13 to use Flamenet Messenger."
|
||||
ageError =
|
||||
if (year < 1900) {
|
||||
"Enter your year of birth."
|
||||
} else {
|
||||
"You must be at least 13 to use Flamenet Messenger."
|
||||
}
|
||||
return@Button
|
||||
}
|
||||
ageError = null
|
||||
}
|
||||
model.signIn(handle.trim(), passphrase, register = registering, email = email.trim())
|
||||
},
|
||||
enabled = !phase.busy && handle.isNotBlank() && passphrase.isNotBlank() &&
|
||||
(!needsEmail || email.isNotBlank()) &&
|
||||
(!registering || birthYear.length == 4),
|
||||
enabled =
|
||||
!phase.busy && handle.isNotBlank() && passphrase.isNotBlank() &&
|
||||
(!needsEmail || email.isNotBlank()) &&
|
||||
(!registering || birthYear.length == 4),
|
||||
shape = RoundedCornerShape(KitButton.radius),
|
||||
modifier = Modifier.width(240.dp).height(KitButton.height).testTag("submit"),
|
||||
) {
|
||||
@@ -523,14 +461,12 @@ 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 —
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String) {
|
||||
private fun ColumnScope.ResetPassword(
|
||||
model: MessengerViewModel,
|
||||
handle: String,
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
var code by remember { mutableStateOf("") }
|
||||
var fresh by remember { mutableStateOf("") }
|
||||
@@ -577,52 +513,31 @@ 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
|
||||
* destination could be dropped in a refactor and no test anywhere would go red.
|
||||
* It has happened once already in spirit: Files and Wallet lived only inside
|
||||
* the Account screen for weeks while iOS carried them in its bar, and the two
|
||||
* apps disagreed with nothing to notice.
|
||||
*/
|
||||
object NavDestinations {
|
||||
|
||||
data class Tab(val screen: MessengerViewModel.Phase.Screen, val label: String, val tag: String)
|
||||
data class Tab(
|
||||
val screen: MessengerViewModel.Phase.Screen,
|
||||
val label: String,
|
||||
val tag: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* The bar, in order.
|
||||
*
|
||||
* ⚠️ **Order matches iOS**, which puts Files and Wallet between Stories and
|
||||
* Account. The same destination in the same place on both phones is most of
|
||||
* what "the apps stay in sync" means to somebody holding one of each.
|
||||
*
|
||||
* `storiesHidden` is parental supervision: it removes the surface rather
|
||||
* than showing an empty one.
|
||||
*/
|
||||
fun tabs(storiesHidden: Boolean): List<Tab> = listOfNotNull(
|
||||
Tab(Screen.CONTACTS, "Home", "nav-contacts"),
|
||||
Tab(Screen.MESSAGES, "Messages", "nav-messages"),
|
||||
Tab(Screen.CALLS, "Calls", "nav-calls"),
|
||||
Tab(Screen.STORIES, "Stories", "nav-stories").takeUnless { storiesHidden },
|
||||
Tab(Screen.FILES, "Files", "nav-files"),
|
||||
Tab(Screen.WALLET, "Wallet", "nav-wallet"),
|
||||
Tab(Screen.ACCOUNT, "Account", "nav-account"),
|
||||
)
|
||||
fun tabs(storiesHidden: Boolean): List<Tab> =
|
||||
listOfNotNull(
|
||||
Tab(Screen.CONTACTS, "Home", "nav-contacts"),
|
||||
Tab(Screen.MESSAGES, "Messages", "nav-messages"),
|
||||
Tab(Screen.CALLS, "Calls", "nav-calls"),
|
||||
Tab(Screen.STORIES, "Stories", "nav-stories").takeUnless { storiesHidden },
|
||||
Tab(Screen.FILES, "Files", "nav-files"),
|
||||
Tab(Screen.WALLET, "Wallet", "nav-wallet"),
|
||||
Tab(Screen.ACCOUNT, "Account", "nav-account"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The places to be, always visible.
|
||||
*
|
||||
* A tab bar rather than a menu: a capability hidden behind an overflow is one
|
||||
* most users never find. That was the argument for putting the account here,
|
||||
* and it is the same argument that brought Files and Wallet here on 2026-09-07
|
||||
* — they were reachable only by scrolling most of the way down the account
|
||||
* screen, which is indistinguishable from not shipping them.
|
||||
*
|
||||
* ⚠️ **The kit's artboard is four icons and this is now seven.** That is a
|
||||
* deliberate departure, not an oversight: the earlier note here argued a wallet
|
||||
* "does not earn the slot", and iOS had already decided otherwise. Two apps
|
||||
* disagreeing about where a feature lives is worse than either answer.
|
||||
*/
|
||||
@Composable
|
||||
private fun BottomNav(
|
||||
@@ -630,17 +545,14 @@ 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
|
||||
// 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
|
||||
// bottom of the window. This is the single most recognisable thing about
|
||||
// the design and the app had a plain row of text buttons.
|
||||
// A floating pill, not a full-width strip.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = Kit.pillHeight)
|
||||
.clip(RoundedCornerShape(Kit.pillRadius))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = Kit.pillHeight)
|
||||
.clip(RoundedCornerShape(Kit.pillRadius))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
@@ -648,28 +560,32 @@ private fun BottomNav(
|
||||
val storiesHidden =
|
||||
ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current)
|
||||
for (tab in NavDestinations.tabs(storiesHidden)) {
|
||||
val badge = when (tab.screen) {
|
||||
// The inbox badge lives on Messages — Home is the roster, this
|
||||
// is where new mail is read.
|
||||
Screen.MESSAGES -> phase.unread.size
|
||||
// Missed calls SINCE the tab was last opened — it used to count
|
||||
// every missed call in the log for ever, a badge nothing could
|
||||
// clear. Opening the tab is what "seeing" them means.
|
||||
Screen.CALLS -> model.missedCallsUnseen(phase)
|
||||
// Alerts left the bar 2026-08-28 and iOS never had one; its
|
||||
// unread count badges Account on both phones now.
|
||||
Screen.ACCOUNT -> unreadNotes
|
||||
else -> 0
|
||||
}
|
||||
val (icon, active) = when (tab.screen) {
|
||||
Screen.CONTACTS -> KitIcon.Home to KitIcon.Home
|
||||
Screen.MESSAGES -> KitIcon.Chats to KitIcon.ChatsActive
|
||||
Screen.CALLS -> KitIcon.Call to KitIcon.Call
|
||||
Screen.STORIES -> KitIcon.Stories to KitIcon.Stories
|
||||
Screen.FILES -> KitIcon.Files to KitIcon.Files
|
||||
Screen.WALLET -> KitIcon.Wallet to KitIcon.Wallet
|
||||
else -> KitIcon.Account to KitIcon.Account
|
||||
}
|
||||
val badge =
|
||||
when (tab.screen) {
|
||||
// The inbox badge lives on Messages.
|
||||
Screen.MESSAGES -> phase.unread.size
|
||||
|
||||
// Missed calls SINCE the tab was last opened, it used to count
|
||||
// every missed call in the log for ever, a badge nothing could
|
||||
// clear.
|
||||
Screen.CALLS -> model.missedCallsUnseen(phase)
|
||||
|
||||
// Alerts left the bar 2026-08-28 and iOS never had one; its
|
||||
// unread count badges Account on both phones now.
|
||||
Screen.ACCOUNT -> unreadNotes
|
||||
|
||||
else -> 0
|
||||
}
|
||||
val (icon, active) =
|
||||
when (tab.screen) {
|
||||
Screen.CONTACTS -> KitIcon.Home to KitIcon.Home
|
||||
Screen.MESSAGES -> KitIcon.Chats to KitIcon.ChatsActive
|
||||
Screen.CALLS -> KitIcon.Call to KitIcon.Call
|
||||
Screen.STORIES -> KitIcon.Stories to KitIcon.Stories
|
||||
Screen.FILES -> KitIcon.Files to KitIcon.Files
|
||||
Screen.WALLET -> KitIcon.Wallet to KitIcon.Wallet
|
||||
else -> KitIcon.Account to KitIcon.Account
|
||||
}
|
||||
NavButton(model, phase, tab.screen, icon, active, tab.label, badge, tab.tag)
|
||||
}
|
||||
}
|
||||
@@ -677,16 +593,6 @@ 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
|
||||
* 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
|
||||
* 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
|
||||
* `contentDescription` would be strictly worse than the text it replaced.
|
||||
*/
|
||||
@Composable
|
||||
private fun RowScope.NavButton(
|
||||
@@ -701,45 +607,45 @@ private fun RowScope.NavButton(
|
||||
) {
|
||||
val selected = phase.screen == screen
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(Kit.pillHeight)
|
||||
// 🔴 `navigateTo`, not `showScreen`. Wallet and Files each have to
|
||||
// read the vault before they have anything to draw, so a bare
|
||||
// `showScreen` lands somebody on an empty screen with nothing
|
||||
// loading and no error — see the note on `navigateTo`.
|
||||
.clickable { model.navigateTo(screen) }
|
||||
.semantics {
|
||||
contentDescription =
|
||||
if (badge > 0) "$label, $badge unread" else label
|
||||
}
|
||||
.testTag(tag),
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.height(Kit.pillHeight)
|
||||
// `navigateTo`, not `showScreen`.
|
||||
.clickable { model.navigateTo(screen) }
|
||||
.semantics {
|
||||
contentDescription =
|
||||
if (badge > 0) "$label, $badge unread" else label
|
||||
}.testTag(tag),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
if (selected) activeIcon else icon,
|
||||
contentDescription = null,
|
||||
tint = if (selected) MaterialTheme.colorScheme.primary
|
||||
else Palette.SecondaryText,
|
||||
tint =
|
||||
if (selected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
Palette.SecondaryText
|
||||
},
|
||||
modifier = Modifier.size(Kit.icon),
|
||||
)
|
||||
if (badge > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = Space.md, end = Space.lg)
|
||||
.size(Space.sm)
|
||||
.clip(CircleShape)
|
||||
.background(Palette.Pink),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = Space.md, end = Space.lg)
|
||||
.size(Space.sm)
|
||||
.clip(CircleShape)
|
||||
.background(Palette.Pink),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 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.
|
||||
* Internal rather than private ONLY so the JVM screenshot suite can render it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ColumnScope.SignedIn(
|
||||
@@ -751,28 +657,24 @@ internal fun ColumnScope.SignedIn(
|
||||
var scanning by remember { mutableStateOf(false) }
|
||||
var scannedPrefill by remember { mutableStateOf("") }
|
||||
val scanContext = androidx.compose.ui.platform.LocalContext.current
|
||||
val askCamera = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
|
||||
) { _ ->
|
||||
// Open either way — the dialog explains itself when access is refused.
|
||||
scanning = true
|
||||
}
|
||||
val askCamera =
|
||||
androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts
|
||||
.RequestPermission(),
|
||||
) { _ ->
|
||||
// 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 same way an open chat does. A detail view stacked below a list is
|
||||
// two screens fighting over one scroll.
|
||||
// The contact screen REPLACES the list rather than sitting under it, the
|
||||
// same way an open chat does.
|
||||
val profile = openProfile?.let { id -> phase.buddies.firstOrNull { it.userId == id } }
|
||||
if (profile != null) {
|
||||
ContactDetail(model, phase, profile) { openProfile = null }
|
||||
return
|
||||
}
|
||||
|
||||
// 🔴 **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
|
||||
// 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).
|
||||
// Home is two things now, on purpose: your people, and their stories.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -783,18 +685,22 @@ internal fun ColumnScope.SignedIn(
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// 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.
|
||||
// Scan their Address screen, iOS's home has the same button.
|
||||
BarAction(
|
||||
icon = KitIcon.Scan,
|
||||
label = "Scan an address",
|
||||
tag = "scan-address",
|
||||
onClick = {
|
||||
val has = androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
scanContext, android.Manifest.permission.CAMERA
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (has) scanning = true
|
||||
else askCamera.launch(android.Manifest.permission.CAMERA)
|
||||
val has =
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
scanContext,
|
||||
android.Manifest.permission.CAMERA,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (has) {
|
||||
scanning = true
|
||||
} else {
|
||||
askCamera.launch(android.Manifest.permission.CAMERA)
|
||||
}
|
||||
},
|
||||
)
|
||||
BarAction(
|
||||
@@ -810,12 +716,7 @@ internal fun ColumnScope.SignedIn(
|
||||
onFound = { raw ->
|
||||
scanning = false
|
||||
parseScannedAddress(raw)?.let { card ->
|
||||
// 🔴 **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
|
||||
// the choice of who you just scanned. iOS always binds the
|
||||
// address; this now matches it, same relay or not.
|
||||
// Bind by the fnid, never the handle (audit LOW).
|
||||
scannedPrefill = "${'$'}{card.fnid}@${'$'}{card.relay}"
|
||||
addingContact = true
|
||||
}
|
||||
@@ -862,11 +763,6 @@ 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
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
private fun BarAction(
|
||||
@@ -876,17 +772,16 @@ private fun BarAction(
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(Kit.icon + Space.md)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onClick)
|
||||
.semantics { contentDescription = label }
|
||||
.testTag(tag),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Kit.icon + Space.md)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onClick)
|
||||
.semantics { contentDescription = label }
|
||||
.testTag(tag),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// 🔴 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.
|
||||
// An `Icon`, not a glyph in a text run.
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
@@ -896,7 +791,6 @@ private fun BarAction(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.VaultUnreadable(
|
||||
model: MessengerViewModel,
|
||||
@@ -913,12 +807,11 @@ 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
|
||||
// keeping the device, so it left the unreadable vault in place and the next
|
||||
// restore came straight back here — the only button on a dead-end screen
|
||||
// did nothing but redraw it.
|
||||
Button(onClick = model::discardUnreadableVault,
|
||||
modifier = Modifier.testTag("start-fresh")) {
|
||||
// `discardUnreadableVault`, NOT `signOut`.
|
||||
Button(
|
||||
onClick = model::discardUnreadableVault,
|
||||
modifier = Modifier.testTag("start-fresh"),
|
||||
) {
|
||||
Text("Erase and start fresh")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,32 +9,13 @@ 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
|
||||
* 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
|
||||
* already safe by accident, because [ImagePrep] re-encodes them through a
|
||||
* 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
|
||||
* 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
|
||||
* was, not where it was, and dropping it plays every portrait clip on its side.
|
||||
*/
|
||||
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
|
||||
* @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
|
||||
* caller must then refuse the post rather than fall back to the original.
|
||||
*/
|
||||
fun videoWithoutMetadata(
|
||||
@@ -54,9 +35,7 @@ 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 —
|
||||
// is exactly the kind of passenger this exists to leave behind.
|
||||
// Audio and video only.
|
||||
val trackMap = HashMap<Int, Int>()
|
||||
var bufferSize = DEFAULT_BUFFER
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
@@ -87,9 +66,7 @@ object MediaScrub {
|
||||
info.offset = 0
|
||||
info.size = size
|
||||
info.presentationTimeUs = extractor.sampleTime
|
||||
// ⚠️ 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.
|
||||
// Mapped rather than assigned across.
|
||||
info.flags =
|
||||
if (extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0) {
|
||||
MediaCodec.BUFFER_FLAG_KEY_FRAME
|
||||
|
||||
@@ -4,21 +4,14 @@ 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
|
||||
* the encrypted store being open.
|
||||
*/
|
||||
class MediaSettings(context: Context) {
|
||||
|
||||
class MediaSettings(
|
||||
context: Context,
|
||||
) {
|
||||
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* 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
|
||||
* photo that failed to send would be worse than a slightly softer one.
|
||||
*/
|
||||
var fullQuality: Boolean
|
||||
get() = prefs.getBoolean(FULL_QUALITY, false)
|
||||
@@ -26,18 +19,6 @@ 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
|
||||
* constant, so the one thing people actually want to change about a story
|
||||
* — how long it lasts — was the one thing they could not.
|
||||
*
|
||||
* ⚠️ 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
|
||||
* corrupted preference, must not post a story that expires before anybody
|
||||
* sees it — which is what a raw 0 would do.
|
||||
*/
|
||||
var storyLifetimeHours: Int
|
||||
get() {
|
||||
@@ -51,32 +32,16 @@ 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
|
||||
* keeps something the interface told everybody would disappear, so it
|
||||
* cannot be the quiet default — and it only ever holds stories this
|
||||
* account posted.
|
||||
*/
|
||||
var storyArchiveEnabled: Boolean
|
||||
get() = prefs.getBoolean("stories.archiveEnabled", false)
|
||||
set(value) { prefs.edit().putBoolean("stories.archiveEnabled", value).apply() }
|
||||
set(value) {
|
||||
prefs.edit().putBoolean("stories.archiveEnabled", value).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
* transparency log has no log to prove anything against, so enforcing it
|
||||
* everywhere would refuse every peer on those deployments.
|
||||
*/
|
||||
var requireKeyLog: Boolean
|
||||
get() = prefs.getBoolean(REQUIRE_KEY_LOG, false)
|
||||
@@ -84,11 +49,6 @@ class MediaSettings(context: Context) {
|
||||
|
||||
/**
|
||||
* Generate a preview card when sending a link.
|
||||
*
|
||||
* ⚠️ 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
|
||||
* what the setting is for.
|
||||
*/
|
||||
var linkPreviews: Boolean
|
||||
get() = prefs.getBoolean(LINK_PREVIEWS, true)
|
||||
@@ -100,32 +60,32 @@ 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"
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
var requireSealedSending: Boolean
|
||||
get() = prefs.getBoolean("privacy.requireSealed", false)
|
||||
set(value) { prefs.edit().putBoolean("privacy.requireSealed", value).apply() }
|
||||
set(value) {
|
||||
prefs.edit().putBoolean("privacy.requireSealed", value).apply()
|
||||
}
|
||||
|
||||
/** Counters for how often sealing degraded (ios#16). */
|
||||
var sealingRecord: io.flamenet.e2e.SealingRecord
|
||||
get() = io.flamenet.e2e.SealingRecord(
|
||||
sealed = prefs.getInt("privacy.sealing.sealed", 0),
|
||||
unsealed = prefs.getInt("privacy.sealing.unsealed", 0),
|
||||
lastUnsealedAt = prefs.getLong("privacy.sealing.lastUnsealedAt", 0L))
|
||||
get() =
|
||||
io.flamenet.e2e.SealingRecord(
|
||||
sealed = prefs.getInt("privacy.sealing.sealed", 0),
|
||||
unsealed = prefs.getInt("privacy.sealing.unsealed", 0),
|
||||
lastUnsealedAt = prefs.getLong("privacy.sealing.lastUnsealedAt", 0L),
|
||||
)
|
||||
set(value) {
|
||||
prefs.edit()
|
||||
prefs
|
||||
.edit()
|
||||
.putInt("privacy.sealing.sealed", value.sealed)
|
||||
.putInt("privacy.sealing.unsealed", value.unsealed)
|
||||
.putLong("privacy.sealing.lastUnsealedAt", value.lastUnsealedAt)
|
||||
|
||||
@@ -10,9 +10,9 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -22,8 +22,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -35,12 +35,8 @@ import androidx.compose.ui.unit.sp
|
||||
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
|
||||
* split iOS ships, so the two apps stay one product.
|
||||
* The kit's `07 Messages-Listing` as its own tab: large title, search box, the
|
||||
* Recent circles row, then a flat list of conversations.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.MessagesScreen(
|
||||
@@ -53,16 +49,19 @@ fun ColumnScope.MessagesScreen(
|
||||
var scanning by remember { mutableStateOf(false) }
|
||||
var scannedPrefill by remember { mutableStateOf("") }
|
||||
val scanContext = androidx.compose.ui.platform.LocalContext.current
|
||||
val askCamera = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
|
||||
) { _ ->
|
||||
// Open either way — the dialog explains itself when access is refused.
|
||||
scanning = true
|
||||
}
|
||||
val askCamera =
|
||||
androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts
|
||||
.RequestPermission(),
|
||||
) { _ ->
|
||||
// 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,
|
||||
@@ -77,26 +76,32 @@ fun ColumnScope.MessagesScreen(
|
||||
KitIcon.Scan,
|
||||
contentDescription = "Scan an address",
|
||||
tint = Palette.Accent,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clickable {
|
||||
val has = androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
scanContext, android.Manifest.permission.CAMERA
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (has) scanning = true
|
||||
else askCamera.launch(android.Manifest.permission.CAMERA)
|
||||
}
|
||||
.testTag("messages-scan"),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(24.dp)
|
||||
.clickable {
|
||||
val has =
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
scanContext,
|
||||
android.Manifest.permission.CAMERA,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (has) {
|
||||
scanning = true
|
||||
} else {
|
||||
askCamera.launch(android.Manifest.permission.CAMERA)
|
||||
}
|
||||
}.testTag("messages-scan"),
|
||||
)
|
||||
Spacer(Modifier.width(Space.md))
|
||||
androidx.compose.material3.Icon(
|
||||
if (addingContact) KitIcon.Close else KitIcon.Add,
|
||||
contentDescription = "Add contact",
|
||||
tint = Palette.Accent,
|
||||
modifier = Modifier
|
||||
.size(26.dp)
|
||||
.clickable { addingContact = !addingContact }
|
||||
.testTag("messages-add-contact"),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(26.dp)
|
||||
.clickable { addingContact = !addingContact }
|
||||
.testTag("messages-add-contact"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,9 +110,14 @@ fun ColumnScope.MessagesScreen(
|
||||
onFound = { raw ->
|
||||
scanning = false
|
||||
parseScannedAddress(raw)?.let { card ->
|
||||
scannedPrefill = if (card.handle != null &&
|
||||
card.relay.equals(phase.relay, ignoreCase = true)
|
||||
) card.handle else "${card.fnid}@${card.relay}"
|
||||
scannedPrefill =
|
||||
if (card.handle != null &&
|
||||
card.relay.equals(phase.relay, ignoreCase = true)
|
||||
) {
|
||||
card.handle
|
||||
} else {
|
||||
"${card.fnid}@${card.relay}"
|
||||
}
|
||||
addingContact = true
|
||||
}
|
||||
},
|
||||
@@ -132,9 +142,7 @@ 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
|
||||
// already covered by the list underneath.
|
||||
// Above the conversation list.
|
||||
MessageHits(model, query.value)
|
||||
|
||||
BuddyCirclesRow(model, phase)
|
||||
@@ -142,10 +150,7 @@ 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
|
||||
// (and only when there is something to head). A second one here printed
|
||||
// "GROUPS" twice.
|
||||
// The kit's listing holds every conversation, groups included.
|
||||
GroupsSection(model, phase, query.value, creatingGroup.value) { creatingGroup.value = it }
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
val showNewChat = remember { mutableStateOf(false) }
|
||||
@@ -158,20 +163,22 @@ fun ColumnScope.MessagesScreen(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Palette.SecondaryText,
|
||||
letterSpacing = androidx.compose.ui.unit.TextUnit(
|
||||
0.8f, androidx.compose.ui.unit.TextUnitType.Sp),
|
||||
letterSpacing =
|
||||
androidx.compose.ui.unit
|
||||
.TextUnit(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,
|
||||
contentDescription = "New chat",
|
||||
tint = Palette.Accent,
|
||||
modifier = Modifier
|
||||
.size(22.dp)
|
||||
.clickable { showNewChat.value = true }
|
||||
.testTag("new-chat"),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(22.dp)
|
||||
.clickable { showNewChat.value = true }
|
||||
.testTag("new-chat"),
|
||||
)
|
||||
}
|
||||
if (showNewChat.value) {
|
||||
@@ -180,21 +187,23 @@ 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 ->
|
||||
val rowName = buddy.displayName.ifBlank { buddy.handle }
|
||||
.ifBlank { "user ${buddy.userId}" }
|
||||
val rowName =
|
||||
buddy.displayName
|
||||
.ifBlank { buddy.handle }
|
||||
.ifBlank { "user ${buddy.userId}" }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
showNewChat.value = false
|
||||
model.openChat(buddy.userId, rowName)
|
||||
}
|
||||
.padding(vertical = 10.dp)
|
||||
.testTag("new-chat-${buddy.userId}"),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
showNewChat.value = false
|
||||
model.openChat(buddy.userId, rowName)
|
||||
}.padding(vertical = 10.dp)
|
||||
.testTag("new-chat-${buddy.userId}"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Avatar(
|
||||
@@ -217,28 +226,27 @@ fun ColumnScope.MessagesScreen(
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(
|
||||
onClick = { showNewChat.value = false }) { Text("Cancel") }
|
||||
androidx.compose.material3.TextButton(onClick = { showNewChat.value = false }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val shown = phase.buddies
|
||||
.filter {
|
||||
query.value.isBlank() ||
|
||||
it.displayName.contains(query.value, true) ||
|
||||
it.handle.contains(query.value, true)
|
||||
}
|
||||
.sortedWith(
|
||||
compareByDescending<io.flamenet.client.MessengerApi.Buddy> {
|
||||
Vault.threadForUser(it.userId) in phase.unread
|
||||
}.thenBy { it.displayName.ifBlank { it.handle } }
|
||||
)
|
||||
val shown =
|
||||
phase.buddies
|
||||
.filter {
|
||||
query.value.isBlank() ||
|
||||
it.displayName.contains(query.value, true) ||
|
||||
it.handle.contains(query.value, true)
|
||||
}.sortedWith(
|
||||
compareByDescending<io.flamenet.client.MessengerApi.Buddy> {
|
||||
Vault.threadForUser(it.userId) in phase.unread
|
||||
}.thenBy { it.displayName.ifBlank { it.handle } },
|
||||
)
|
||||
|
||||
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."
|
||||
},
|
||||
@@ -250,15 +258,18 @@ fun ColumnScope.MessagesScreen(
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().testTag("conversations")) {
|
||||
shown.forEach { buddy ->
|
||||
val name = buddy.displayName.ifBlank { buddy.handle }
|
||||
.ifBlank { "user ${buddy.userId}" }
|
||||
val name =
|
||||
buddy.displayName
|
||||
.ifBlank { buddy.handle }
|
||||
.ifBlank { "user ${buddy.userId}" }
|
||||
val unread = Vault.threadForUser(buddy.userId) in phase.unread
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { model.openChat(buddy.userId, name) }
|
||||
.padding(vertical = Kit.rowGap / 2)
|
||||
.testTag("conversation-${buddy.userId}"),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { model.openChat(buddy.userId, name) }
|
||||
.padding(vertical = Kit.rowGap / 2)
|
||||
.testTag("conversation-${buddy.userId}"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
|
||||
) {
|
||||
@@ -279,8 +290,12 @@ fun ColumnScope.MessagesScreen(
|
||||
Text(
|
||||
if (unread) "New messages" else buddy.presence,
|
||||
fontSize = 12.sp,
|
||||
color = if (unread) MaterialTheme.colorScheme.primary
|
||||
else Palette.SecondaryText,
|
||||
color =
|
||||
if (unread) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
Palette.SecondaryText
|
||||
},
|
||||
)
|
||||
}
|
||||
if (unread) UnreadDot()
|
||||
@@ -294,22 +309,22 @@ fun ColumnScope.MessagesScreen(
|
||||
@Composable
|
||||
private fun UnreadDot() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* history in memory.
|
||||
*/
|
||||
@Composable
|
||||
private fun MessageHits(model: MessengerViewModel, query: String) {
|
||||
private fun MessageHits(
|
||||
model: MessengerViewModel,
|
||||
query: String,
|
||||
) {
|
||||
var hits by remember { mutableStateOf<List<io.flamenet.e2e.MessageSearch.Hit>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(query) {
|
||||
@@ -333,9 +348,10 @@ private fun MessageHits(model: MessengerViewModel, query: String) {
|
||||
)
|
||||
for (hit in hits) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = Space.md, vertical = Space.sm)
|
||||
.testTag("search-hit")
|
||||
.testTag("search-hit"),
|
||||
) {
|
||||
Text(
|
||||
hit.snippet,
|
||||
|
||||
@@ -24,31 +24,8 @@ 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
|
||||
* 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
|
||||
* 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
|
||||
* 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`,
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
class MessengerService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val ONGOING_ID = 1
|
||||
private const val CHANNEL_ONGOING = "flamenet.ongoing"
|
||||
@@ -61,7 +38,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) {
|
||||
@@ -85,10 +62,13 @@ class MessengerService : Service() {
|
||||
createChannels()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// ⚠️ startForeground within a few seconds of onStartCommand or Android
|
||||
override fun onStartCommand(
|
||||
intent: Intent?,
|
||||
flags: Int,
|
||||
startId: Int,
|
||||
): Int {
|
||||
// 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()
|
||||
|
||||
if (loop?.isActive == true) return START_STICKY
|
||||
@@ -107,21 +87,30 @@ class MessengerService : Service() {
|
||||
}
|
||||
|
||||
private fun startInForeground() {
|
||||
val open = PendingIntent.getActivity(
|
||||
this, 0, Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
val notice = NotificationCompat.Builder(this, CHANNEL_ONGOING)
|
||||
.setContentTitle("Flamenet is receiving")
|
||||
.setContentText("Messages arrive without Google or Apple in the middle.")
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setContentIntent(open)
|
||||
.build()
|
||||
val open =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val notice =
|
||||
NotificationCompat
|
||||
.Builder(this, CHANNEL_ONGOING)
|
||||
.setContentTitle("Flamenet is receiving")
|
||||
.setContentText("Messages arrive without Google or Apple in the middle.")
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setContentIntent(open)
|
||||
.build()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(ONGOING_ID, notice,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING)
|
||||
startForeground(
|
||||
ONGOING_ID,
|
||||
notice,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING,
|
||||
)
|
||||
} else {
|
||||
startForeground(ONGOING_ID, notice)
|
||||
}
|
||||
@@ -129,220 +118,216 @@ 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
|
||||
* vault is the durable half and both sides read it the same way.
|
||||
*/
|
||||
private fun openSession(): Session? = runCatching {
|
||||
val vault: Vault = KeystoreVault.open(applicationContext)
|
||||
val identity = vault.loadIdentity() ?: return null
|
||||
val credential = vault.loadCredential() ?: return null
|
||||
val relay = RelayClient(
|
||||
credential.relayBaseUrl, credential.username, credential.appPassword)
|
||||
relay.useRelayAuth(identity)
|
||||
Session(
|
||||
handle = credential.username,
|
||||
userId = credential.userId,
|
||||
identity = identity,
|
||||
vault = vault,
|
||||
relay = relay,
|
||||
api = MessengerApi(
|
||||
credential.relayBaseUrl, credential.username, credential.appPassword),
|
||||
)
|
||||
}.getOrNull()
|
||||
private fun openSession(): Session? =
|
||||
runCatching {
|
||||
val vault: Vault = KeystoreVault.open(applicationContext)
|
||||
val identity = vault.loadIdentity() ?: return null
|
||||
val credential = vault.loadCredential() ?: return null
|
||||
val relay =
|
||||
RelayClient(credential.relayBaseUrl, credential.username, credential.appPassword)
|
||||
relay.useRelayAuth(identity)
|
||||
Session(
|
||||
handle = credential.username,
|
||||
userId = credential.userId,
|
||||
identity = identity,
|
||||
vault = vault,
|
||||
relay = relay,
|
||||
api =
|
||||
MessengerApi(credential.relayBaseUrl, credential.username, credential.appPassword),
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
private suspend fun run() {
|
||||
val s = openSession() ?: run {
|
||||
// Nothing to receive for — signed out, or the vault is unreadable.
|
||||
// Stop rather than sit in the notification shade doing nothing.
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
val s =
|
||||
openSession() ?: run {
|
||||
// Nothing to receive for, signed out, or the vault is
|
||||
// unreadable.
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
session = s
|
||||
// ⚠️ The constant, not the literal: the wire value is "call-offer",
|
||||
// and a bare "offer" matches nothing and rings for no call ever.
|
||||
// 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
|
||||
// UI answers from PendingIncomingCall or not at all.
|
||||
// Offers old enough that the caller gave up are notified
|
||||
// as missed, not parked as answerable.
|
||||
// Park the offer BEFORE notifying.
|
||||
if (sentAt > 0 &&
|
||||
System.currentTimeMillis() - sentAt > CallManager.DIAL_TIMEOUT_MS) {
|
||||
System.currentTimeMillis() - sentAt > CallManager.DIAL_TIMEOUT_MS
|
||||
) {
|
||||
runCatching { s.recordCall(fromUser, incoming = true, connected = false) }
|
||||
} else {
|
||||
PendingIncomingCall.park(fromUser, signal, sentAt)
|
||||
notifyCall(nameFor(fromUser, s))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// would the notification, which is now `ongoing` and will
|
||||
// NOT go away on its own.
|
||||
// The caller hung up before anyone opened the app.
|
||||
PendingIncomingCall.clear(signal.callId)
|
||||
clearCallNotification()
|
||||
}
|
||||
else -> Unit
|
||||
|
||||
else -> {
|
||||
Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val touched = runCatching { s.receive() }.getOrDefault(emptySet())
|
||||
if (touched.isNotEmpty()) notifyMessages(touched.size)
|
||||
// 🔴 Reply reminders fire from THIS loop, after `receive()`, and
|
||||
// the order matters: an inbound message clears the reminder inside
|
||||
// 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 —
|
||||
// which is the one thing that would teach people to ignore them.
|
||||
// reminder before it can go off.
|
||||
fireDueReplyReminders(s)
|
||||
// 🔴 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.
|
||||
// An unanswered ring has to stop ringing.
|
||||
expireStaleRing()
|
||||
// ⚠️ 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.
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* not wait on the network when it already knows the answer.
|
||||
*/
|
||||
private var names: Map<Long, String> = emptyMap()
|
||||
|
||||
private fun nameFor(userId: Long, s: Session): String {
|
||||
private fun nameFor(
|
||||
userId: Long,
|
||||
s: Session,
|
||||
): String {
|
||||
names[userId]?.let { return it }
|
||||
names = runCatching {
|
||||
s.roster().associate { it.userId to it.displayName.ifBlank { it.handle } }
|
||||
}.getOrDefault(names)
|
||||
names =
|
||||
runCatching {
|
||||
s.roster().associate { it.userId to it.displayName.ifBlank { it.handle } }
|
||||
}.getOrDefault(names)
|
||||
// Falls back to something answerable rather than a bare number.
|
||||
return names[userId] ?: "Someone"
|
||||
}
|
||||
|
||||
private fun notifyMessages(threads: Int, from: String? = null) {
|
||||
private fun notifyMessages(
|
||||
threads: Int,
|
||||
from: String? = null,
|
||||
) {
|
||||
val settings = NotificationSettings(this)
|
||||
// 🔴 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.
|
||||
// The master switch and the per-kind one, both checked before a
|
||||
// notification is built rather than after.
|
||||
if (!settings.enabled || !settings.newMessages) return
|
||||
|
||||
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
|
||||
// notification is drawn on this device from locally decrypted
|
||||
// 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
|
||||
// nobody acquires a name on their lock screen by upgrading.
|
||||
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.
|
||||
val text = settings.messageText(threads, from)
|
||||
notify(CHANNEL_MESSAGES, 2, text, NotificationCompat.PRIORITY_HIGH, open,
|
||||
number = threads)
|
||||
notify(
|
||||
CHANNEL_MESSAGES,
|
||||
2,
|
||||
text,
|
||||
NotificationCompat.PRIORITY_HIGH,
|
||||
open,
|
||||
number = threads,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about conversations nobody answered, and forget them.
|
||||
*
|
||||
* ⚠️ 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) {
|
||||
val due = runCatching { s.dueReplyReminders() }.getOrDefault(emptyMap())
|
||||
if (due.isEmpty()) return
|
||||
due.keys.forEach { threadId -> runCatching { s.vault.saveReplyReminder(threadId, 0L) } }
|
||||
val open = PendingIntent.getActivity(
|
||||
this, 4, Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
// Same privacy rule as a message notification, and the same setting:
|
||||
val open =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
4,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
// Same privacy rule as a message notification, and the same setting.
|
||||
// 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
|
||||
// `user:<id>`, and groups and cross-relay peers use their own prefixes
|
||||
// — 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) }
|
||||
?.toLongOrNull()
|
||||
?.let { userId -> runCatching { nameFor(userId, s) }.getOrNull() }
|
||||
// Only a one-to-one thread can be named.
|
||||
val only =
|
||||
due.keys
|
||||
.singleOrNull()
|
||||
?.removePrefix("user:")
|
||||
?.takeIf { it.all(Char::isDigit) }
|
||||
?.toLongOrNull()
|
||||
?.let { userId -> runCatching { nameFor(userId, s) }.getOrNull() }
|
||||
val text = settings.replyReminderText(due.size, only)
|
||||
notify(CHANNEL_MESSAGES, 5, text, NotificationCompat.PRIORITY_DEFAULT, open)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
* lose a call.
|
||||
*/
|
||||
private fun notifyCall(from: String) {
|
||||
val full = PendingIntent.getActivity(
|
||||
this, 2,
|
||||
Intent(this, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
.putExtra(EXTRA_ANSWER_CALL, true),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
val full =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
2,
|
||||
Intent(this, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
.putExtra(EXTRA_ANSWER_CALL, true),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
val decline = PendingIntent.getBroadcast(
|
||||
this, 6, Intent(this, CallActionReceiver::class.java).setAction(ACTION_DECLINE),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
val decline =
|
||||
PendingIntent.getBroadcast(
|
||||
this,
|
||||
6,
|
||||
Intent(this, CallActionReceiver::class.java).setAction(ACTION_DECLINE),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
val caller = androidx.core.app.Person.Builder().setName(from).setImportant(true).build()
|
||||
val notice = NotificationCompat.Builder(this, CHANNEL_CALLS)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
// Ongoing: a ringing call is not something to swipe away by
|
||||
// accident while reaching for the torch.
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setFullScreenIntent(full, true)
|
||||
.setStyle(NotificationCompat.CallStyle.forIncomingCall(caller, decline, full))
|
||||
.build()
|
||||
val caller =
|
||||
androidx.core.app.Person
|
||||
.Builder()
|
||||
.setName(from)
|
||||
.setImportant(true)
|
||||
.build()
|
||||
val notice =
|
||||
NotificationCompat
|
||||
.Builder(this, CHANNEL_CALLS)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
// Ongoing: a ringing call is not something to swipe away by
|
||||
// accident while reaching for the torch.
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setFullScreenIntent(full, true)
|
||||
.setStyle(NotificationCompat.CallStyle.forIncomingCall(caller, decline, full))
|
||||
.build()
|
||||
|
||||
runCatching {
|
||||
androidx.core.app.NotificationManagerCompat.from(this).notify(3, notice)
|
||||
androidx.core.app.NotificationManagerCompat
|
||||
.from(this)
|
||||
.notify(3, notice)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) }
|
||||
runCatching {
|
||||
androidx.core.app.NotificationManagerCompat
|
||||
.from(this)
|
||||
.cancel(3)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
private fun expireStaleRing() {
|
||||
if (PendingIncomingCall.isStale()) {
|
||||
@@ -351,22 +336,29 @@ class MessengerService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(channel: String, id: Int, text: String, priority: Int,
|
||||
open: PendingIntent, category: String? = null, number: Int = 0) {
|
||||
val n = NotificationCompat.Builder(this, channel)
|
||||
.setContentTitle("Flamenet")
|
||||
.setContentText(text)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setPriority(priority)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(open)
|
||||
.also { b -> category?.let { b.setCategory(it) } }
|
||||
// The launcher badge. A dot on every launcher; the count itself on
|
||||
// launchers that show one. Same privacy rule as the text: a number,
|
||||
// never a name or a preview.
|
||||
.also { b -> if (number > 0) b.setNumber(number) }
|
||||
.setBadgeIconType(NotificationCompat.BADGE_ICON_SMALL)
|
||||
.build()
|
||||
private fun notify(
|
||||
channel: String,
|
||||
id: Int,
|
||||
text: String,
|
||||
priority: Int,
|
||||
open: PendingIntent,
|
||||
category: String? = null,
|
||||
number: Int = 0,
|
||||
) {
|
||||
val n =
|
||||
NotificationCompat
|
||||
.Builder(this, channel)
|
||||
.setContentTitle("Flamenet")
|
||||
.setContentText(text)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setPriority(priority)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(open)
|
||||
.also { b -> category?.let { b.setCategory(it) } }
|
||||
// The launcher badge.
|
||||
.also { b -> if (number > 0) b.setNumber(number) }
|
||||
.setBadgeIconType(NotificationCompat.BADGE_ICON_SMALL)
|
||||
.build()
|
||||
runCatching {
|
||||
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
|
||||
.notify(id, n)
|
||||
@@ -376,20 +368,32 @@ class MessengerService : Service() {
|
||||
private fun createChannels() {
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ONGOING, "Receiving",
|
||||
NotificationManager.IMPORTANCE_MIN).apply {
|
||||
NotificationChannel(
|
||||
CHANNEL_ONGOING,
|
||||
"Receiving",
|
||||
NotificationManager.IMPORTANCE_MIN,
|
||||
).apply {
|
||||
description = "The notice Android requires while Flamenet is receiving."
|
||||
// The always-on service notice must not put a dot on the icon:
|
||||
// The always-on service notice must not put a dot on the icon.
|
||||
// a permanent badge reads as "you have unread mail", for ever.
|
||||
setShowBadge(false)
|
||||
})
|
||||
},
|
||||
)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_MESSAGES, "Messages",
|
||||
NotificationManager.IMPORTANCE_HIGH).apply { setShowBadge(true) })
|
||||
NotificationChannel(
|
||||
CHANNEL_MESSAGES,
|
||||
"Messages",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply { setShowBadge(true) },
|
||||
)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_CALLS, "Calls",
|
||||
NotificationManager.IMPORTANCE_HIGH).apply {
|
||||
NotificationChannel(
|
||||
CHANNEL_CALLS,
|
||||
"Calls",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
setBypassDnd(false)
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,55 +6,49 @@ import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* Strips metadata from an image on its way out, whichever door it left by.
|
||||
*
|
||||
* 🔴 **A photo sent as a *file* used to keep its GPS; sent as a *photo* it did
|
||||
* not** (android#17, ios#69). The picker path runs through [ImagePrep], which
|
||||
* decodes to a bitmap and re-encodes — the output is pixels, with no container
|
||||
* to carry EXIF. `Session.sendFile` handed the stored bytes straight to
|
||||
* `sendAttachment`. Somebody who had learned "Flamenet strips location from my
|
||||
* photos" had learned something true of one button and false of the one beside
|
||||
* it, and the safer one looked like the shortcut.
|
||||
*
|
||||
* ⚠️ **Sniffed, not trusted.** The vault stores no MIME type and a file name is
|
||||
* a guess in both directions: `holiday.txt` can be a JPEG and `photo.jpg` can be
|
||||
* text. Deciding by extension would strip the wrong things and miss the right
|
||||
* ones.
|
||||
*
|
||||
* ⚠️ **Only images are touched.** "Send this exact file" is what a file send
|
||||
* means, and silently rewriting somebody's PDF or spreadsheet would be its own
|
||||
* surprise. Anything not recognised goes byte-for-byte.
|
||||
*
|
||||
* Matches `MetadataScrub` on iOS, format list included.
|
||||
*/
|
||||
object MetadataScrub {
|
||||
|
||||
enum class ImageKind { JPEG, PNG, HEIC, TIFF, WEBP, GIF }
|
||||
|
||||
/** What happened, so the caller can say so rather than guess. */
|
||||
data class Result(val bytes: ByteArray, val strippedKind: ImageKind?) {
|
||||
data class Result(
|
||||
val bytes: ByteArray,
|
||||
val strippedKind: ImageKind?,
|
||||
) {
|
||||
// ByteArray in a data class needs these, or equality compares identity.
|
||||
override fun equals(other: Any?): Boolean =
|
||||
this === other || (other is Result &&
|
||||
bytes.contentEquals(other.bytes) && strippedKind == other.strippedKind)
|
||||
this === other || (
|
||||
other is Result &&
|
||||
bytes.contentEquals(other.bytes) && strippedKind == other.strippedKind
|
||||
)
|
||||
|
||||
override fun hashCode(): Int = 31 * bytes.contentHashCode() + (strippedKind?.hashCode() ?: 0)
|
||||
}
|
||||
|
||||
/** Pure, and the reason this is testable without a device. */
|
||||
fun kind(of: ByteArray): ImageKind? {
|
||||
fun at(i: Int): Int? = if (i < of.size) of[i].toInt() and 0xFF else null
|
||||
fun matches(bytes: List<Int>, offset: Int = 0): Boolean =
|
||||
bytes.withIndex().all { (i, b) -> at(offset + i) == b }
|
||||
|
||||
fun matches(
|
||||
bytes: List<Int>,
|
||||
offset: Int = 0,
|
||||
): Boolean = bytes.withIndex().all { (i, b) -> at(offset + i) == b }
|
||||
|
||||
if (matches(listOf(0xFF, 0xD8, 0xFF))) return ImageKind.JPEG
|
||||
if (matches(listOf(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A))) return ImageKind.PNG
|
||||
if (matches(listOf(0x47, 0x49, 0x46, 0x38))) return ImageKind.GIF
|
||||
// TIFF, either byte order. ⚠️ Also the container many RAW files use,
|
||||
// which is exactly why it should be recognised rather than sent raw.
|
||||
// TIFF, either byte order.
|
||||
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.
|
||||
matches(listOf(0x4D, 0x4D, 0x00, 0x2A))
|
||||
) {
|
||||
return ImageKind.TIFF
|
||||
}
|
||||
// RIFF….WEBP, the size field sits between the two markers.
|
||||
if (matches(listOf(0x52, 0x49, 0x46, 0x46)) &&
|
||||
matches(listOf(0x57, 0x45, 0x42, 0x50), 8)) return ImageKind.WEBP
|
||||
matches(listOf(0x57, 0x45, 0x42, 0x50), 8)
|
||||
) {
|
||||
return ImageKind.WEBP
|
||||
}
|
||||
// ISO-BMFF: "ftyp" at offset 4, then a HEIF brand.
|
||||
if (matches(listOf(0x66, 0x74, 0x79, 0x70), 4)) {
|
||||
val brand = (8..11).mapNotNull { at(it) }.map { it.toChar() }.joinToString("")
|
||||
@@ -68,33 +62,23 @@ 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
|
||||
* XMP, IPTC, MakerNote and thumbnail streams, several of which carry
|
||||
* location independently. Decoding to a bitmap and writing out again is the
|
||||
* only version of this with no list to keep up to date.
|
||||
*
|
||||
* ⚠️ Undecodable bytes are returned as they came. A file that will not
|
||||
* decode is not necessarily a file with location in it, and losing
|
||||
* somebody's data to a scrubber is a worse failure than the one being
|
||||
* fixed.
|
||||
*/
|
||||
fun scrub(data: ByteArray): Result {
|
||||
val kind = kind(data) ?: return Result(data, null)
|
||||
val raw = runCatching { BitmapFactory.decodeByteArray(data, 0, data.size) }.getOrNull()
|
||||
?: return Result(data, null)
|
||||
// 🔴 This function's whole job is to remove the EXIF block — which
|
||||
// holds the GPS coordinates AND the tag saying which way is up. Turning
|
||||
// the pixels first is what keeps the second from being collateral
|
||||
// damage of removing the first. See ImagePrep.orientedFrom.
|
||||
val raw =
|
||||
runCatching { BitmapFactory.decodeByteArray(data, 0, data.size) }.getOrNull()
|
||||
?: return Result(data, null)
|
||||
// This function's whole job is to remove the EXIF block, which holds
|
||||
// the GPS coordinates AND the tag saying which way is up.
|
||||
val bitmap = ImagePrep.orientedFrom(data, raw)
|
||||
val out = ByteArrayOutputStream()
|
||||
// PNG where a lossy pass would be a second surprise; JPEG otherwise, at
|
||||
// a quality high enough not to be visible.
|
||||
val format = when (kind) {
|
||||
ImageKind.PNG, ImageKind.GIF, ImageKind.TIFF, ImageKind.WEBP -> Bitmap.CompressFormat.PNG
|
||||
ImageKind.JPEG, ImageKind.HEIC -> Bitmap.CompressFormat.JPEG
|
||||
}
|
||||
val format =
|
||||
when (kind) {
|
||||
ImageKind.PNG, ImageKind.GIF, ImageKind.TIFF, ImageKind.WEBP -> Bitmap.CompressFormat.PNG
|
||||
ImageKind.JPEG, ImageKind.HEIC -> Bitmap.CompressFormat.JPEG
|
||||
}
|
||||
val ok = runCatching { bitmap.compress(format, 95, out) }.getOrDefault(false)
|
||||
bitmap.recycle()
|
||||
return if (ok && out.size() > 0) Result(out.toByteArray(), kind) else Result(data, null)
|
||||
|
||||
@@ -2,12 +2,11 @@ package io.flamenet.messenger.app
|
||||
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
@@ -17,6 +16,7 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
@@ -30,18 +30,6 @@ 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 —
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.NetworkSection(
|
||||
@@ -54,21 +42,19 @@ fun ColumnScope.NetworkSection(
|
||||
var stored by remember { mutableStateOf(settings.bridgeLine) }
|
||||
// Bumped on save/remove so the live status below re-reads.
|
||||
var tick by remember { mutableIntStateOf(0) }
|
||||
// Who has been handed the line this sitting. A confirmation, not a record:
|
||||
// it is not persisted, because whether somebody still has a working bridge
|
||||
// is not something this phone can know.
|
||||
// Who has been handed the line this sitting.
|
||||
var sharedWith by remember { mutableStateOf(setOf<Long>()) }
|
||||
|
||||
@Suppress("UNUSED_EXPRESSION") tick
|
||||
@Suppress("UNUSED_EXPRESSION")
|
||||
tick
|
||||
|
||||
val parsed = if (draft.isBlank()) null else BridgeLine.parse(draft)
|
||||
val isUnchanged = draft.trim() == (stored ?: "")
|
||||
|
||||
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
|
||||
// not there leaves the app connecting directly, and only this says so.
|
||||
// Reads the handshake counter and the bridge in force, never the stored
|
||||
// line.
|
||||
Text(
|
||||
statusLine(),
|
||||
fontSize = 13.sp,
|
||||
@@ -76,13 +62,7 @@ fun ColumnScope.NetworkSection(
|
||||
modifier = Modifier.testTag("obfs4-status"),
|
||||
)
|
||||
|
||||
// 🔴 **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
|
||||
// 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
|
||||
// button, pressed by the person it was sent to.
|
||||
// Offers from contacts, and the reason they are a list and not a setting.
|
||||
val offers = remember(tick) { model?.bridgeOffers().orEmpty() }
|
||||
if (offers.isNotEmpty()) {
|
||||
Spacer(Modifier.height(Space.md))
|
||||
@@ -94,10 +74,7 @@ fun ColumnScope.NetworkSection(
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.testTag("obfs4-offer-${offer.fromUser}"),
|
||||
)
|
||||
// ⚠️ 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.
|
||||
// The line is shown, not hidden behind "Accept".
|
||||
Text(
|
||||
offer.line,
|
||||
fontSize = 11.sp,
|
||||
@@ -105,18 +82,23 @@ fun ColumnScope.NetworkSection(
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
when (val parsed = BridgeLine.parse(offer.line)) {
|
||||
is BridgeLineResult.Bad -> Text(
|
||||
// An offer that cannot be used says why, rather than
|
||||
// failing when it is accepted.
|
||||
parsed.problem.message,
|
||||
fontSize = 12.sp,
|
||||
color = Palette.Danger,
|
||||
)
|
||||
is BridgeLineResult.Ok -> Text(
|
||||
"Reads as ${parsed.bridge.host} port ${parsed.bridge.port}",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
is BridgeLineResult.Bad -> {
|
||||
Text(
|
||||
// An offer that cannot be used says why, rather than
|
||||
// failing when it is accepted.
|
||||
parsed.problem.message,
|
||||
fontSize = 12.sp,
|
||||
color = Palette.Danger,
|
||||
)
|
||||
}
|
||||
|
||||
is BridgeLineResult.Ok -> {
|
||||
Text(
|
||||
"Reads as ${parsed.bridge.host} port ${parsed.bridge.port}",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -137,7 +119,10 @@ fun ColumnScope.NetworkSection(
|
||||
Spacer(Modifier.width(Space.sm))
|
||||
KitSecondaryButton(
|
||||
text = "Dismiss",
|
||||
onClick = { model?.dismissBridgeOffer(offer.fromUser); tick++ },
|
||||
onClick = {
|
||||
model?.dismissBridgeOffer(offer.fromUser)
|
||||
tick++
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "obfs4-offer-dismiss-${offer.fromUser}",
|
||||
)
|
||||
@@ -157,15 +142,18 @@ fun ColumnScope.NetworkSection(
|
||||
modifier = Modifier.fillMaxWidth().testTag("obfs4-bridge-line"),
|
||||
)
|
||||
|
||||
// ⚠️ 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.
|
||||
// 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(
|
||||
parsed.problem.message,
|
||||
fontSize = 12.sp,
|
||||
color = Palette.Danger,
|
||||
modifier = Modifier.testTag("obfs4-problem"),
|
||||
)
|
||||
is BridgeLineResult.Bad -> {
|
||||
Text(
|
||||
parsed.problem.message,
|
||||
fontSize = 12.sp,
|
||||
color = Palette.Danger,
|
||||
modifier = Modifier.testTag("obfs4-problem"),
|
||||
)
|
||||
}
|
||||
|
||||
is BridgeLineResult.Ok -> {
|
||||
Text(
|
||||
"Reads as ${parsed.bridge.host} port ${parsed.bridge.port}",
|
||||
@@ -182,7 +170,10 @@ fun ColumnScope.NetworkSection(
|
||||
)
|
||||
}
|
||||
}
|
||||
null -> Unit
|
||||
|
||||
null -> {
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
@@ -190,9 +181,8 @@ fun ColumnScope.NetworkSection(
|
||||
KitPrimaryButton(
|
||||
text = "Save this bridge",
|
||||
onClick = {
|
||||
// ⚠️ 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.
|
||||
// Goes through `save`, which parses again and stores only what it
|
||||
// could use.
|
||||
if (settings.save(draft) is BridgeLineResult.Ok) {
|
||||
stored = settings.bridgeLine
|
||||
tick++
|
||||
@@ -218,21 +208,12 @@ fun ColumnScope.NetworkSection(
|
||||
)
|
||||
}
|
||||
|
||||
// 🔴 **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
|
||||
// nothing set would send an empty line and put an un-actionable row on
|
||||
// somebody else's settings screen.
|
||||
// The other half of receiving one.
|
||||
if (stored != null && buddies.isNotEmpty()) {
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Text("Share it with", fontWeight = FontWeight.Medium, fontSize = 15.sp)
|
||||
Text(
|
||||
// Says who learns what. The bridge operator sees a new client; the
|
||||
// contact learns you use this bridge. Neither is nothing.
|
||||
// Says who learns what.
|
||||
"They get the line and decide for themselves. Whoever runs the bridge will " +
|
||||
"see one more person connecting, and your contact will know you use it.",
|
||||
fontSize = 12.sp,
|
||||
@@ -254,9 +235,7 @@ fun ColumnScope.NetworkSection(
|
||||
model?.shareBridge(buddy.userId)
|
||||
sharedWith = sharedWith + buddy.userId
|
||||
},
|
||||
// ⚠️ 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.
|
||||
// Not disabled after sending.
|
||||
tag = "obfs4-share-${buddy.userId}",
|
||||
)
|
||||
}
|
||||
@@ -267,7 +246,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,
|
||||
)
|
||||
@@ -281,43 +260,40 @@ 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
|
||||
* 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.
|
||||
*/
|
||||
internal fun statusLine(
|
||||
bridge: io.flamenet.obfs4.Obfs4Bridge? = Obfs4Routing.bridge,
|
||||
handshakes: Int = Obfs4Stats.completedHandshakes(),
|
||||
): String = when {
|
||||
bridge == null ->
|
||||
"Connecting directly to your Flamenet server. That's the right setting almost " +
|
||||
"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 " +
|
||||
"always did."
|
||||
// ⚠️ 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 " +
|
||||
"app will try the next time it goes online."
|
||||
}
|
||||
): String =
|
||||
when {
|
||||
bridge == null -> {
|
||||
"Connecting directly to your Flamenet server. That's the right setting almost " +
|
||||
"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 " +
|
||||
"always did."
|
||||
}
|
||||
|
||||
// Not "protected".
|
||||
else -> {
|
||||
"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
|
||||
* failure mode for this feature is somebody believing it hides them from their
|
||||
* server, behaving accordingly, and being wrong.
|
||||
* Says what it does not protect, in the same breath as what it does.
|
||||
*/
|
||||
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."
|
||||
|
||||
@@ -22,11 +22,6 @@ 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
|
||||
* 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."
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.NotificationSection() {
|
||||
@@ -40,44 +35,56 @@ fun ColumnScope.NotificationSection() {
|
||||
Text("Notifications", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
@Composable
|
||||
fun row(label: String, tag: String, value: Boolean, onChange: (Boolean) -> Unit) {
|
||||
fun row(
|
||||
label: String,
|
||||
tag: String,
|
||||
value: Boolean,
|
||||
onChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(label, fontSize = 16.sp, modifier = Modifier.weight(1f))
|
||||
Switch(checked = value, onCheckedChange = onChange,
|
||||
modifier = Modifier.testTag(tag))
|
||||
Switch(
|
||||
checked = value,
|
||||
onCheckedChange = onChange,
|
||||
modifier = Modifier.testTag(tag),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
row("Notify me", "notify-enabled", enabled) { on ->
|
||||
settings.enabled = on; enabled = on
|
||||
settings.enabled = on
|
||||
enabled = on
|
||||
}
|
||||
if (enabled) {
|
||||
row("New messages", "notify-messages", newMessages) { on ->
|
||||
settings.newMessages = on; newMessages = on
|
||||
settings.newMessages = on
|
||||
newMessages = on
|
||||
}
|
||||
row("Show who it is from", "notify-previews", previews) { on ->
|
||||
settings.showPreviews = on; previews = on
|
||||
settings.showPreviews = on
|
||||
previews = on
|
||||
}
|
||||
row("Sound while the app is open", "notify-sounds", sounds) { on ->
|
||||
settings.inAppSounds = on; sounds = on
|
||||
settings.inAppSounds = on
|
||||
sounds = on
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
|
||||
// 🔴 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.
|
||||
// Says what turning it on costs, and does not pretend the default is merely
|
||||
// cautious.
|
||||
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."
|
||||
else
|
||||
"Notifications say how many messages arrived and nothing else — no name, no "
|
||||
+ "words. Calls still ring.",
|
||||
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."
|
||||
} else {
|
||||
"Notifications say how many messages arrived and nothing else, no name, no " +
|
||||
"words. Calls still ring."
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
|
||||
@@ -4,24 +4,10 @@ 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
|
||||
* 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
|
||||
* 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."
|
||||
*
|
||||
* This is that setting, and it defaults to the behaviour that was there before:
|
||||
* 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
|
||||
* person. iOS reads these through `SettingsKeys`; the names differ because the
|
||||
* platforms store differently, and the DEFAULTS are what must not drift.
|
||||
*/
|
||||
class NotificationSettings(context: Context) {
|
||||
|
||||
class NotificationSettings(
|
||||
context: Context,
|
||||
) {
|
||||
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/** The master switch. Off means no notification of any kind is posted. */
|
||||
@@ -29,20 +15,13 @@ 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()
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* that is fine can have it; nobody should acquire it by installing an
|
||||
* update.
|
||||
*/
|
||||
var showPreviews: Boolean
|
||||
get() = prefs.getBoolean(SHOW_PREVIEWS, false)
|
||||
@@ -55,44 +34,44 @@ 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
|
||||
* 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
|
||||
* shipped rendering its own source code, invisible to the compiler.
|
||||
*
|
||||
* ⚠️ `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 =
|
||||
messageText(showPreviews, threads, from)
|
||||
fun messageText(
|
||||
threads: Int,
|
||||
from: String?,
|
||||
): String = messageText(showPreviews, threads, from)
|
||||
|
||||
/** The same question for a reply reminder. */
|
||||
fun replyReminderText(count: Int, from: String?): String =
|
||||
replyReminderText(showPreviews, count, from)
|
||||
fun replyReminderText(
|
||||
count: Int,
|
||||
from: String?,
|
||||
): String = replyReminderText(showPreviews, count, from)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* 🔴 **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
|
||||
* 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.
|
||||
* Pure, and takes the preference as an argument, so a test can call
|
||||
* THIS rather than a copy of it.
|
||||
*/
|
||||
fun messageText(showPreviews: Boolean, threads: Int, from: String?): String = when {
|
||||
showPreviews && threads == 1 && !from.isNullOrBlank() -> "New message from $from"
|
||||
threads == 1 -> "New message"
|
||||
else -> "New messages in $threads chats"
|
||||
}
|
||||
fun messageText(
|
||||
showPreviews: Boolean,
|
||||
threads: Int,
|
||||
from: String?,
|
||||
): String =
|
||||
when {
|
||||
showPreviews && threads == 1 && !from.isNullOrBlank() -> "New message from $from"
|
||||
threads == 1 -> "New message"
|
||||
else -> "New messages in $threads chats"
|
||||
}
|
||||
|
||||
fun replyReminderText(showPreviews: Boolean, count: Int, from: String?): String = when {
|
||||
showPreviews && count == 1 && !from.isNullOrBlank() -> "Still no reply from $from"
|
||||
count == 1 -> "Still no reply"
|
||||
else -> "Still no reply in $count chats"
|
||||
}
|
||||
fun replyReminderText(
|
||||
showPreviews: Boolean,
|
||||
count: Int,
|
||||
from: String?,
|
||||
): String =
|
||||
when {
|
||||
showPreviews && count == 1 && !from.isNullOrBlank() -> "Still no reply from $from"
|
||||
count == 1 -> "Still no reply"
|
||||
else -> "Still no reply in $count chats"
|
||||
}
|
||||
|
||||
private const val PREFS = "notification-settings"
|
||||
private const val ENABLED = "enabled"
|
||||
|
||||
@@ -7,46 +7,27 @@ 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
|
||||
* 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:
|
||||
* 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
|
||||
* in.
|
||||
*
|
||||
* ⚠️ **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.
|
||||
*/
|
||||
class Obfs4Settings(context: Context) {
|
||||
|
||||
class Obfs4Settings(
|
||||
context: Context,
|
||||
) {
|
||||
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* The stored line, exactly as it was pasted.
|
||||
*
|
||||
* ⚠️ 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.
|
||||
*/
|
||||
var bridgeLine: String?
|
||||
get() = prefs.getString(BRIDGE_LINE, null)
|
||||
private set(value) {
|
||||
prefs.edit().apply {
|
||||
if (value.isNullOrBlank()) remove(BRIDGE_LINE) else putString(BRIDGE_LINE, value)
|
||||
}.apply()
|
||||
prefs
|
||||
.edit()
|
||||
.apply {
|
||||
if (value.isNullOrBlank()) remove(BRIDGE_LINE) else putString(BRIDGE_LINE, value)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* fallback all over again.
|
||||
*/
|
||||
fun save(line: String): BridgeLineResult {
|
||||
val trimmed = line.trim()
|
||||
@@ -65,22 +46,15 @@ class Obfs4Settings(context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the stored line into force. Called once at startup.
|
||||
*
|
||||
* ⚠️ **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
|
||||
* 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.
|
||||
* Leaving it would show a bridge on the settings screen that nothing could
|
||||
* use.
|
||||
* Puts the stored line into force.
|
||||
*/
|
||||
fun applyAtStartup() {
|
||||
val line = bridgeLine ?: return
|
||||
when (val result = BridgeLine.parse(line)) {
|
||||
is BridgeLineResult.Ok -> Obfs4Routing.use(result.bridge)
|
||||
is BridgeLineResult.Ok -> {
|
||||
Obfs4Routing.use(result.bridge)
|
||||
}
|
||||
|
||||
is BridgeLineResult.Bad -> {
|
||||
bridgeLine = null
|
||||
Obfs4Routing.use(null)
|
||||
@@ -90,15 +64,6 @@ 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 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
|
||||
* traffic goes.
|
||||
*
|
||||
* Returns the same result [save] does, so a line that arrived broken is
|
||||
* reported with a reason rather than dropped.
|
||||
*/
|
||||
fun accept(offeredLine: String): BridgeLineResult = save(offeredLine)
|
||||
|
||||
|
||||
@@ -6,24 +6,8 @@ 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
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
* the device. The server learns nothing.
|
||||
*/
|
||||
object ParentalControls {
|
||||
|
||||
private const val PREFS = "parental-controls"
|
||||
private const val PIN_HASH = "pinHash"
|
||||
private const val PIN_SALT = "pinSalt"
|
||||
@@ -31,38 +15,45 @@ object ParentalControls {
|
||||
const val STORIES_HIDDEN = "storiesHidden"
|
||||
const val CALLS_DISABLED = "callsDisabled"
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
fun isEnabled(context: Context) = prefs(context).getString(PIN_HASH, null) != null
|
||||
|
||||
fun contactsGated(context: Context) =
|
||||
isEnabled(context) && prefs(context).getBoolean(CONTACTS_GATED, false)
|
||||
fun contactsGated(context: Context) = isEnabled(context) && prefs(context).getBoolean(CONTACTS_GATED, false)
|
||||
|
||||
fun storiesHidden(context: Context) =
|
||||
isEnabled(context) && prefs(context).getBoolean(STORIES_HIDDEN, false)
|
||||
fun storiesHidden(context: Context) = isEnabled(context) && prefs(context).getBoolean(STORIES_HIDDEN, false)
|
||||
|
||||
fun callsDisabled(context: Context) =
|
||||
isEnabled(context) && prefs(context).getBoolean(CALLS_DISABLED, false)
|
||||
fun callsDisabled(context: Context) = isEnabled(context) && prefs(context).getBoolean(CALLS_DISABLED, false)
|
||||
|
||||
fun setGate(context: Context, key: String, on: Boolean) {
|
||||
fun setGate(
|
||||
context: Context,
|
||||
key: String,
|
||||
on: Boolean,
|
||||
) {
|
||||
prefs(context).edit().putBoolean(key, on).apply()
|
||||
}
|
||||
|
||||
fun enable(context: Context, pin: String) {
|
||||
fun enable(
|
||||
context: Context,
|
||||
pin: String,
|
||||
) {
|
||||
val salt = ByteArray(16).also(SecureRandom()::nextBytes)
|
||||
prefs(context).edit()
|
||||
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
|
||||
// supervision, and each is individually releasable afterwards.
|
||||
// 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)
|
||||
.putBoolean(CALLS_DISABLED, true)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun verify(context: Context, pin: String): Boolean {
|
||||
fun verify(
|
||||
context: Context,
|
||||
pin: String,
|
||||
): Boolean {
|
||||
val stored = prefs(context).getString(PIN_HASH, null) ?: return false
|
||||
val saltHex = prefs(context).getString(PIN_SALT, null) ?: return false
|
||||
val salt = saltHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
@@ -71,8 +62,12 @@ object ParentalControls {
|
||||
|
||||
fun disable(context: Context) = prefs(context).edit().clear().apply()
|
||||
|
||||
private fun hash(pin: String, salt: ByteArray): String =
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
private fun hash(
|
||||
pin: String,
|
||||
salt: ByteArray,
|
||||
): String =
|
||||
MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.apply { update(salt) }
|
||||
.digest(pin.toByteArray(Charsets.UTF_8))
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
@@ -25,8 +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
|
||||
* decoration if the person they restrain could flip them.
|
||||
* device may do.
|
||||
*/
|
||||
@Composable
|
||||
fun ParentalControlsSection() {
|
||||
@@ -39,24 +38,26 @@ fun ParentalControlsSection() {
|
||||
var newPin by remember { mutableStateOf("") }
|
||||
var repeatPin by remember { mutableStateOf("") }
|
||||
|
||||
@Suppress("UNUSED_EXPRESSION") tick
|
||||
@Suppress("UNUSED_EXPRESSION")
|
||||
tick
|
||||
|
||||
Text("Parental controls", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
when {
|
||||
!ParentalControls.isEnabled(context) -> {
|
||||
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 "
|
||||
+ "supervision is sent to any server. Use it alongside Family "
|
||||
+ "Link, which governs the phone itself.",
|
||||
"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 " +
|
||||
"supervision is sent to any server. Use it alongside Family " +
|
||||
"Link, which governs the phone itself.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
OutlinedTextField(
|
||||
value = newPin, onValueChange = { newPin = it },
|
||||
value = newPin,
|
||||
onValueChange = { newPin = it },
|
||||
placeholder = { Text("Parent PIN (at least 4 digits)", fontSize = 14.sp) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
@@ -64,7 +65,8 @@ fun ParentalControlsSection() {
|
||||
)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
OutlinedTextField(
|
||||
value = repeatPin, onValueChange = { repeatPin = it },
|
||||
value = repeatPin,
|
||||
onValueChange = { repeatPin = it },
|
||||
placeholder = { Text("Repeat PIN", fontSize = 14.sp) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
@@ -74,7 +76,10 @@ fun ParentalControlsSection() {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
ParentalControls.enable(context, newPin)
|
||||
newPin = ""; repeatPin = ""; unlocked = true; tick++
|
||||
newPin = ""
|
||||
repeatPin = ""
|
||||
unlocked = true
|
||||
tick++
|
||||
},
|
||||
enabled = newPin.length >= 4 && newPin == repeatPin,
|
||||
modifier = Modifier.testTag("parental-enable"),
|
||||
@@ -82,11 +87,18 @@ fun ParentalControlsSection() {
|
||||
}
|
||||
|
||||
!unlocked -> {
|
||||
Text("Supervision is on. The settings behind this are the parent's to change.",
|
||||
fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
Text(
|
||||
"Supervision is on. The settings behind this are the parent's to change.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
OutlinedTextField(
|
||||
value = pinEntry, onValueChange = { pinEntry = it; wrong = false },
|
||||
value = pinEntry,
|
||||
onValueChange = {
|
||||
pinEntry = it
|
||||
wrong = false
|
||||
},
|
||||
placeholder = { Text("Parent PIN", fontSize = 14.sp) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
@@ -94,8 +106,11 @@ fun ParentalControlsSection() {
|
||||
modifier = Modifier.fillMaxWidth().testTag("parental-unlock-pin"),
|
||||
)
|
||||
if (wrong) {
|
||||
Text("That's not the PIN.", fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.error)
|
||||
Text(
|
||||
"That's not the PIN.",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
OutlinedButton(
|
||||
@@ -108,31 +123,44 @@ fun ParentalControlsSection() {
|
||||
) { Text("Unlock") }
|
||||
}
|
||||
|
||||
else -> Column {
|
||||
GateRow("Ask the PIN to add a contact", ParentalControls.CONTACTS_GATED) { tick++ }
|
||||
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 "
|
||||
+ "caller is answered busy.",
|
||||
fontSize = 12.sp, color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
OutlinedButton(
|
||||
onClick = { ParentalControls.disable(context); unlocked = false; tick++ },
|
||||
modifier = Modifier.testTag("parental-disable"),
|
||||
) { Text("Turn off supervision") }
|
||||
else -> {
|
||||
Column {
|
||||
GateRow("Ask the PIN to add a contact", ParentalControls.CONTACTS_GATED) { tick++ }
|
||||
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 " +
|
||||
"caller is answered busy.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
ParentalControls.disable(context)
|
||||
unlocked = false
|
||||
tick++
|
||||
},
|
||||
modifier = Modifier.testTag("parental-disable"),
|
||||
) { Text("Turn off supervision") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GateRow(label: String, key: String, onChanged: () -> Unit) {
|
||||
private fun GateRow(
|
||||
label: String,
|
||||
key: String,
|
||||
onChanged: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var on by remember(key) {
|
||||
mutableStateOf(
|
||||
context.getSharedPreferences("parental-controls", android.content.Context.MODE_PRIVATE)
|
||||
.getBoolean(key, false))
|
||||
context
|
||||
.getSharedPreferences("parental-controls", android.content.Context.MODE_PRIVATE)
|
||||
.getBoolean(key, false),
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(label, fontSize = 14.sp, modifier = Modifier.weight(1f))
|
||||
|
||||
@@ -36,10 +36,6 @@ 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
|
||||
* panel below, and the reason these are two controls rather than one.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) {
|
||||
@@ -47,8 +43,7 @@ fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) {
|
||||
val stickers = StickerPack.all(context)
|
||||
|
||||
if (stickers.isEmpty()) {
|
||||
// The manifest failed to load. Say so rather than showing an empty
|
||||
// panel that reads as "there are no stickers".
|
||||
// The manifest failed to load.
|
||||
Text(
|
||||
"Stickers could not be loaded on this device.",
|
||||
fontSize = 14.sp,
|
||||
@@ -60,22 +55,26 @@ fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) {
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 64.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 220.dp)
|
||||
.clip(RoundedCornerShape(Radius.large))
|
||||
.background(Palette.SurfaceAlt)
|
||||
.testTag("sticker-picker"),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(Space.sm),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 220.dp)
|
||||
.clip(RoundedCornerShape(Radius.large))
|
||||
.background(Palette.SurfaceAlt)
|
||||
.testTag("sticker-picker"),
|
||||
contentPadding =
|
||||
androidx.compose.foundation.layout
|
||||
.PaddingValues(Space.sm),
|
||||
) {
|
||||
items(stickers, key = { it.id }) { sticker ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(Space.xs)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.clickable { onPick(sticker) }
|
||||
.semantics { contentDescription = sticker.name }
|
||||
.testTag("sticker-pick-${sticker.id}"),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(Space.xs)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.clickable { onPick(sticker) }
|
||||
.semantics { contentDescription = sticker.name }
|
||||
.testTag("sticker-pick-${sticker.id}"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(sticker.glyph, fontSize = 34.sp, modifier = Modifier.padding(Space.sm))
|
||||
@@ -86,33 +85,14 @@ 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
|
||||
* 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
|
||||
* 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 —
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.EmojiPicker(onPick: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember {
|
||||
context.getSharedPreferences("flamenet-emoji", android.content.Context.MODE_PRIVATE)
|
||||
}
|
||||
val prefs =
|
||||
remember {
|
||||
context.getSharedPreferences("flamenet-emoji", android.content.Context.MODE_PRIVATE)
|
||||
}
|
||||
var recents by remember {
|
||||
mutableStateOf(prefs.getString("recent", "")!!.split(" ").filter { it.isNotBlank() })
|
||||
}
|
||||
@@ -125,14 +105,15 @@ fun ColumnScope.EmojiPicker(onPick: (String) -> Unit) {
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 220.dp)
|
||||
.clip(RoundedCornerShape(Radius.large))
|
||||
.background(Palette.SurfaceAlt)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(Space.sm)
|
||||
.testTag("emoji-picker"),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 220.dp)
|
||||
.clip(RoundedCornerShape(Radius.large))
|
||||
.background(Palette.SurfaceAlt)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(Space.sm)
|
||||
.testTag("emoji-picker"),
|
||||
verticalArrangement = Arrangement.spacedBy(Space.xs),
|
||||
) {
|
||||
if (recents.isNotEmpty()) EmojiSection("Recent", recents, ::pick)
|
||||
@@ -141,7 +122,11 @@ fun ColumnScope.EmojiPicker(onPick: (String) -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiSection(title: String, emoji: List<String>, onPick: (String) -> Unit) {
|
||||
private fun EmojiSection(
|
||||
title: String,
|
||||
emoji: List<String>,
|
||||
onPick: (String) -> Unit,
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
fontSize = 11.sp,
|
||||
@@ -149,20 +134,22 @@ private fun EmojiSection(title: String, emoji: List<String>, onPick: (String) ->
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(Space.xs),
|
||||
) {
|
||||
emoji.forEach { e ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.clickable { onPick(e) }
|
||||
// Scoped by section, as on iOS: the same emoji can sit in
|
||||
// Recent AND its own group, and a tag on the glyph alone is
|
||||
// ambiguous to a test.
|
||||
.testTag("emoji-$title-$e"),
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.clickable { onPick(e) }
|
||||
// Scoped by section, as on iOS: the same emoji can sit
|
||||
// in Recent AND its own group, and a tag on the glyph
|
||||
// alone is ambiguous to a test.
|
||||
.testTag("emoji-$title-$e"),
|
||||
) {
|
||||
Text(e, fontSize = 24.sp, modifier = Modifier.padding(Space.xs))
|
||||
}
|
||||
@@ -171,14 +158,48 @@ private fun EmojiSection(title: String, emoji: List<String>, onPick: (String) ->
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The same set as iOS, grouped the same way, kept in step on purpose.
|
||||
*/
|
||||
private val EMOJI_GROUPS = listOf(
|
||||
"Smileys" to listOf("😀", "😂", "🙂", "😉", "😍", "🤔", "😅", "😭", "😡", "🥳",
|
||||
"😴", "🤯", "🫠", "😬", "🙃", "😎"),
|
||||
"Gestures" to listOf("👍", "👎", "👋", "🙏", "👏", "🤝", "💪", "🫶", "✌️", "🤞"),
|
||||
"Hearts" to listOf("❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "💔", "✨", "🔥"),
|
||||
"Things" to listOf("✅", "❌", "⚠️", "📌", "📎", "🔒", "🔑", "☕", "🍕", "🎉",
|
||||
"🎂", "🚀", "🌧️", "☀️", "🌙", "⭐"),
|
||||
)
|
||||
private val EMOJI_GROUPS =
|
||||
listOf(
|
||||
"Smileys" to
|
||||
listOf(
|
||||
"😀",
|
||||
"😂",
|
||||
"🙂",
|
||||
"😉",
|
||||
"😍",
|
||||
"🤔",
|
||||
"😅",
|
||||
"😭",
|
||||
"😡",
|
||||
"🥳",
|
||||
"😴",
|
||||
"🤯",
|
||||
"🫠",
|
||||
"😬",
|
||||
"🙃",
|
||||
"😎",
|
||||
),
|
||||
"Gestures" to listOf("👍", "👎", "👋", "🙏", "👏", "🤝", "💪", "🫶", "✌️", "🤞"),
|
||||
"Hearts" to listOf("❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "💔", "✨", "🔥"),
|
||||
"Things" to
|
||||
listOf(
|
||||
"✅",
|
||||
"❌",
|
||||
"⚠️",
|
||||
"📌",
|
||||
"📎",
|
||||
"🔒",
|
||||
"🔑",
|
||||
"☕",
|
||||
"🍕",
|
||||
"🎉",
|
||||
"🎂",
|
||||
"🚀",
|
||||
"🌧️",
|
||||
"☀️",
|
||||
"🌙",
|
||||
"⭐",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -9,99 +9,80 @@ 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
|
||||
* 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.**
|
||||
* `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
|
||||
* 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,
|
||||
* 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
|
||||
* reports it rather than throwing something a screen cannot phrase.
|
||||
*/
|
||||
class SafReferences(private val context: Context) : LocalVault.ReferenceResolver {
|
||||
|
||||
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
|
||||
* 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
|
||||
* worked.
|
||||
*/
|
||||
fun takePersistablePermission(uri: Uri): Boolean = try {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
true
|
||||
} catch (e: SecurityException) {
|
||||
false
|
||||
}
|
||||
fun takePersistablePermission(uri: Uri): Boolean =
|
||||
try {
|
||||
context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
true
|
||||
} catch (e: SecurityException) {
|
||||
false
|
||||
}
|
||||
|
||||
fun releasePersistablePermission(bookmark: String) {
|
||||
runCatching {
|
||||
context.contentResolver.releasePersistableUriPermission(
|
||||
Uri.parse(bookmark), Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
context.contentResolver.releasePersistableUriPermission(Uri.parse(bookmark), Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ **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
|
||||
* the thing this whole distinction exists to avoid.
|
||||
* Opening the stream, not asking the provider whether the file exists.
|
||||
*/
|
||||
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,
|
||||
// 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
|
||||
}
|
||||
override fun exists(bookmark: String): Boolean =
|
||||
try {
|
||||
context.contentResolver.openInputStream(Uri.parse(bookmark))?.use { true } ?: false
|
||||
} catch (e: Exception) {
|
||||
// Deliberately broad.
|
||||
false
|
||||
}
|
||||
|
||||
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.")
|
||||
?: throw LocalVault.VaultException("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
|
||||
* failure. `OpenableColumns.SIZE` is optional, and several providers return
|
||||
* null for it — the row then shows no size rather than a wrong one.
|
||||
* Zero when the provider will not say, which is a real answer and not a
|
||||
* failure.
|
||||
*/
|
||||
override fun size(bookmark: String): Int = try {
|
||||
context.contentResolver.query(
|
||||
Uri.parse(bookmark), arrayOf(OpenableColumns.SIZE), null, null, null
|
||||
)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) return@use 0
|
||||
val column = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
if (column < 0 || cursor.isNull(column)) 0 else cursor.getLong(column).toInt()
|
||||
} ?: 0
|
||||
} catch (e: Exception) {
|
||||
0
|
||||
}
|
||||
override fun size(bookmark: String): Int =
|
||||
try {
|
||||
context.contentResolver
|
||||
.query(
|
||||
Uri.parse(bookmark),
|
||||
arrayOf(OpenableColumns.SIZE),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) return@use 0
|
||||
val column = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
if (column < 0 || cursor.isNull(column)) 0 else cursor.getLong(column).toInt()
|
||||
} ?: 0
|
||||
} catch (e: Exception) {
|
||||
0
|
||||
}
|
||||
|
||||
/** The name the provider shows, for pre-filling the file's name in the vault. */
|
||||
fun displayName(uri: Uri): String? = try {
|
||||
context.contentResolver.query(
|
||||
uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null
|
||||
)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) return@use null
|
||||
val column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (column < 0 || cursor.isNull(column)) null else cursor.getString(column)
|
||||
fun displayName(uri: Uri): String? =
|
||||
try {
|
||||
context.contentResolver
|
||||
.query(
|
||||
uri,
|
||||
arrayOf(OpenableColumns.DISPLAY_NAME),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) return@use null
|
||||
val column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (column < 0 || cursor.isNull(column)) null else cursor.getString(column)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,27 +20,19 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* [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
|
||||
* a dependency for decoration.
|
||||
* Point the camera at somebody's address, the other half of [AddressCard].
|
||||
*/
|
||||
|
||||
/** What a valid code carries. `handle` only when the QR's author included it. */
|
||||
internal data class ScannedAddress(val fnid: String, val relay: String, val handle: String?)
|
||||
internal data class ScannedAddress(
|
||||
val fnid: String,
|
||||
val relay: String,
|
||||
val handle: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* `flamenet:<fnid>@<relay>[?u=<handle>&k=…]`, or the bare `fnid@relay`
|
||||
* somebody retypes. Refuses anything else rather than guessing — an address
|
||||
* that is nearly right addresses somebody else.
|
||||
* `flamenet:<fnid>@<relay>[?u=<handle>&k=…]`, or the bare `fnid@relay` somebody
|
||||
* retypes.
|
||||
*/
|
||||
internal fun parseScannedAddress(text: String): ScannedAddress? {
|
||||
var s = text.trim()
|
||||
@@ -58,19 +50,23 @@ internal fun parseScannedAddress(text: String): ScannedAddress? {
|
||||
val fnid = s.substring(0, at).trim()
|
||||
val relay = s.substring(at + 1).trim().lowercase()
|
||||
if (fnid.isEmpty() || relay.isEmpty() || !relay.contains('.')) return null
|
||||
// A relay is a hostname. Accepting "https://x/y" would let a printed code
|
||||
// aim this client at a path on a host of the attacker's choosing.
|
||||
// A relay is a hostname.
|
||||
if (relay.contains('/') || relay.contains(':')) return null
|
||||
return ScannedAddress(fnid, relay, handle?.takeIf { it.isNotBlank() })
|
||||
}
|
||||
|
||||
/** Full-screen camera dialog. Calls [onFound] once with the raw decoded text. */
|
||||
@Composable
|
||||
fun ScanAddressDialog(onFound: (String) -> Unit, onDismiss: () -> Unit) {
|
||||
fun ScanAddressDialog(
|
||||
onFound: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val granted = androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context, android.Manifest.permission.CAMERA
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
val granted =
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
android.Manifest.permission.CAMERA,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -78,12 +74,11 @@ 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,
|
||||
// only Android settings can change the answer.
|
||||
// Says what to do, not just that it failed.
|
||||
Column(modifier = Modifier.align(Alignment.Center).padding(24.dp)) {
|
||||
Text(
|
||||
"Flamenet cannot use the camera. Allow it in Android "
|
||||
+ "settings, or paste their address into Add contact instead.",
|
||||
"Flamenet cannot use the camera. Allow it in Android " +
|
||||
"settings, or paste their address into Add contact instead.",
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.testTag("scan-denied"),
|
||||
@@ -103,26 +98,26 @@ fun ScanAddressDialog(onFound: (String) -> Unit, onDismiss: () -> Unit) {
|
||||
"Point at the code on their Address screen.",
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 48.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 48.dp),
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 24.dp, end = 8.dp)
|
||||
.testTag("scan-cancel"),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 24.dp, end = 8.dp)
|
||||
.testTag("scan-cancel"),
|
||||
) { Text("Cancel", color = Color.White) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The capture-and-decode loop. Frames render to the given view AND, four times
|
||||
* a second, have their luminance plane offered to the QR decoder. First hit
|
||||
* wins; everything after it is dropped.
|
||||
* The capture-and-decode loop.
|
||||
*/
|
||||
private class QrScan(
|
||||
context: Context,
|
||||
@@ -132,28 +127,40 @@ private class QrScan(
|
||||
private val egl = org.webrtc.EglBase.create()
|
||||
private val helper = org.webrtc.SurfaceTextureHelper.create("qr-scan", egl.eglBaseContext)
|
||||
private var capturer: org.webrtc.CameraVideoCapturer? = null
|
||||
private val done = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
private val done =
|
||||
java.util.concurrent.atomic
|
||||
.AtomicBoolean(false)
|
||||
|
||||
@Volatile private var lastDecode = 0L
|
||||
private val reader = com.google.zxing.qrcode.QRCodeReader()
|
||||
private val reader =
|
||||
com.google.zxing.qrcode
|
||||
.QRCodeReader()
|
||||
private val main = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
|
||||
init {
|
||||
renderer.init(egl.eglBaseContext, null)
|
||||
// The BACK camera: a code is on somebody else's screen, not your face.
|
||||
val enumerator = org.webrtc.Camera2Enumerator(context)
|
||||
val name = enumerator.deviceNames.firstOrNull { enumerator.isBackFacing(it) }
|
||||
?: enumerator.deviceNames.firstOrNull()
|
||||
val name =
|
||||
enumerator.deviceNames.firstOrNull { enumerator.isBackFacing(it) }
|
||||
?: enumerator.deviceNames.firstOrNull()
|
||||
if (name != null) {
|
||||
runCatching {
|
||||
val cap = enumerator.createCapturer(name, null)
|
||||
cap.initialize(helper, context, object : org.webrtc.CapturerObserver {
|
||||
override fun onCapturerStarted(success: Boolean) {}
|
||||
override fun onCapturerStopped() {}
|
||||
override fun onFrameCaptured(frame: org.webrtc.VideoFrame) {
|
||||
renderer.onFrame(frame)
|
||||
maybeDecode(frame)
|
||||
}
|
||||
})
|
||||
cap.initialize(
|
||||
helper,
|
||||
context,
|
||||
object : org.webrtc.CapturerObserver {
|
||||
override fun onCapturerStarted(success: Boolean) {}
|
||||
|
||||
override fun onCapturerStopped() {}
|
||||
|
||||
override fun onFrameCaptured(frame: org.webrtc.VideoFrame) {
|
||||
renderer.onFrame(frame)
|
||||
maybeDecode(frame)
|
||||
}
|
||||
},
|
||||
)
|
||||
cap.startCapture(1280, 720, 30)
|
||||
capturer = cap
|
||||
}
|
||||
@@ -170,8 +177,7 @@ private class QrScan(
|
||||
val w = i420.width
|
||||
val h = i420.height
|
||||
// Row by row because the stride can exceed the width; the decoder
|
||||
// wants the packed plane. A QR carries its own orientation in the
|
||||
// finder patterns, so the frame's rotation does not matter.
|
||||
// wants the packed plane.
|
||||
val y = ByteArray(w * h)
|
||||
val data = i420.dataY
|
||||
for (row in 0 until h) {
|
||||
@@ -179,8 +185,11 @@ private class QrScan(
|
||||
data.get(y, row * w, w)
|
||||
}
|
||||
val source = com.google.zxing.PlanarYUVLuminanceSource(y, w, h, 0, 0, w, h, false)
|
||||
val bitmap = com.google.zxing.BinaryBitmap(
|
||||
com.google.zxing.common.HybridBinarizer(source))
|
||||
val bitmap =
|
||||
com.google.zxing.BinaryBitmap(
|
||||
com.google.zxing.common
|
||||
.HybridBinarizer(source),
|
||||
)
|
||||
val text = runCatching { reader.decode(bitmap).text }.getOrNull()
|
||||
reader.reset()
|
||||
if (text != null && done.compareAndSet(false, true)) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,50 +4,23 @@ import java.net.URI
|
||||
|
||||
/**
|
||||
* Which relay this install talks to, and where its accounts live.
|
||||
*
|
||||
* The three deployment shapes a user can be in, and the only one they have to
|
||||
* choose is the address:
|
||||
*
|
||||
* - **Managed** — our relay, accounts in the shared service.
|
||||
* - **Self-hosted, shared identity** — their relay, accounts in the shared
|
||||
* service, so one credential still covers Messenger and Files.
|
||||
* - **Self-hosted, local** — their relay, its own account table. Not a lesser
|
||||
* mode: everything works, it simply is not the same login as Files.
|
||||
*/
|
||||
data class SiteConfig(
|
||||
val baseUrl: String,
|
||||
/**
|
||||
* 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
|
||||
* `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
|
||||
* on "Checking what this server uses…" forever and `accountProvider` stayed
|
||||
* LOCAL for a shared-accounts relay. Keep it in the constructor and write
|
||||
* it with `copy(...)`.
|
||||
*/
|
||||
val discovered: io.flamenet.client.RelayDiscovery.Descriptor? = null,
|
||||
/**
|
||||
* The probe was made and came back with nothing.
|
||||
*
|
||||
* 🔴 **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.
|
||||
* 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.
|
||||
*/
|
||||
val probeFailed: Boolean = false,
|
||||
) {
|
||||
|
||||
enum class AccountProvider {
|
||||
/** The shared FlameNet account service. One credential across products. */
|
||||
SHARED,
|
||||
|
||||
/** The relay's own account table — what keeps self-hosting one service, not two. */
|
||||
/** The relay's own account table, what keeps self-hosting one service, not two. */
|
||||
LOCAL,
|
||||
}
|
||||
|
||||
@@ -56,10 +29,6 @@ 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,
|
||||
* and it would work, which is the worst outcome.
|
||||
*/
|
||||
fun from(typed: String): SiteConfig? {
|
||||
val trimmed = typed.trim().removeSuffix("/")
|
||||
@@ -80,24 +49,14 @@ data class SiteConfig(
|
||||
|
||||
/**
|
||||
* Where this relay's accounts live.
|
||||
*
|
||||
* ⚠️ **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
|
||||
* that hostname, and a self-hoster running Messenger and Files together
|
||||
* needs their own account service to be usable across both. See
|
||||
* [io.flamenet.client.RelayDiscovery.PINNED_RELAYS].
|
||||
*
|
||||
* Defaults to LOCAL until the descriptor has been fetched, which is the
|
||||
* safe direction: the passphrase goes only to the server the user typed.
|
||||
*/
|
||||
val accountProvider: AccountProvider
|
||||
get() = if (discovered?.usesSharedAccounts == true) {
|
||||
AccountProvider.SHARED
|
||||
} else {
|
||||
AccountProvider.LOCAL
|
||||
}
|
||||
get() =
|
||||
if (discovered?.usesSharedAccounts == true) {
|
||||
AccountProvider.SHARED
|
||||
} else {
|
||||
AccountProvider.LOCAL
|
||||
}
|
||||
|
||||
val accountServiceUrl: String
|
||||
get() = discovered?.effectiveAccountsUrl ?: SHARED_ACCOUNT_SERVICE
|
||||
@@ -106,13 +65,23 @@ data class SiteConfig(
|
||||
|
||||
/** What to tell the user this relay does about identity, in their words. */
|
||||
val identityDescription: String
|
||||
get() = when {
|
||||
probeFailed ->
|
||||
"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 ->
|
||||
"One Flamenet account, shared with Flamenet Files."
|
||||
else -> "This server keeps its own accounts."
|
||||
}
|
||||
get() =
|
||||
when {
|
||||
probeFailed -> {
|
||||
"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 -> {
|
||||
"One Flamenet account, shared with Flamenet Files."
|
||||
}
|
||||
|
||||
else -> {
|
||||
"This server keeps its own accounts."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,21 +5,13 @@ 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
|
||||
* 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
|
||||
* something. A sticker that renders as an empty bubble is worse than one that
|
||||
* renders as the wrong face.
|
||||
*/
|
||||
object StickerPack {
|
||||
|
||||
data class Sticker(val id: String, val glyph: String, val name: String)
|
||||
data class Sticker(
|
||||
val id: String,
|
||||
val glyph: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
const val CORE = "flamenet.core"
|
||||
|
||||
@@ -35,17 +27,22 @@ 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.
|
||||
*/
|
||||
fun glyph(context: Context, pack: String, id: String): String {
|
||||
fun glyph(
|
||||
context: Context,
|
||||
pack: String,
|
||||
id: String,
|
||||
): String {
|
||||
ensure(context)
|
||||
if (pack != CORE) return fallbackGlyph
|
||||
return loaded?.get(id)?.glyph ?: fallbackGlyph
|
||||
}
|
||||
|
||||
fun name(context: Context, pack: String, id: String): String {
|
||||
fun name(
|
||||
context: Context,
|
||||
pack: String,
|
||||
id: String,
|
||||
): String {
|
||||
ensure(context)
|
||||
if (pack != CORE) return "Sticker"
|
||||
return loaded?.get(id)?.name ?: "Sticker"
|
||||
@@ -53,15 +50,24 @@ 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
|
||||
// sticker somebody has already been sent renderable regardless.
|
||||
val text = runCatching {
|
||||
context.assets.open("stickers/$CORE.json").bufferedReader().use { it.readText() }
|
||||
}.getOrNull() ?: run { loaded = emptyMap(); return }
|
||||
// Failure is empty, not a crash.
|
||||
val text =
|
||||
runCatching {
|
||||
context.assets
|
||||
.open("stickers/$CORE.json")
|
||||
.bufferedReader()
|
||||
.use { it.readText() }
|
||||
}.getOrNull() ?: run {
|
||||
loaded = emptyMap()
|
||||
return
|
||||
}
|
||||
|
||||
val json = runCatching { JSONObject(text) }.getOrNull()
|
||||
?: run { loaded = emptyMap(); return }
|
||||
val json =
|
||||
runCatching { JSONObject(text) }.getOrNull()
|
||||
?: run {
|
||||
loaded = emptyMap()
|
||||
return
|
||||
}
|
||||
|
||||
fallbackGlyph = json.optString("fallback").ifBlank { "🙂" }
|
||||
val array = json.optJSONArray("stickers")
|
||||
@@ -70,11 +76,12 @@ object StickerPack {
|
||||
val o = array!!.optJSONObject(i) ?: continue
|
||||
val id = o.optString("id")
|
||||
if (id.isBlank()) continue
|
||||
list += Sticker(
|
||||
id = id,
|
||||
glyph = o.optString("glyph").ifBlank { fallbackGlyph },
|
||||
name = o.optString("name").ifBlank { id },
|
||||
)
|
||||
list +=
|
||||
Sticker(
|
||||
id = id,
|
||||
glyph = o.optString("glyph").ifBlank { fallbackGlyph },
|
||||
name = o.optString("name").ifBlank { id },
|
||||
)
|
||||
}
|
||||
order = list
|
||||
loaded = list.associateBy { it.id }
|
||||
|
||||
@@ -16,47 +16,34 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import io.flamenet.e2e.Vault
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.e2e.Vault
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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 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
|
||||
* card says.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.StoriesFeed(
|
||||
@@ -66,15 +53,16 @@ fun ColumnScope.StoriesFeed(
|
||||
LaunchedEffect(Unit) { model.refreshStories() }
|
||||
var writingWords by remember { mutableStateOf(false) }
|
||||
|
||||
// Post from HERE too. The strip on Messages was the only way in, which
|
||||
// means the tab named Stories could show them but not make one.
|
||||
// Post from HERE too.
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val pickStory = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.GetContent()
|
||||
) { uri ->
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
StoryMedia.stage(context, uri, model)
|
||||
}
|
||||
val pickStory =
|
||||
androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts
|
||||
.GetContent(),
|
||||
) { uri ->
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
StoryMedia.stage(context, uri, model)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -85,31 +73,34 @@ fun ColumnScope.StoriesFeed(
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// 🔴 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
|
||||
// existed, for the same reason it was on iOS.
|
||||
// A menu, not a picker.
|
||||
var choosing by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
Icon(
|
||||
KitIcon.Add,
|
||||
contentDescription = "New story",
|
||||
tint = Palette.Accent,
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clickable { choosing = true }
|
||||
.testTag("new-story"),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(28.dp)
|
||||
.clickable { choosing = true }
|
||||
.testTag("new-story"),
|
||||
)
|
||||
DropdownMenu(expanded = choosing, onDismissRequest = { choosing = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Photo or video") },
|
||||
onClick = { choosing = false; pickStory.launch("*/*") },
|
||||
onClick = {
|
||||
choosing = false
|
||||
pickStory.launch("*/*")
|
||||
},
|
||||
modifier = Modifier.testTag("new-story-photo"),
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Just words") },
|
||||
onClick = { choosing = false; writingWords = true },
|
||||
onClick = {
|
||||
choosing = false
|
||||
writingWords = true
|
||||
},
|
||||
modifier = Modifier.testTag("new-story-text"),
|
||||
)
|
||||
}
|
||||
@@ -119,13 +110,15 @@ fun ColumnScope.StoriesFeed(
|
||||
if (writingWords) {
|
||||
TextStoryComposer(
|
||||
onDismiss = { writingWords = false },
|
||||
onPost = { text -> writingWords = false; model.postTextStory(text) },
|
||||
onPost = { text ->
|
||||
writingWords = false
|
||||
model.postTextStory(text)
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
|
||||
StoriesCards(model, phase)
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,26 +131,30 @@ private fun StoryCard(
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
story: Vault.Story,
|
||||
) {
|
||||
val author = remember(story.authorId, phase.buddies) {
|
||||
phase.buddies.firstOrNull { it.userId == story.authorId }
|
||||
?.let { it.displayName.ifBlank { it.handle } }
|
||||
?: if (story.mine) "You" else "user ${story.authorId}"
|
||||
}
|
||||
val remaining = remember(story.id) {
|
||||
val seconds = story.expires - System.currentTimeMillis() / 1000.0
|
||||
when {
|
||||
story.expires <= 0L -> "no expiry"
|
||||
seconds <= 0 -> "expired"
|
||||
seconds < 3600 -> "${(seconds / 60).toInt()} min left"
|
||||
else -> "${(seconds / 3600).toInt()} h left"
|
||||
val author =
|
||||
remember(story.authorId, phase.buddies) {
|
||||
phase.buddies
|
||||
.firstOrNull { it.userId == story.authorId }
|
||||
?.let { it.displayName.ifBlank { it.handle } }
|
||||
?: if (story.mine) "You" else "user ${story.authorId}"
|
||||
}
|
||||
val remaining =
|
||||
remember(story.id) {
|
||||
val seconds = story.expires - System.currentTimeMillis() / 1000.0
|
||||
when {
|
||||
story.expires <= 0L -> "no expiry"
|
||||
seconds <= 0 -> "expired"
|
||||
seconds < 3600 -> "${(seconds / 60).toInt()} min left"
|
||||
else -> "${(seconds / 3600).toInt()} h left"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { model.openStory(story) }
|
||||
.testTag("story-card-${story.id}"),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { model.openStory(story) }
|
||||
.testTag("story-card-${story.id}"),
|
||||
) {
|
||||
// Header: avatar 40, name 14 medium over a 12 regular description.
|
||||
Row(
|
||||
@@ -165,9 +162,8 @@ private fun StoryCard(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
|
||||
) {
|
||||
// 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.
|
||||
// The author's actual picture when one is held, your own comes from
|
||||
// myAvatar, a contact's from the avatar map.
|
||||
Avatar(
|
||||
name = author,
|
||||
online = false,
|
||||
@@ -177,19 +173,19 @@ 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.
|
||||
// It is the one fact about a story that changes while you are
|
||||
// looking at it, and the whole point of the feature.
|
||||
// The deadline, in the slot the kit gives a description.
|
||||
Text(remaining, fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
}
|
||||
// Unseen is marked here rather than by styling the whole card: a
|
||||
// feed where read and unread look alike is a feed nobody returns to.
|
||||
// feed where read and unread look alike is a feed nobody returns
|
||||
// to.
|
||||
if (!story.seen) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(Kit.presenceDot)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Kit.presenceDot)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -199,34 +195,34 @@ private fun StoryCard(
|
||||
// The image. 375x320 in the kit; an aspect ratio here, because the kit
|
||||
// is drawn on one canvas and this has to hold on every phone.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(240.dp)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.SurfaceAlt),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(240.dp)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.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
|
||||
// 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.
|
||||
// backboard and read as a broken layout. §6.13, an embedded live
|
||||
// stream.
|
||||
val stream = story.streamUrl
|
||||
val streamHost = story.streamHost
|
||||
if (stream != null && streamHost != null &&
|
||||
io.flamenet.e2e.StreamDisclosure.acceptable(stream)) {
|
||||
io.flamenet.e2e.StreamDisclosure
|
||||
.acceptable(stream)
|
||||
) {
|
||||
StoryStreamGate(stream, streamHost)
|
||||
} else {
|
||||
val ref = story.ref
|
||||
when {
|
||||
// 🔴 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.
|
||||
// Words alone, set large on a tinted band, the way every
|
||||
// other messenger draws one.
|
||||
ref == null -> TextStoryCard(story.caption)
|
||||
|
||||
ref.mime.startsWith("video/") -> StoryVideoCard(ref, model)
|
||||
|
||||
else -> StoryImage(ref, model)
|
||||
}
|
||||
}
|
||||
@@ -244,21 +240,14 @@ 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
|
||||
* 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
|
||||
* 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
|
||||
// buddy list and has already done this, and two resolutions of the same
|
||||
// name are two chances to disagree about who is being reported.
|
||||
// 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 = "",
|
||||
) {
|
||||
val quick = listOf("❤️", "😂", "😮", "😢", "🔥")
|
||||
@@ -269,8 +258,10 @@ private fun StoryActions(
|
||||
if (story.mine) {
|
||||
val tally = model.storyTally(story.id)
|
||||
if (tally.isNotEmpty()) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.testTag("story-tally")) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.testTag("story-tally"),
|
||||
) {
|
||||
tally.entries.sortedBy { it.key }.forEach { (emoji, count) ->
|
||||
Text("$emoji $count", fontSize = 13.sp, color = Palette.Mist)
|
||||
}
|
||||
@@ -280,54 +271,63 @@ private fun StoryActions(
|
||||
}
|
||||
|
||||
val mine = model.myStoryReaction(story.id)
|
||||
Row(verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
quick.forEach { emoji ->
|
||||
Text(
|
||||
emoji,
|
||||
fontSize = 20.sp,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(
|
||||
if (mine == emoji) Palette.Accent.copy(alpha = 0.15f)
|
||||
else androidx.compose.ui.graphics.Color.Transparent
|
||||
)
|
||||
.clickable {
|
||||
// Tapping the one already sent takes it back.
|
||||
model.reactToStory(if (mine == emoji) "" else emoji, story)
|
||||
}
|
||||
.padding(4.dp)
|
||||
.testTag("story-react-$emoji"),
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(
|
||||
if (mine == emoji) {
|
||||
Palette.Accent.copy(alpha = 0.15f)
|
||||
} else {
|
||||
androidx.compose.ui.graphics.Color.Transparent
|
||||
},
|
||||
).clickable {
|
||||
// Tapping the one already sent takes it back.
|
||||
model.reactToStory(if (mine == emoji) "" else emoji, story)
|
||||
}.padding(4.dp)
|
||||
.testTag("story-react-$emoji"),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text("Reply", fontSize = 13.sp, color = Palette.Accent,
|
||||
modifier = Modifier.clickable { replying = true }.testTag("story-reply"))
|
||||
Text(
|
||||
"Reply",
|
||||
fontSize = 13.sp,
|
||||
color = Palette.Accent,
|
||||
modifier = Modifier.clickable { replying = true }.testTag("story-reply"),
|
||||
)
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
// something anybody means to do, and offering it invites a misfire.
|
||||
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 since
|
||||
// stories shipped; here `MessengerViewModel.reportStory` was written,
|
||||
// tested by nothing, and called by no screen.
|
||||
if (!story.mine) {
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Text("Report", fontSize = 13.sp, color = Palette.Mist,
|
||||
modifier = Modifier.clickable { reporting = true }
|
||||
.testTag("story-report"))
|
||||
Text(
|
||||
"Report",
|
||||
fontSize = 13.sp,
|
||||
color = Palette.Mist,
|
||||
modifier =
|
||||
Modifier
|
||||
.clickable { reporting = true }
|
||||
.testTag("story-report"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (reporting) {
|
||||
// ⚠️ 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 —
|
||||
// is the part that matters.
|
||||
// The SAME sheet a message report uses, deliberately.
|
||||
ReportSheet(
|
||||
authorName = authorName.ifBlank { "this person" },
|
||||
preview = story.caption,
|
||||
@@ -348,16 +348,18 @@ 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
|
||||
// surprise in the wrong direction.
|
||||
// Says where it goes.
|
||||
text = {
|
||||
Column {
|
||||
Text("Goes to them in your conversation. Nobody else who "
|
||||
+ "saw this story can read it.", fontSize = 13.sp)
|
||||
Text(
|
||||
"Goes to them in your conversation. Nobody else who " +
|
||||
"saw this story can read it.",
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
OutlinedTextField(
|
||||
value = draft, onValueChange = { draft = it },
|
||||
value = draft,
|
||||
onValueChange = { draft = it },
|
||||
placeholder = { Text("Message") },
|
||||
modifier = Modifier.fillMaxWidth().testTag("story-reply-field"),
|
||||
)
|
||||
@@ -365,7 +367,10 @@ private fun StoryActions(
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { replying = false; model.replyToStory(draft.trim(), story) },
|
||||
onClick = {
|
||||
replying = false
|
||||
model.replyToStory(draft.trim(), story)
|
||||
},
|
||||
enabled = draft.isNotBlank(),
|
||||
modifier = Modifier.testTag("story-reply-send"),
|
||||
) { Text("Send") }
|
||||
@@ -378,18 +383,21 @@ private fun StoryActions(
|
||||
AlertDialog(
|
||||
onDismissRequest = { muting = false },
|
||||
title = { Text("Mute their stories?") },
|
||||
// ⚠️ 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.
|
||||
// Says what muting is NOT.
|
||||
text = {
|
||||
Text("Their stories stop showing on this phone. They aren't "
|
||||
+ "told, and their messages still arrive as usual. You "
|
||||
+ "can turn it back on from their contact page.",
|
||||
fontSize = 13.sp)
|
||||
Text(
|
||||
"Their stories stop showing on this phone. They aren't " +
|
||||
"told, and their messages still arrive as usual. You " +
|
||||
"can turn it back on from their contact page.",
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { muting = false; model.setStoriesMuted(story.authorId, true) },
|
||||
onClick = {
|
||||
muting = false
|
||||
model.setStoriesMuted(story.authorId, true)
|
||||
},
|
||||
modifier = Modifier.testTag("story-mute-confirm"),
|
||||
) { Text("Mute") }
|
||||
},
|
||||
@@ -401,7 +409,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.
|
||||
*/
|
||||
@Composable
|
||||
fun StoryComposerDialog(
|
||||
@@ -412,26 +420,33 @@ fun StoryComposerDialog(
|
||||
val caption = remember(draft) { androidx.compose.runtime.mutableStateOf("") }
|
||||
val streamUrl = remember(draft) { androidx.compose.runtime.mutableStateOf("") }
|
||||
// Whether the typed URL is one that could be disclosed to a viewer (§6.13).
|
||||
val streamOk = streamUrl.value.isBlank() ||
|
||||
io.flamenet.e2e.StreamDisclosure.acceptable(streamUrl.value)
|
||||
val streamOk =
|
||||
streamUrl.value.isBlank() ||
|
||||
io.flamenet.e2e.StreamDisclosure
|
||||
.acceptable(streamUrl.value)
|
||||
// Per-story audience, starting where the account setting points.
|
||||
val mode = remember(draft) {
|
||||
androidx.compose.runtime.mutableStateOf(phase.storyAudience.mode)
|
||||
}
|
||||
val chosen = remember(draft) {
|
||||
androidx.compose.runtime.mutableStateOf(phase.storyAudience.userIds)
|
||||
}
|
||||
val mode =
|
||||
remember(draft) {
|
||||
androidx.compose.runtime.mutableStateOf(phase.storyAudience.mode)
|
||||
}
|
||||
val chosen =
|
||||
remember(draft) {
|
||||
androidx.compose.runtime.mutableStateOf(phase.storyAudience.userIds)
|
||||
}
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val image = remember(draft) {
|
||||
runCatching {
|
||||
if (draft.duration > 0)
|
||||
StoryMedia.posterFrame(draft.bytes, context)?.asImageBitmap()
|
||||
else
|
||||
// Own draft, but a 50MP photo is just as much bitmap.
|
||||
ImagePrep.decodeBounded(draft.bytes)
|
||||
?.asImageBitmap()
|
||||
}.getOrNull()
|
||||
}
|
||||
val image =
|
||||
remember(draft) {
|
||||
runCatching {
|
||||
if (draft.duration > 0) {
|
||||
StoryMedia.posterFrame(draft.bytes, context)?.asImageBitmap()
|
||||
} else {
|
||||
// Own draft, but a 50MP photo is just as much bitmap.
|
||||
ImagePrep
|
||||
.decodeBounded(draft.bytes)
|
||||
?.asImageBitmap()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { model.discardStagedStory() },
|
||||
title = { Text("New story") },
|
||||
@@ -442,13 +457,17 @@ fun StoryComposerDialog(
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = it,
|
||||
contentDescription = "Story preview",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
)
|
||||
if (draft.duration > 0) {
|
||||
Text("▶ ${draft.duration.toInt()}s", fontSize = 22.sp,
|
||||
color = androidx.compose.ui.graphics.Color.White)
|
||||
Text(
|
||||
"▶ ${draft.duration.toInt()}s",
|
||||
fontSize = 22.sp,
|
||||
color = androidx.compose.ui.graphics.Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -458,11 +477,7 @@ fun StoryComposerDialog(
|
||||
placeholder = { Text("Add a caption…") },
|
||||
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 —
|
||||
// so the choice belongs here, before posting. Seeded from the
|
||||
// §6.13 — attach a live stream. The picked picture becomes its
|
||||
// poster: what people see before deciding whether to connect.
|
||||
// Who THIS story goes to.
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = streamUrl.value,
|
||||
onValueChange = { streamUrl.value = it },
|
||||
@@ -472,22 +487,24 @@ fun StoryComposerDialog(
|
||||
modifier = Modifier.fillMaxWidth().testTag("story-stream-field"),
|
||||
)
|
||||
if (streamUrl.value.isNotBlank()) {
|
||||
// 🔴 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.
|
||||
// Says what a stream costs the people watching, at the
|
||||
// moment of attaching one.
|
||||
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 " +
|
||||
"name in it."
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = if (streamOk) Palette.SecondaryText
|
||||
else MaterialTheme.colorScheme.error,
|
||||
color =
|
||||
if (streamOk) {
|
||||
Palette.SecondaryText
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -509,15 +526,18 @@ fun StoryComposerDialog(
|
||||
if (mode.value != Session.AudienceMode.EVERYONE) {
|
||||
phase.buddies.forEach { buddy ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
chosen.value =
|
||||
if (buddy.userId in chosen.value) chosen.value - buddy.userId
|
||||
else chosen.value + buddy.userId
|
||||
}
|
||||
.padding(vertical = 4.dp)
|
||||
.testTag("story-aud-${buddy.userId}"),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
chosen.value =
|
||||
if (buddy.userId in chosen.value) {
|
||||
chosen.value - buddy.userId
|
||||
} else {
|
||||
chosen.value + buddy.userId
|
||||
}
|
||||
}.padding(vertical = 4.dp)
|
||||
.testTag("story-aud-${buddy.userId}"),
|
||||
horizontalArrangement = Arrangement.spacedBy(Space.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -528,12 +548,17 @@ fun StoryComposerDialog(
|
||||
}
|
||||
Text(
|
||||
when (mode.value) {
|
||||
Session.AudienceMode.EVERYONE ->
|
||||
Session.AudienceMode.EVERYONE -> {
|
||||
"Goes to all your contacts and disappears after a day."
|
||||
Session.AudienceMode.ONLY ->
|
||||
}
|
||||
|
||||
Session.AudienceMode.ONLY -> {
|
||||
"Goes only to the ${chosen.value.size} chosen, and disappears after a day."
|
||||
Session.AudienceMode.EXCEPT ->
|
||||
}
|
||||
|
||||
Session.AudienceMode.EXCEPT -> {
|
||||
"Goes to everyone but the ${chosen.value.size} chosen, and disappears after a day."
|
||||
}
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
@@ -549,9 +574,7 @@ fun StoryComposerDialog(
|
||||
streamUrl.value,
|
||||
)
|
||||
},
|
||||
// ⚠️ 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.
|
||||
// Refuses to post a stream URL it could not disclose.
|
||||
enabled = streamOk,
|
||||
modifier = Modifier.testTag("story-post"),
|
||||
) { Text("Post") }
|
||||
@@ -564,11 +587,9 @@ fun StoryComposerDialog(
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The story cards themselves, everyone's stories, or one honest message when
|
||||
* there are none.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.StoriesCards(
|
||||
@@ -591,9 +612,9 @@ 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 "
|
||||
+ "your device and theirs, because their app deletes it too. "
|
||||
+ "It is not a secret you can take back.",
|
||||
"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,
|
||||
color = Palette.SecondaryText,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
@@ -608,18 +629,20 @@ 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
|
||||
* bubble, wrong for the kit's full-width post card.
|
||||
* A story's image at card size: fills the box, cropped.
|
||||
*/
|
||||
|
||||
/** A story's video, fetched, decrypted, and looping in the card. */
|
||||
@Composable
|
||||
private fun StoryVideoCard(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerViewModel) {
|
||||
val bytes = remember(ref.id) {
|
||||
androidx.compose.runtime.mutableStateOf<ByteArray?>(null)
|
||||
}
|
||||
private fun StoryVideoCard(
|
||||
ref: io.flamenet.e2e.Attachment.Ref,
|
||||
model: MessengerViewModel,
|
||||
) {
|
||||
val bytes =
|
||||
remember(ref.id) {
|
||||
androidx.compose.runtime.mutableStateOf<ByteArray?>(null)
|
||||
}
|
||||
val failed = remember(ref.id) { androidx.compose.runtime.mutableStateOf(false) }
|
||||
LaunchedEffect(ref.id) {
|
||||
val fetched = model.attachmentBytes(ref)
|
||||
@@ -627,57 +650,86 @@ private fun StoryVideoCard(ref: io.flamenet.e2e.Attachment.Ref, model: Messenger
|
||||
}
|
||||
val ready = bytes.value
|
||||
when {
|
||||
ready != null -> StoryVideoPlayer(ready, cacheKey = ref.id,
|
||||
modifier = Modifier.fillMaxSize())
|
||||
failed.value -> Text("Video unavailable", fontSize = 14.sp,
|
||||
color = Palette.SecondaryText)
|
||||
else -> Text("Loading…", fontSize = 14.sp, color = Palette.SecondaryText)
|
||||
ready != null -> {
|
||||
StoryVideoPlayer(
|
||||
ready,
|
||||
cacheKey = ref.id,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
failed.value -> {
|
||||
Text(
|
||||
"Video unavailable",
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Text("Loading…", fontSize = 14.sp, color = Palette.SecondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StoryImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerViewModel) {
|
||||
val bitmap = remember(ref.id) {
|
||||
androidx.compose.runtime.mutableStateOf<android.graphics.Bitmap?>(null)
|
||||
}
|
||||
private fun StoryImage(
|
||||
ref: io.flamenet.e2e.Attachment.Ref,
|
||||
model: MessengerViewModel,
|
||||
) {
|
||||
val bitmap =
|
||||
remember(ref.id) {
|
||||
androidx.compose.runtime.mutableStateOf<android.graphics.Bitmap?>(null)
|
||||
}
|
||||
val failed = remember(ref.id) { androidx.compose.runtime.mutableStateOf(false) }
|
||||
LaunchedEffect(ref.id) {
|
||||
val bytes = model.attachmentBytes(ref)
|
||||
if (bytes == null) { failed.value = true; return@LaunchedEffect }
|
||||
if (bytes == null) {
|
||||
failed.value = true
|
||||
return@LaunchedEffect
|
||||
}
|
||||
// Bounded: a received story's bytes are a peer's choice.
|
||||
bitmap.value = ImagePrep.decodeBounded(bytes)
|
||||
if (bitmap.value == null) failed.value = true
|
||||
}
|
||||
val loaded = bitmap.value
|
||||
when {
|
||||
loaded != null -> androidx.compose.foundation.Image(
|
||||
bitmap = loaded.asImageBitmap(),
|
||||
contentDescription = "Story image",
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
failed.value -> Text("Image unavailable", fontSize = 14.sp,
|
||||
color = Palette.SecondaryText)
|
||||
else -> Text("Loading…", fontSize = 14.sp, color = Palette.SecondaryText)
|
||||
loaded != null -> {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = loaded.asImageBitmap(),
|
||||
contentDescription = "Story image",
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
failed.value -> {
|
||||
Text(
|
||||
"Image unavailable",
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Text("Loading…", fontSize = 14.sp, color = Palette.SecondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A story that is only words.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
fun TextStoryCard(text: String) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(240.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.10f))
|
||||
.testTag("story-text"),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(240.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.10f))
|
||||
.testTag("story-text"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
@@ -691,15 +743,14 @@ fun TextStoryCard(text: String) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Write a story that is only words.
|
||||
*
|
||||
* A dialog rather than a screen: a text story is a line or two, and a
|
||||
* full-screen composer for that is the reason people do not post them.
|
||||
*/
|
||||
@Composable
|
||||
private fun TextStoryComposer(onDismiss: () -> Unit, onPost: (String) -> Unit) {
|
||||
private fun TextStoryComposer(
|
||||
onDismiss: () -> Unit,
|
||||
onPost: (String) -> Unit,
|
||||
) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
|
||||
@@ -4,39 +4,31 @@ import android.content.Context
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.widget.VideoView
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Staging a picked story, image or video, from either entry point.
|
||||
*
|
||||
* 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
|
||||
* remuxed to drop the container metadata before it goes out. See [MediaScrub];
|
||||
* "exactly as picked" used to include the capture location.
|
||||
*/
|
||||
object StoryMedia {
|
||||
|
||||
/** Duration in seconds, display dimensions, and the container rotation. */
|
||||
private data class Meta(
|
||||
val seconds: Double,
|
||||
@@ -45,42 +37,56 @@ 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
|
||||
|
||||
/**
|
||||
* Read the pick, validate it, and stage it on the model.
|
||||
* Reports refusals through the model's own error surface.
|
||||
*/
|
||||
fun stage(context: Context, uri: Uri, model: MessengerViewModel) {
|
||||
fun stage(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
model: MessengerViewModel,
|
||||
) {
|
||||
val mime = context.contentResolver.getType(uri) ?: "image/jpeg"
|
||||
|
||||
if (mime.startsWith("video/")) {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
val meta = runCatching {
|
||||
retriever.setDataSource(context, uri)
|
||||
val durationMs = retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
|
||||
?.toLongOrNull() ?: 0L
|
||||
var w = retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
|
||||
?.toIntOrNull() ?: 0
|
||||
var h = retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
|
||||
?.toIntOrNull() ?: 0
|
||||
val rotation = retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
|
||||
?.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
|
||||
// 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.
|
||||
Meta(durationMs / 1000.0, w, h, rotation)
|
||||
}.getOrNull().also { runCatching { retriever.release() } }
|
||||
val meta =
|
||||
runCatching {
|
||||
retriever.setDataSource(context, uri)
|
||||
val durationMs =
|
||||
retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
|
||||
?.toLongOrNull() ?: 0L
|
||||
var w =
|
||||
retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
|
||||
?.toIntOrNull() ?: 0
|
||||
var h =
|
||||
retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
|
||||
?.toIntOrNull() ?: 0
|
||||
val rotation =
|
||||
retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
|
||||
?.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
|
||||
// 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.
|
||||
Meta(durationMs / 1000.0, w, h, rotation)
|
||||
}.getOrNull().also { runCatching { retriever.release() } }
|
||||
|
||||
if (meta == null) {
|
||||
model.reportStoryProblem("That video couldn't be read.")
|
||||
@@ -89,21 +95,20 @@ 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 "
|
||||
+ "${seconds.toInt()}. Trim it and try again.")
|
||||
"Stories are capped at $MAX_DURATION_S seconds, this one is " +
|
||||
"${seconds.toInt()}. Trim it and try again.",
|
||||
)
|
||||
return
|
||||
}
|
||||
val picked = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()
|
||||
val picked =
|
||||
runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()
|
||||
if (picked == null) {
|
||||
model.reportStoryProblem("That video couldn't be read.")
|
||||
return
|
||||
}
|
||||
// 🔴 **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.
|
||||
// Where it was shot does not go with it.
|
||||
val bytes = MediaScrub.videoWithoutMetadata(picked, rotation, context.cacheDir)
|
||||
if (bytes == null) {
|
||||
model.reportStoryProblem("That video couldn't be prepared for sending.")
|
||||
@@ -111,27 +116,24 @@ object StoryMedia {
|
||||
}
|
||||
if (bytes.size > MAX_BYTES) {
|
||||
model.reportStoryProblem(
|
||||
"That video is too large to send — the cap is about 9 MB. "
|
||||
+ "A shorter or lower-resolution clip will fit.")
|
||||
"That video is too large to send, the cap is about 9 MB. " +
|
||||
"A shorter or lower-resolution clip will fit.",
|
||||
)
|
||||
return
|
||||
}
|
||||
model.stageStory(bytes, mime, w, h, duration = seconds)
|
||||
return
|
||||
}
|
||||
|
||||
val picked = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()
|
||||
val picked =
|
||||
runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()
|
||||
if (picked == null) {
|
||||
model.reportStoryProblem("That file couldn't be read.")
|
||||
return
|
||||
}
|
||||
// 🔴 **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
|
||||
// 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.
|
||||
// Prepared, not posted as picked.
|
||||
val prepared = ImagePrep.prepare(picked, MediaSettings(context).fullQuality)
|
||||
if (prepared == null) {
|
||||
model.reportStoryProblem("That picture couldn't be read.")
|
||||
@@ -141,7 +143,10 @@ object StoryMedia {
|
||||
}
|
||||
|
||||
/** The first frame, for the composer's preview of a staged video. */
|
||||
fun posterFrame(bytes: ByteArray, context: Context): android.graphics.Bitmap? {
|
||||
fun posterFrame(
|
||||
bytes: ByteArray,
|
||||
context: Context,
|
||||
): android.graphics.Bitmap? {
|
||||
val f = File.createTempFile("story-poster", ".mp4", context.cacheDir)
|
||||
return try {
|
||||
f.writeBytes(bytes)
|
||||
@@ -158,22 +163,22 @@ object StoryMedia {
|
||||
|
||||
/**
|
||||
* Plays a decrypted story video, looping, with sound.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
fun StoryVideoPlayer(bytes: ByteArray, cacheKey: String, modifier: Modifier = Modifier) {
|
||||
fun StoryVideoPlayer(
|
||||
bytes: ByteArray,
|
||||
cacheKey: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val file = remember(cacheKey) { mutableStateOf<File?>(null) }
|
||||
LaunchedEffect(cacheKey) {
|
||||
file.value = runCatching {
|
||||
val f = File.createTempFile("story-$cacheKey", ".mp4", context.cacheDir)
|
||||
f.writeBytes(bytes)
|
||||
f
|
||||
}.getOrNull()
|
||||
file.value =
|
||||
runCatching {
|
||||
val f = File.createTempFile("story-$cacheKey", ".mp4", context.cacheDir)
|
||||
f.writeBytes(bytes)
|
||||
f
|
||||
}.getOrNull()
|
||||
}
|
||||
val ready = file.value ?: return
|
||||
AndroidView(
|
||||
@@ -193,16 +198,6 @@ 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
|
||||
* 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
|
||||
* viewer's IP address and that they watched.
|
||||
*
|
||||
* So playback is an explicit tap with the host named, never autoplay. A story
|
||||
* that started playing on its own would make that disclosure after the fact,
|
||||
* which is not a disclosure.
|
||||
*/
|
||||
@Composable
|
||||
fun StoryStreamGate(
|
||||
@@ -233,7 +228,8 @@ fun StoryStreamGate(
|
||||
Text("Live stream", fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
io.flamenet.e2e.StreamDisclosure.text(host),
|
||||
io.flamenet.e2e.StreamDisclosure
|
||||
.text(host),
|
||||
fontSize = 12.sp,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
|
||||
@@ -8,38 +8,40 @@ 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
|
||||
* 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 —
|
||||
* a much larger ask than the feature.
|
||||
*/
|
||||
object StorySave {
|
||||
|
||||
/**
|
||||
* Save bytes to the gallery under Flamenet's own album.
|
||||
*
|
||||
* Returns the display name written, or null if the insert failed.
|
||||
*/
|
||||
fun save(context: Context, bytes: ByteArray, mime: String, id: String): String? {
|
||||
fun save(
|
||||
context: Context,
|
||||
bytes: ByteArray,
|
||||
mime: String,
|
||||
id: String,
|
||||
): String? {
|
||||
val isVideo = mime.startsWith("video/")
|
||||
val extension = if (isVideo) "mp4" else "jpg"
|
||||
val name = "flamenet-story-$id.$extension"
|
||||
val collection = if (isVideo) {
|
||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
} else {
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
}
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, name)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, mime)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val root = if (isVideo) Environment.DIRECTORY_MOVIES
|
||||
else Environment.DIRECTORY_PICTURES
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, "$root/Flamenet")
|
||||
val collection =
|
||||
if (isVideo) {
|
||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
} else {
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
}
|
||||
val values =
|
||||
ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, name)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, mime)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val root =
|
||||
if (isVideo) {
|
||||
Environment.DIRECTORY_MOVIES
|
||||
} else {
|
||||
Environment.DIRECTORY_PICTURES
|
||||
}
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, "$root/Flamenet")
|
||||
}
|
||||
}
|
||||
}
|
||||
val uri = context.contentResolver.insert(collection, values) ?: return null
|
||||
return runCatching {
|
||||
context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -11,6 +10,7 @@ import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
@@ -21,63 +21,38 @@ import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* The Flamenet design system.
|
||||
*
|
||||
* 🔴 **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,
|
||||
* 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
|
||||
* 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
|
||||
* claim was simply false and nobody noticed because nothing checks it.
|
||||
* **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.
|
||||
*/
|
||||
object Palette {
|
||||
// 🔴 **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
|
||||
// it. A constant called `Ember` holding blue is how a palette drifts back.
|
||||
val Accent = Color(0xFF2298FF) // buttons, bubbles, links
|
||||
// The design kit's palette, measured out of the file, not sampled from a
|
||||
// render and not approximated.
|
||||
val Accent = Color(0xFF2298FF) // buttons, bubbles, links
|
||||
val AccentBright = Color(0xFF4FACFE) // the kit's lighter primary swatch
|
||||
val AccentSoft = Color(0x1A2298FF)
|
||||
|
||||
val Teal = Color(0xFF3BD5B2) // success and presence
|
||||
val Pink = Color(0xFFED497C) // the kit's alert accent
|
||||
val Teal = Color(0xFF3BD5B2) // success and presence
|
||||
val Pink = Color(0xFFED497C) // the kit's alert accent
|
||||
|
||||
// Neutrals, all from the kit.
|
||||
val Ink = Color(0xFF090710)
|
||||
val Mist = Color(0xFFF9F9F9) // the page ground
|
||||
val SurfaceAlt = Color(0xFFF3F3F3) // received bubbles, pills, bands
|
||||
val 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
|
||||
// needs to read as a circle against the surface behind it, and two greys a
|
||||
// shade apart do not.
|
||||
|
||||
// A blue-tinted grey rather than the flat #F3F3F3: an initials avatar needs
|
||||
// to read as a circle against the surface behind it, and two greys a shade
|
||||
// apart do not.
|
||||
val AvatarFill = Color(0xFFE8F1FB)
|
||||
val SecondaryText = Color(0xFF505C69)
|
||||
val Divider = Color(0x1A505C69)
|
||||
|
||||
// Semantic
|
||||
// Semantic.
|
||||
val Success = Color(0xFF3BD5B2)
|
||||
val Warning = Color(0xFFB8860B)
|
||||
val Danger = Color(0xFFED497C)
|
||||
|
||||
// Dark-mode grounds — these ARE the site's, since the site is dark.
|
||||
val InkSurface = Color(0xFF121A2E) // --panel
|
||||
val NightGround = Color(0xFF0A0E1A) // --bg
|
||||
// Dark-mode grounds, these ARE the site's, since the site is dark.
|
||||
val InkSurface = Color(0xFF121A2E) // --panel
|
||||
val NightGround = Color(0xFF0A0E1A) // --bg
|
||||
}
|
||||
|
||||
/** Corner radii, matching `Theme.Radius` on iOS. */
|
||||
@@ -89,14 +64,9 @@ object Radius {
|
||||
}
|
||||
|
||||
/** The 4dp spacing scale, matching `Theme.Space` on iOS. */
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
object Kit {
|
||||
val margin = 24.dp
|
||||
@@ -111,8 +81,10 @@ object Kit {
|
||||
val pillRadius = 25.dp
|
||||
val icon = 24.dp
|
||||
val presenceDot = 12.dp
|
||||
|
||||
/** Every bubble corner but the one that points at its author. */
|
||||
val bubbleRadius = 16.dp
|
||||
|
||||
/** Not zero: a hard point reads as a rendering artefact at this size. */
|
||||
val bubbleTail = 2.dp
|
||||
}
|
||||
@@ -126,117 +98,103 @@ object Space {
|
||||
val xxl = 32.dp
|
||||
}
|
||||
|
||||
private val FlamenetShapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(Radius.small),
|
||||
small = RoundedCornerShape(Radius.small),
|
||||
medium = RoundedCornerShape(Radius.medium),
|
||||
large = RoundedCornerShape(Radius.large),
|
||||
extraLarge = RoundedCornerShape(Radius.large),
|
||||
)
|
||||
private val FlamenetShapes =
|
||||
Shapes(
|
||||
extraSmall = RoundedCornerShape(Radius.small),
|
||||
small = RoundedCornerShape(Radius.small),
|
||||
medium = RoundedCornerShape(Radius.medium),
|
||||
large = RoundedCornerShape(Radius.large),
|
||||
extraLarge = RoundedCornerShape(Radius.large),
|
||||
)
|
||||
|
||||
/**
|
||||
* The type scale.
|
||||
*
|
||||
* ⚠️ 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.**
|
||||
* 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
|
||||
* 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,
|
||||
* Description 12/10.
|
||||
*/
|
||||
private val FlamenetTypography = Typography().let { base ->
|
||||
base.copy(
|
||||
headlineLarge = base.headlineLarge.copy(fontSize = 32.sp, fontWeight = FontWeight.Bold),
|
||||
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.
|
||||
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),
|
||||
bodyMedium = base.bodyMedium.copy(fontSize = 14.sp),
|
||||
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.
|
||||
labelSmall = base.labelSmall.copy(fontSize = 10.sp, letterSpacing = 0.sp,
|
||||
fontWeight = FontWeight.Normal),
|
||||
)
|
||||
}
|
||||
private val FlamenetTypography =
|
||||
Typography().let { base ->
|
||||
base.copy(
|
||||
headlineLarge = base.headlineLarge.copy(fontSize = 32.sp, fontWeight = FontWeight.Bold),
|
||||
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.
|
||||
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),
|
||||
bodyMedium = base.bodyMedium.copy(fontSize = 14.sp),
|
||||
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.
|
||||
labelSmall =
|
||||
base.labelSmall.copy(
|
||||
fontSize = 10.sp,
|
||||
letterSpacing = 0.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The card: raised surface, generous radius, one soft shadow.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
fun Modifier.flamenetCard(
|
||||
padding: PaddingValues = PaddingValues(Space.lg),
|
||||
): Modifier = this
|
||||
.shadow(3.dp, RoundedCornerShape(Radius.medium), clip = false)
|
||||
.clip(RoundedCornerShape(Radius.medium))
|
||||
.padding(padding)
|
||||
fun Modifier.flamenetCard(padding: PaddingValues = PaddingValues(Space.lg)): Modifier =
|
||||
this
|
||||
.shadow(3.dp, RoundedCornerShape(Radius.medium), clip = false)
|
||||
.clip(RoundedCornerShape(Radius.medium))
|
||||
.padding(padding)
|
||||
|
||||
@Composable
|
||||
fun MessengerTheme(content: @Composable () -> Unit) {
|
||||
// ⚠️ Read here rather than passed in, so every entry point picks it up —
|
||||
// the app has more than one, and a theme applied at only some of them is
|
||||
// the kind of half-wiring that looks fine until somebody opens a call
|
||||
// screen from a notification.
|
||||
// Read here rather than passed in, so every entry point picks it up, the
|
||||
// app has more than one, and a theme applied at only some of them is the
|
||||
// kind of half-wiring that looks fine until somebody opens a call screen
|
||||
// from a notification.
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val preference = remember { DisplaySettings(context).appearance }
|
||||
val dark = when (preference) {
|
||||
DisplaySettings.Appearance.LIGHT -> false
|
||||
DisplaySettings.Appearance.DARK -> true
|
||||
DisplaySettings.Appearance.SYSTEM -> isSystemInDarkTheme()
|
||||
}
|
||||
val dark =
|
||||
when (preference) {
|
||||
DisplaySettings.Appearance.LIGHT -> false
|
||||
DisplaySettings.Appearance.DARK -> true
|
||||
DisplaySettings.Appearance.SYSTEM -> isSystemInDarkTheme()
|
||||
}
|
||||
MaterialTheme(
|
||||
colorScheme = if (dark) {
|
||||
darkColorScheme(
|
||||
primary = Palette.AccentBright,
|
||||
onPrimary = Color.White,
|
||||
secondary = Palette.Accent,
|
||||
background = Palette.NightGround,
|
||||
surface = Palette.InkSurface,
|
||||
surfaceVariant = Palette.InkSurface,
|
||||
onBackground = Palette.Mist,
|
||||
onSurface = Palette.Mist,
|
||||
onSurfaceVariant = Palette.SecondaryText,
|
||||
error = Palette.Danger,
|
||||
outline = Palette.Divider,
|
||||
)
|
||||
} else {
|
||||
lightColorScheme(
|
||||
// ⚠️ The kit's #2298FF on white is about 3.3:1 — fine for a
|
||||
// bold button label, not for small text. Anything small and
|
||||
// blue must use a darker step; the two places that do are the
|
||||
// section labels and the timestamps, and both are grey.
|
||||
primary = Palette.Accent,
|
||||
onPrimary = Color.White,
|
||||
secondary = Palette.AccentBright,
|
||||
background = Palette.Mist,
|
||||
surface = Color.White,
|
||||
surfaceVariant = Palette.SurfaceAlt,
|
||||
onBackground = Palette.Ink,
|
||||
onSurface = Palette.Ink,
|
||||
onSurfaceVariant = Palette.SecondaryText,
|
||||
error = Palette.Danger,
|
||||
outline = Palette.Divider,
|
||||
)
|
||||
},
|
||||
colorScheme =
|
||||
if (dark) {
|
||||
darkColorScheme(
|
||||
primary = Palette.AccentBright,
|
||||
onPrimary = Color.White,
|
||||
secondary = Palette.Accent,
|
||||
background = Palette.NightGround,
|
||||
surface = Palette.InkSurface,
|
||||
surfaceVariant = Palette.InkSurface,
|
||||
onBackground = Palette.Mist,
|
||||
onSurface = Palette.Mist,
|
||||
onSurfaceVariant = Palette.SecondaryText,
|
||||
error = Palette.Danger,
|
||||
outline = Palette.Divider,
|
||||
)
|
||||
} else {
|
||||
lightColorScheme(
|
||||
// The kit's #2298FF on white is about 3.3:1, fine for a
|
||||
// bold button label, not for small text.
|
||||
primary = Palette.Accent,
|
||||
onPrimary = Color.White,
|
||||
secondary = Palette.AccentBright,
|
||||
background = Palette.Mist,
|
||||
surface = Color.White,
|
||||
surfaceVariant = Palette.SurfaceAlt,
|
||||
onBackground = Palette.Ink,
|
||||
onSurface = Palette.Ink,
|
||||
onSurfaceVariant = Palette.SecondaryText,
|
||||
error = Palette.Danger,
|
||||
outline = Palette.Divider,
|
||||
)
|
||||
},
|
||||
shapes = FlamenetShapes,
|
||||
typography = FlamenetTypography,
|
||||
content = content,
|
||||
|
||||
@@ -6,20 +6,14 @@ import java.io.File
|
||||
|
||||
/**
|
||||
* Recording and playing voice notes.
|
||||
*
|
||||
* 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
|
||||
* encrypted and stored by somebody paying for it.
|
||||
*/
|
||||
object VoiceNote {
|
||||
|
||||
/** What a finished recording produced. */
|
||||
data class Recorded(val bytes: ByteArray, val mime: String, val seconds: Double) {
|
||||
data class Recorded(
|
||||
val bytes: ByteArray,
|
||||
val mime: String,
|
||||
val seconds: Double,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is Recorded && bytes.contentEquals(other.bytes) &&
|
||||
mime == other.mime && seconds == other.seconds
|
||||
@@ -31,10 +25,6 @@ object VoiceNote {
|
||||
|
||||
/**
|
||||
* A recording in progress.
|
||||
*
|
||||
* ⚠️ **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.
|
||||
*/
|
||||
class Recording internal constructor(
|
||||
private val recorder: MediaRecorder,
|
||||
@@ -43,29 +33,22 @@ object VoiceNote {
|
||||
) {
|
||||
/**
|
||||
* Stop, and return the bytes.
|
||||
*
|
||||
* ⚠️ **`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
|
||||
* is "there was nothing to record". Treated as an empty recording,
|
||||
* because that is what it is.
|
||||
*/
|
||||
fun stop(): Recorded? {
|
||||
val elapsed = (System.currentTimeMillis() - startedAtMillis) / 1000.0
|
||||
val captured = try {
|
||||
recorder.stop()
|
||||
true
|
||||
} catch (_: RuntimeException) {
|
||||
false
|
||||
} finally {
|
||||
recorder.release()
|
||||
}
|
||||
val captured =
|
||||
try {
|
||||
recorder.stop()
|
||||
true
|
||||
} catch (_: RuntimeException) {
|
||||
false
|
||||
} finally {
|
||||
recorder.release()
|
||||
}
|
||||
val bytes = if (captured && file.exists()) file.readBytes() else ByteArray(0)
|
||||
file.delete()
|
||||
// Below this, the container holds a header and nothing worth
|
||||
// sending. Better to drop it than to deliver a note that plays
|
||||
// silence and looks like a delivery bug.
|
||||
// sending.
|
||||
if (!captured || bytes.size < MIN_USEFUL_BYTES || elapsed < MIN_SECONDS) return null
|
||||
return Recorded(bytes, MIME, elapsed)
|
||||
}
|
||||
@@ -75,9 +58,7 @@ object VoiceNote {
|
||||
try {
|
||||
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
|
||||
// mind, not a failure.
|
||||
// Never started, or stopped with no frames.
|
||||
} finally {
|
||||
recorder.release()
|
||||
file.delete()
|
||||
@@ -93,25 +74,22 @@ object VoiceNote {
|
||||
|
||||
/**
|
||||
* Start recording into the cache directory.
|
||||
*
|
||||
* @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.
|
||||
* @throws IllegalStateException when the microphone is unavailable, most
|
||||
* often because `RECORD_AUDIO` has not been granted.
|
||||
*/
|
||||
fun start(cacheDir: File): Recording {
|
||||
val file = File.createTempFile("voice-", ".m4a", cacheDir)
|
||||
val recorder = MediaRecorder().apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
// Speech, not music. Mono at 32 kbit/s is clearly intelligible and
|
||||
// roughly a tenth the size of a stereo default nobody asked for.
|
||||
setAudioChannels(1)
|
||||
setAudioSamplingRate(44_100)
|
||||
setAudioEncodingBitRate(32_000)
|
||||
setOutputFile(file.absolutePath)
|
||||
}
|
||||
val recorder =
|
||||
MediaRecorder().apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
// Speech, not music.
|
||||
setAudioChannels(1)
|
||||
setAudioSamplingRate(44_100)
|
||||
setAudioEncodingBitRate(32_000)
|
||||
setOutputFile(file.absolutePath)
|
||||
}
|
||||
try {
|
||||
recorder.prepare()
|
||||
recorder.start()
|
||||
@@ -125,33 +103,37 @@ object VoiceNote {
|
||||
|
||||
/**
|
||||
* Play decrypted bytes.
|
||||
*
|
||||
* ⚠️ 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
|
||||
* finished with it is a leak, however briefly.
|
||||
*/
|
||||
class Player(private val cacheDir: File) {
|
||||
class Player(
|
||||
private val cacheDir: File,
|
||||
) {
|
||||
private var player: MediaPlayer? = null
|
||||
private var file: File? = null
|
||||
|
||||
fun play(bytes: ByteArray, onFinished: () -> Unit) {
|
||||
fun play(
|
||||
bytes: ByteArray,
|
||||
onFinished: () -> Unit,
|
||||
) {
|
||||
stop()
|
||||
val target = File.createTempFile("play-", ".m4a", cacheDir).also { it.writeBytes(bytes) }
|
||||
file = target
|
||||
player = MediaPlayer().apply {
|
||||
setDataSource(target.absolutePath)
|
||||
setOnCompletionListener {
|
||||
stop()
|
||||
onFinished()
|
||||
player =
|
||||
MediaPlayer().apply {
|
||||
setDataSource(target.absolutePath)
|
||||
setOnCompletionListener {
|
||||
stop()
|
||||
onFinished()
|
||||
}
|
||||
prepare()
|
||||
start()
|
||||
}
|
||||
prepare()
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
player?.let { runCatching { it.stop() }; it.release() }
|
||||
player?.let {
|
||||
runCatching { it.stop() }
|
||||
it.release()
|
||||
}
|
||||
player = null
|
||||
file?.delete()
|
||||
file = null
|
||||
|
||||
@@ -4,40 +4,34 @@ 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
|
||||
* 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
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
object WalletChain {
|
||||
|
||||
const val DEFAULT_ENDPOINT = "https://api.mainnet-beta.solana.com"
|
||||
|
||||
private const val PREFS = "wallet-chain"
|
||||
private const val ENDPOINT = "endpoint"
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/** The operator's own node, or null when the default is in use. */
|
||||
fun custom(context: Context): String? =
|
||||
prefs(context).getString(ENDPOINT, null)
|
||||
prefs(context)
|
||||
.getString(ENDPOINT, null)
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() && it.startsWith("https://") }
|
||||
|
||||
fun endpoint(context: Context): String = custom(context) ?: DEFAULT_ENDPOINT
|
||||
|
||||
fun setEndpoint(context: Context, url: String?) {
|
||||
fun setEndpoint(
|
||||
context: Context,
|
||||
url: String?,
|
||||
) {
|
||||
val cleaned = url?.trim().orEmpty()
|
||||
if (cleaned.isEmpty()) prefs(context).edit().remove(ENDPOINT).apply()
|
||||
else prefs(context).edit().putString(ENDPOINT, cleaned).apply()
|
||||
if (cleaned.isEmpty()) {
|
||||
prefs(context).edit().remove(ENDPOINT).apply()
|
||||
} else {
|
||||
prefs(context).edit().putString(ENDPOINT, cleaned).apply()
|
||||
}
|
||||
}
|
||||
|
||||
/** "Solana mainnet", or the host of whatever was configured. */
|
||||
@@ -48,25 +42,21 @@ object WalletChain {
|
||||
|
||||
/**
|
||||
* What a transfer costs, and what must stay behind.
|
||||
*
|
||||
* 🔴 **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
|
||||
* the reason survives.
|
||||
*/
|
||||
const val FEE_LAMPORTS = 5_000L
|
||||
const val RENT_EXEMPT_LAMPORTS = 890_880L
|
||||
|
||||
/** What can actually be sent from a balance, or zero. */
|
||||
fun spendable(lamports: Long): Long =
|
||||
(lamports - FEE_LAMPORTS - RENT_EXEMPT_LAMPORTS).coerceAtLeast(0)
|
||||
fun spendable(lamports: Long): Long = (lamports - FEE_LAMPORTS - RENT_EXEMPT_LAMPORTS).coerceAtLeast(0)
|
||||
|
||||
/** Lamports as SOL, for display only. */
|
||||
fun sol(lamports: Long): String {
|
||||
val whole = lamports / 1_000_000_000L
|
||||
val fraction = lamports % 1_000_000_000L
|
||||
return "%d.%09d".format(whole, fraction).trimEnd('0').trimEnd('.')
|
||||
return "%d.%09d"
|
||||
.format(whole, fraction)
|
||||
.trimEnd('0')
|
||||
.trimEnd('.')
|
||||
.ifEmpty { "0" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -38,17 +38,6 @@ import java.util.Locale
|
||||
|
||||
/**
|
||||
* The wallet.
|
||||
*
|
||||
* 🔴 **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
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.WalletScreen(
|
||||
@@ -60,12 +49,13 @@ fun ColumnScope.WalletScreen(
|
||||
val clipboard = LocalClipboardManager.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(Kit.margin)
|
||||
.testTag("wallet"),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(Kit.margin)
|
||||
.testTag("wallet"),
|
||||
verticalArrangement = Arrangement.spacedBy(Space.md),
|
||||
) {
|
||||
if (!wallet.exists) {
|
||||
@@ -73,10 +63,7 @@ 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
|
||||
// putting a balance in front of it invites them to skip it and come
|
||||
// back later, which for most people means never.
|
||||
// The phrase card sits ABOVE everything about the chain.
|
||||
wallet.phrase?.let { phrase -> PhraseCard(phrase, model, clipboard) }
|
||||
|
||||
BalanceCard(wallet, model, context)
|
||||
@@ -84,8 +71,12 @@ fun ColumnScope.WalletScreen(
|
||||
ActivityCard(wallet)
|
||||
|
||||
wallet.problem?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, fontSize = 13.sp,
|
||||
modifier = Modifier.testTag("wallet-problem"))
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.testTag("wallet-problem"),
|
||||
)
|
||||
}
|
||||
|
||||
if (wallet.phrase == null) {
|
||||
@@ -105,14 +96,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
|
||||
// facts that surprise people later are that it has nothing to do with
|
||||
// their Flamenet account, and that the phrase is the only backup.
|
||||
// Says what a wallet here IS before offering to make one.
|
||||
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 — "
|
||||
+ "and the recovery phrase is the only way back if this phone is "
|
||||
+ "erased or lost.",
|
||||
"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, " +
|
||||
"and the recovery phrase is the only way back if this phone is " +
|
||||
"erased or lost.",
|
||||
fontSize = 13.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -137,7 +126,10 @@ private fun ColumnScope.NoWallet(model: MessengerViewModel) {
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
KitSecondaryButton(
|
||||
text = "Restore from a phrase",
|
||||
onClick = { model.importWallet(phrase.trim()); phrase = "" },
|
||||
onClick = {
|
||||
model.importWallet(phrase.trim())
|
||||
phrase = ""
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "import-wallet",
|
||||
)
|
||||
@@ -145,11 +137,7 @@ private fun ColumnScope.NoWallet(model: MessengerViewModel) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **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
|
||||
* screenshot it.
|
||||
* Shown once, and the screen says what that means.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.PhraseCard(
|
||||
@@ -161,10 +149,10 @@ private fun ColumnScope.PhraseCard(
|
||||
Text("Your recovery phrase", fontWeight = FontWeight.Medium, fontSize = 16.sp)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
Text(
|
||||
"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.",
|
||||
"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.",
|
||||
fontSize = 13.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -173,25 +161,24 @@ private fun ColumnScope.PhraseCard(
|
||||
phrase,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
// ⚠️ `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.
|
||||
.background(Palette.Mist)
|
||||
.padding(Space.sm)
|
||||
.testTag("wallet-phrase"),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
// `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.
|
||||
.background(Palette.Mist)
|
||||
.padding(Space.sm)
|
||||
.testTag("wallet-phrase"),
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||
KitSecondaryButton(
|
||||
text = "Copy",
|
||||
// ⚠️ 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
|
||||
// option, not the first, and paper is what the text asks for.
|
||||
// Copying puts the phrase on a clipboard every other app can
|
||||
// read.
|
||||
onClick = { clipboard.setText(AnnotatedString(phrase)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "copy-phrase",
|
||||
@@ -223,18 +210,16 @@ private fun ColumnScope.BalanceCard(
|
||||
}
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
Text(
|
||||
// 🔴 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" } ?: "—",
|
||||
// An em dash, never "0.00".
|
||||
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 what the wallet holds.",
|
||||
"Not read yet. Nothing has been lost, this is what the app knows, " +
|
||||
"not what the wallet holds.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.testTag("balance-unknown"),
|
||||
@@ -260,18 +245,15 @@ 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
|
||||
// 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.
|
||||
// Says the address changes BEFORE somebody saves the old one somewhere.
|
||||
Text(
|
||||
"A new address each time you ask, so payments to you are not all "
|
||||
+ "tied together on a public ledger. Older addresses keep working.",
|
||||
"A new address each time you ask, so payments to you are not all " +
|
||||
"tied together on a public ledger. Older addresses keep working.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -301,17 +283,27 @@ private fun ColumnScope.ActivityCard(wallet: MessengerViewModel.WalletState) {
|
||||
val history = wallet.history
|
||||
when {
|
||||
// Same rule as the balance: unknown is not the same as none.
|
||||
history == null -> Text(
|
||||
"Not read yet.",
|
||||
fontSize = 13.sp, color = Palette.SecondaryText,
|
||||
modifier = Modifier.testTag("activity-unknown"),
|
||||
)
|
||||
history.isEmpty() -> Text(
|
||||
"Nothing yet.",
|
||||
fontSize = 13.sp, color = Palette.SecondaryText,
|
||||
modifier = Modifier.testTag("activity-empty"),
|
||||
)
|
||||
else -> history.forEach { entry -> ActivityRow(entry) }
|
||||
history == null -> {
|
||||
Text(
|
||||
"Not read yet.",
|
||||
fontSize = 13.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.testTag("activity-unknown"),
|
||||
)
|
||||
}
|
||||
|
||||
history.isEmpty() -> {
|
||||
Text(
|
||||
"Nothing yet.",
|
||||
fontSize = 13.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.testTag("activity-empty"),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
history.forEach { entry -> ActivityRow(entry) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,9 +322,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.
|
||||
// It has it and has not assigned a block time; showing 1970
|
||||
// would be the epoch leaking onto somebody's screen.
|
||||
// "Just now" for a transaction the node has not dated yet.
|
||||
entry.blockTimeSeconds?.let {
|
||||
SimpleDateFormat("d MMM HH:mm", Locale.getDefault())
|
||||
.format(Date(it * 1000))
|
||||
@@ -341,11 +331,14 @@ private fun ActivityRow(entry: io.flamenet.client.ChainRpc.Entry) {
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
// 🔴 A failed transaction says so. Not showing it would have somebody
|
||||
// believe a payment went through.
|
||||
// A failed transaction says so.
|
||||
if (entry.failed) {
|
||||
Text("Failed", fontSize = 12.sp, color = Palette.Pink,
|
||||
modifier = Modifier.testTag("tx-failed"))
|
||||
Text(
|
||||
"Failed",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.Pink,
|
||||
modifier = Modifier.testTag("tx-failed"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -353,11 +346,12 @@ private fun ActivityRow(entry: io.flamenet.client.ChainRpc.Entry) {
|
||||
@Composable
|
||||
private fun ColumnScope.Card(content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.medium))
|
||||
.background(Palette.SurfaceAlt)
|
||||
.padding(Space.md),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.medium))
|
||||
.background(Palette.SurfaceAlt)
|
||||
.padding(Space.md),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,35 +6,12 @@ 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
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
* whether somebody is locked out of their own messages.
|
||||
*/
|
||||
class AppLockBackoffTest {
|
||||
|
||||
// ---- The schedule ---------------------------------------------------
|
||||
// ---- The schedule ---------------------------------------------------.
|
||||
|
||||
/**
|
||||
* ⚠️ 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.
|
||||
* Three, not one.
|
||||
*/
|
||||
@Test
|
||||
fun `an ordinary slip costs nothing`() {
|
||||
@@ -52,10 +29,7 @@ class AppLockBackoffTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **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.
|
||||
* The ceiling is the important half.
|
||||
*/
|
||||
@Test
|
||||
fun `the wait is capped`() {
|
||||
@@ -65,20 +39,15 @@ class AppLockBackoffTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **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
|
||||
* "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.
|
||||
*
|
||||
* The steps are clamped before the shift. This is the test that says so.
|
||||
* 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.
|
||||
*/
|
||||
@Test
|
||||
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)")
|
||||
}
|
||||
}
|
||||
@@ -89,17 +58,19 @@ class AppLockBackoffTest {
|
||||
var previous = 0L
|
||||
for (failures in 0..40) {
|
||||
val wait = AppLock.delayMillis(failures)
|
||||
assertTrue(wait >= previous,
|
||||
"delayMillis($failures) = $wait dropped below $previous")
|
||||
assertTrue(
|
||||
wait >= previous,
|
||||
"delayMillis($failures) = $wait dropped below $previous",
|
||||
)
|
||||
previous = wait
|
||||
}
|
||||
}
|
||||
|
||||
// ---- How it is said -------------------------------------------------
|
||||
// ---- How it is said -------------------------------------------------.
|
||||
|
||||
/**
|
||||
* ⚠️ A lock screen that says "try again in 240 seconds" is one people read
|
||||
* as broken. The unit follows the size of the number.
|
||||
* A lock screen that says "try again in 240 seconds" is one people read as
|
||||
* broken.
|
||||
*/
|
||||
@Test
|
||||
fun `the wait is said in whichever unit reads naturally`() {
|
||||
@@ -123,11 +94,6 @@ 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
|
||||
* 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.
|
||||
*/
|
||||
private fun sayWait(seconds: Int): String =
|
||||
if (seconds >= 60) {
|
||||
@@ -141,7 +107,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))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ import io.flamenet.client.RelayClient
|
||||
import io.flamenet.e2e.Attachment
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.json.JSONObject
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
@@ -14,47 +19,51 @@ import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.json.JSONObject
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Cached plaintext goes when nothing refers to it any more.
|
||||
*
|
||||
* 🔴 **`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,
|
||||
* spec §6.11).
|
||||
*
|
||||
* ⚠️ 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.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class AttachmentPurgeTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var cache: File
|
||||
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
cache = File.createTempFile("cache", "").let { it.delete(); it.mkdirs(); it }
|
||||
cache =
|
||||
File.createTempFile("cache", "").let {
|
||||
it.delete()
|
||||
it.mkdirs()
|
||||
it
|
||||
}
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
cache.deleteRecursively()
|
||||
}
|
||||
@AfterTest fun stop() { server.shutdown(); cache.deleteRecursively() }
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
private val records = mutableMapOf<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun key(): SecretKey =
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
private lateinit var vault: Vault
|
||||
|
||||
@@ -62,25 +71,39 @@ class AttachmentPurgeTest {
|
||||
val identity = DeviceIdentity.create()
|
||||
vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) }
|
||||
val base = server.url("/").toString()
|
||||
return Session("alice", 1, identity, vault,
|
||||
return Session(
|
||||
"alice",
|
||||
1,
|
||||
identity,
|
||||
vault,
|
||||
RelayClient(base, "alice", "app pass word"),
|
||||
MessengerApi(base, "alice", "app pass word"))
|
||||
MessengerApi(base, "alice", "app pass word"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ref(id: String) = Attachment.Ref(
|
||||
kind = Attachment.IMAGE, id = id,
|
||||
key = ByteArray(32) { 3 }, digest = ByteArray(32) { 4 },
|
||||
bytes = 9, mime = "image/jpeg", width = 1, height = 1,
|
||||
)
|
||||
private fun ref(id: String) =
|
||||
Attachment.Ref(
|
||||
kind = Attachment.IMAGE,
|
||||
id = id,
|
||||
key = ByteArray(32) { 3 },
|
||||
digest = ByteArray(32) { 4 },
|
||||
bytes = 9,
|
||||
mime = "image/jpeg",
|
||||
width = 1,
|
||||
height = 1,
|
||||
)
|
||||
|
||||
/** A cache file as `attachmentBytes` would have written it, aged past the grace. */
|
||||
private fun cached(id: String, ageMillis: Long = 10 * 60_000L): File =
|
||||
private fun cached(
|
||||
id: String,
|
||||
ageMillis: Long = 10 * 60_000L,
|
||||
): File =
|
||||
File(cache, "att-" + id.replace('/', '_')).apply {
|
||||
writeBytes("decrypted".toByteArray())
|
||||
setLastModified(System.currentTimeMillis() - ageMillis)
|
||||
}
|
||||
|
||||
// ---- The bug ----------------------------------------------------------
|
||||
// ---- The bug ----------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `an expired message's cached plaintext is deleted`() {
|
||||
@@ -92,13 +115,18 @@ class AttachmentPurgeTest {
|
||||
assertFalse(orphan.exists(), "the plaintext of an expired message is still on disk")
|
||||
}
|
||||
|
||||
// ---- What must survive ------------------------------------------------
|
||||
// ---- What must survive ------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `an attachment a live message still refers to survives`() {
|
||||
val s = session()
|
||||
vault.append("user:2", JSONObject().put("at", 1L).put("text", "")
|
||||
.put("att", ref("still-shown").toJson()))
|
||||
vault.append(
|
||||
"user:2",
|
||||
JSONObject()
|
||||
.put("at", 1L)
|
||||
.put("text", "")
|
||||
.put("att", ref("still-shown").toJson()),
|
||||
)
|
||||
val live = cached("still-shown")
|
||||
|
||||
assertEquals(0, s.purgeOrphanedAttachments(cache))
|
||||
@@ -106,16 +134,27 @@ 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.
|
||||
*/
|
||||
@Test
|
||||
fun `an archived story's attachment survives, long after it expired`() {
|
||||
val s = session()
|
||||
vault.saveStoryArchive(listOf(Vault.Story(
|
||||
id = "s1", authorId = 1L, mine = true, ref = ref("archived"),
|
||||
caption = "", postedAt = 0L, seen = true, expiresAt = 1.0)))
|
||||
vault.saveStoryArchive(
|
||||
listOf(
|
||||
Vault.Story(
|
||||
id = "s1",
|
||||
authorId = 1L,
|
||||
mine = true,
|
||||
ref = ref("archived"),
|
||||
caption = "",
|
||||
postedAt = 0L,
|
||||
seen = true,
|
||||
expiresAt = 1.0,
|
||||
),
|
||||
),
|
||||
)
|
||||
val archived = cached("archived")
|
||||
|
||||
assertEquals(0, s.purgeOrphanedAttachments(cache))
|
||||
@@ -128,38 +167,39 @@ class AttachmentPurgeTest {
|
||||
val s = session()
|
||||
vault.saveAvatar(7L, ref("their-face"))
|
||||
vault.saveMyAvatar(ref("my-face"))
|
||||
val a = cached("their-face"); val b = cached("my-face")
|
||||
val a = cached("their-face")
|
||||
val b = cached("my-face")
|
||||
|
||||
assertEquals(0, s.purgeOrphanedAttachments(cache))
|
||||
assertTrue(a.exists() && b.exists(), "an avatar was deleted as an orphan")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **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
|
||||
* its id is deleted while still on screen.
|
||||
*
|
||||
* ⚠️ 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.
|
||||
* An id containing a slash, which is the reason the transformation exists.
|
||||
*/
|
||||
@Test
|
||||
fun `an id containing a slash is matched against its mangled filename`() {
|
||||
val s = session()
|
||||
vault.append("user:2", JSONObject().put("at", 1L).put("text", "")
|
||||
.put("att", ref("blobs/2026/09/live-one").toJson()))
|
||||
vault.append(
|
||||
"user:2",
|
||||
JSONObject()
|
||||
.put("at", 1L)
|
||||
.put("text", "")
|
||||
.put("att", ref("blobs/2026/09/live-one").toJson()),
|
||||
)
|
||||
val live = cached("blobs/2026/09/live-one")
|
||||
assertEquals("att-blobs_2026_09_live-one", live.name)
|
||||
|
||||
assertEquals(0, s.purgeOrphanedAttachments(cache),
|
||||
"an attachment whose id contains a slash was treated as an orphan")
|
||||
assertEquals(
|
||||
0,
|
||||
s.purgeOrphanedAttachments(cache),
|
||||
"an attachment whose id contains a slash was treated as an orphan",
|
||||
)
|
||||
assertTrue(live.exists())
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 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
|
||||
@@ -180,12 +220,10 @@ class AttachmentPurgeTest {
|
||||
assertTrue(other.exists(), "a file that is not an attachment cache entry was deleted")
|
||||
}
|
||||
|
||||
// ---- Failing closed ---------------------------------------------------
|
||||
// ---- Failing closed ---------------------------------------------------.
|
||||
|
||||
/**
|
||||
* 🔴 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.
|
||||
* If the in-use set cannot be built, NOTHING is deleted.
|
||||
*/
|
||||
@Test
|
||||
fun `an unreadable vault record stops the sweep entirely`() {
|
||||
@@ -193,20 +231,26 @@ class AttachmentPurgeTest {
|
||||
val orphan = cached("would-have-gone")
|
||||
// A record the vault cannot open at all.
|
||||
vault.append("user:9", JSONObject().put("at", 1L))
|
||||
val storage = Vault::class.java.getDeclaredField("storage")
|
||||
.apply { isAccessible = true }.get(vault) as Vault.Storage
|
||||
val storage =
|
||||
Vault::class.java
|
||||
.getDeclaredField("storage")
|
||||
.apply { isAccessible = true }
|
||||
.get(vault) as Vault.Storage
|
||||
storage.write("history/user:9", ByteArray(30) { it.toByte() })
|
||||
|
||||
assertEquals(0, s.purgeOrphanedAttachments(cache),
|
||||
"the sweep ran on a partial view of what is in use")
|
||||
assertEquals(
|
||||
0,
|
||||
s.purgeOrphanedAttachments(cache),
|
||||
"the sweep ran on a partial view of what is in use",
|
||||
)
|
||||
assertTrue(orphan.exists())
|
||||
}
|
||||
|
||||
// ---- The throttle -----------------------------------------------------
|
||||
// ---- 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`() {
|
||||
@@ -216,7 +260,9 @@ class AttachmentPurgeTest {
|
||||
cached("second")
|
||||
assertEquals(0, s.purgeOrphanedAttachments(cache), "the throttle did not hold")
|
||||
// ...and it does run again once the interval has passed.
|
||||
assertEquals(1, s.purgeOrphanedAttachments(
|
||||
cache, now = System.currentTimeMillis() + 11 * 60_000L))
|
||||
assertEquals(
|
||||
1,
|
||||
s.purgeOrphanedAttachments(cache, now = System.currentTimeMillis() + 11 * 60_000L),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,41 +6,21 @@ 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
|
||||
* 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
|
||||
* send path refusing to encrypt to an unreviewed identity, which is TOFU
|
||||
* 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
|
||||
* 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
|
||||
* about the substance of the sentence rather than its exact wording.
|
||||
*/
|
||||
class ChangedKeyIsExplainedTest {
|
||||
|
||||
private val message = describeFailure(IdentityChangedException(7L))
|
||||
|
||||
@Test
|
||||
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
|
||||
* 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.
|
||||
* The actionable half.
|
||||
*/
|
||||
@Test
|
||||
fun `it says where to go and what to do`() {
|
||||
@@ -52,7 +32,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(
|
||||
|
||||
@@ -6,29 +6,11 @@ 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
|
||||
* 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
|
||||
* 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
|
||||
* test, so it costs no emulator.
|
||||
*/
|
||||
class DestructiveLabelsTest {
|
||||
|
||||
private fun uiSources(): List<File> {
|
||||
// From `app/build/classes/...` back to the module root, whatever the
|
||||
// working directory happens to be. Resolving against a relative path
|
||||
// is how this kind of test silently scans nothing.
|
||||
// working directory happens to be.
|
||||
var dir = File(System.getProperty("user.dir"))
|
||||
while (!File(dir, "app/src/main/kotlin").isDirectory && dir.parentFile != null) {
|
||||
dir = dir.parentFile
|
||||
@@ -38,29 +20,21 @@ class DestructiveLabelsTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ **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.
|
||||
* Comments are stripped, and that is required rather than tidy.
|
||||
*/
|
||||
private fun codeLines(body: String): List<String> =
|
||||
body.lines()
|
||||
body
|
||||
.lines()
|
||||
.filterNot { it.trimStart().startsWith("//") || it.trimStart().startsWith("*") }
|
||||
.map { it.substringBefore("//") }
|
||||
|
||||
/**
|
||||
* 🔴 **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
|
||||
* the button eventually says must not promise more than "we asked them to
|
||||
* remove it."
|
||||
*
|
||||
* When that lands, this test is the thing to update, deliberately, in the
|
||||
* same change.
|
||||
* The word promises the recipient's copy is gone.
|
||||
*/
|
||||
@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,15 +49,13 @@ class DestructiveLabelsTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 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.
|
||||
* The walk is the part most likely to rot.
|
||||
*/
|
||||
@Test
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,21 +5,11 @@ 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
|
||||
* 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.
|
||||
*/
|
||||
class DisplaySettingsTest {
|
||||
|
||||
/**
|
||||
* 🔴 **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
|
||||
* and is drawn in another.
|
||||
* 16sp with the setting off, because that is what the kit specifies for a
|
||||
* message body.
|
||||
*/
|
||||
@Test
|
||||
fun `message text is the kit size until larger type is asked for`() {
|
||||
@@ -28,16 +18,17 @@ class DisplaySettingsTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 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.
|
||||
* An unrecognised stored value reads as `system`, the state somebody who
|
||||
* never touched this has.
|
||||
*/
|
||||
@Test
|
||||
fun `an unknown appearance falls back to matching the phone`() {
|
||||
assertEquals(DisplaySettings.Appearance.SYSTEM, DisplaySettings.Appearance.from(null))
|
||||
assertEquals(DisplaySettings.Appearance.SYSTEM, DisplaySettings.Appearance.from(""))
|
||||
assertEquals(DisplaySettings.Appearance.SYSTEM,
|
||||
DisplaySettings.Appearance.from("solarized-from-a-later-build"))
|
||||
assertEquals(
|
||||
DisplaySettings.Appearance.SYSTEM,
|
||||
DisplaySettings.Appearance.from("solarized-from-a-later-build"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -9,27 +9,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 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
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
class EditForwardPinWireTest {
|
||||
|
||||
private fun parse(json: String): Content = Content.parse(json)!!
|
||||
|
||||
// ---- Forward (§6.16) -------------------------------------------------
|
||||
// ---- Forward (§6.16) -------------------------------------------------.
|
||||
|
||||
/**
|
||||
* 🔴 The whole point of the feature: forwarded, and no trail back.
|
||||
* The point of the feature: forwarded, and no trail back.
|
||||
*/
|
||||
@Test
|
||||
fun `a forward carries the words and nothing about their origin`() {
|
||||
@@ -38,7 +25,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,9 +33,7 @@ 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
|
||||
* which would silently rewrite the other.
|
||||
* A forward gets a NEW id, not the original's.
|
||||
*/
|
||||
@Test
|
||||
fun `a forward is a new message, not the old one re-sent`() {
|
||||
@@ -60,23 +45,28 @@ class EditForwardPinWireTest {
|
||||
/** A forward into a group carries the gid, and still no origin. */
|
||||
@Test
|
||||
fun `a group forward carries the group and still no origin`() {
|
||||
val sent = parse(
|
||||
Content.groupText("passed along", groupId = "gid-1",
|
||||
messageId = "mid-new", forwarded = true),
|
||||
)
|
||||
val sent =
|
||||
parse(
|
||||
Content.groupText(
|
||||
"passed along",
|
||||
groupId = "gid-1",
|
||||
messageId = "mid-new",
|
||||
forwarded = true,
|
||||
),
|
||||
)
|
||||
assertEquals("gid-1", sent.groupId)
|
||||
assertTrue(sent.isForwarded)
|
||||
assertNull(sent.about, "a group forward is carrying a reference to its source")
|
||||
}
|
||||
|
||||
// ---- Edit (§6.15) ----------------------------------------------------
|
||||
// ---- Edit (§6.15) ----------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `an edit names the message it replaces and carries the new wording`() {
|
||||
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)
|
||||
}
|
||||
@@ -88,12 +78,10 @@ class EditForwardPinWireTest {
|
||||
assertEquals("mid-1", sent.editOf)
|
||||
}
|
||||
|
||||
// ---- Pin (§6.19) -----------------------------------------------------
|
||||
// ---- 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
|
||||
* that are no longer in the conversation.
|
||||
* An id and never a copy.
|
||||
*/
|
||||
@Test
|
||||
fun `a pin names an id and carries no copy of the message`() {
|
||||
@@ -118,12 +106,10 @@ class EditForwardPinWireTest {
|
||||
assertEquals("mid-1", sent.pins)
|
||||
}
|
||||
|
||||
// ---- The three do not collide ----------------------------------------
|
||||
// ---- The three do not collide ----------------------------------------.
|
||||
|
||||
/**
|
||||
* ⚠️ 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.
|
||||
* Each envelope sets exactly one of the three fields.
|
||||
*/
|
||||
@Test
|
||||
fun `each control sets exactly one of the three fields`() {
|
||||
|
||||
@@ -5,26 +5,19 @@ import org.junit.Assert.assertTrue
|
||||
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
|
||||
* `runCatching { … }.getOrDefault(emptyList())`, so a hiccup, an expired token
|
||||
* 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.
|
||||
* "Nobody here yet" is a claim about the ACCOUNT.
|
||||
*/
|
||||
class EmptyVersusFailedTest {
|
||||
|
||||
private fun phase(loaded: Boolean) = MessengerViewModel.Phase.SignedIn(
|
||||
handle = "someone",
|
||||
userId = 1,
|
||||
deviceId = "d",
|
||||
relay = "relay.example",
|
||||
status = "",
|
||||
buddies = emptyList(),
|
||||
buddiesLoaded = loaded,
|
||||
)
|
||||
private fun phase(loaded: Boolean) =
|
||||
MessengerViewModel.Phase.SignedIn(
|
||||
handle = "someone",
|
||||
userId = 1,
|
||||
deviceId = "d",
|
||||
relay = "relay.example",
|
||||
status = "",
|
||||
buddies = emptyList(),
|
||||
buddiesLoaded = loaded,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `an empty roster that loaded is not the same state as one that failed`() {
|
||||
@@ -35,11 +28,17 @@ class EmptyVersusFailedTest {
|
||||
fun `a failed load does not default to claiming the roster loaded`() {
|
||||
// The default matters: a phase built without saying so must not assert
|
||||
// that the fetch succeeded.
|
||||
val untouched = MessengerViewModel.Phase.SignedIn(
|
||||
handle = "someone", userId = 1, deviceId = "d",
|
||||
relay = "relay.example", status = "",
|
||||
val untouched =
|
||||
MessengerViewModel.Phase.SignedIn(
|
||||
handle = "someone",
|
||||
userId = 1,
|
||||
deviceId = "d",
|
||||
relay = "relay.example",
|
||||
status = "",
|
||||
)
|
||||
assertTrue(
|
||||
"the default must be 'not loaded', so the copy stays honest",
|
||||
!untouched.buddiesLoaded,
|
||||
)
|
||||
assertTrue("the default must be 'not loaded', so the copy stays honest",
|
||||
!untouched.buddiesLoaded)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,19 +6,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
|
||||
* 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.
|
||||
*
|
||||
* These four, in this order, are `Vocabularies.local` in
|
||||
* `Sources/Views/Files/FilesView.swift` on iOS. If that list changes, both have
|
||||
* to change together and this test is where somebody is made to notice.
|
||||
*/
|
||||
class FileLevelsTest {
|
||||
|
||||
@Test
|
||||
fun `the four local levels match the ordinals iOS files with`() {
|
||||
assertEquals(
|
||||
@@ -29,7 +18,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,12 +28,11 @@ 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
|
||||
// empty chip for it would show a row with no classification at all,
|
||||
// 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")
|
||||
// A file can arrive from a client with a wider vocabulary.
|
||||
assertTrue(
|
||||
FileLevels.levelName(9).contains("9"),
|
||||
"an unknown level must still say WHICH level, not render as blank",
|
||||
)
|
||||
assertTrue(FileLevels.levelName(-1).isNotBlank())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,26 +9,23 @@ 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
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
class FileShareWireTest {
|
||||
|
||||
private fun ref(classification: Int, compartments: List<Int>, name: String = "Q3.txt") =
|
||||
Attachment.Ref(
|
||||
kind = Attachment.FILE, id = "blob-1",
|
||||
key = ByteArray(32) { 1 }, digest = ByteArray(32) { 2 },
|
||||
bytes = 4096, mime = "application/octet-stream",
|
||||
filename = name, classification = classification, compartments = compartments,
|
||||
)
|
||||
private fun ref(
|
||||
classification: Int,
|
||||
compartments: List<Int>,
|
||||
name: String = "Q3.txt",
|
||||
) = Attachment.Ref(
|
||||
kind = Attachment.FILE,
|
||||
id = "blob-1",
|
||||
key = ByteArray(32) { 1 },
|
||||
digest = ByteArray(32) { 2 },
|
||||
bytes = 4096,
|
||||
mime = "application/octet-stream",
|
||||
filename = name,
|
||||
classification = classification,
|
||||
compartments = compartments,
|
||||
)
|
||||
|
||||
private fun roundTrip(r: Attachment.Ref): Attachment.Ref {
|
||||
val parsed = assertNotNull(Content.parse(Content.attachment(r)))
|
||||
@@ -44,11 +41,7 @@ class FileShareWireTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **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
|
||||
* the recipient has to guess about.
|
||||
* Unclassified must round-trip as a real value, not as an absence.
|
||||
*/
|
||||
@Test
|
||||
fun `an unclassified file is still a file, not a missing label`() {
|
||||
@@ -59,10 +52,7 @@ class FileShareWireTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 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.
|
||||
* A file envelope always carries a caption, even when nobody wrote one.
|
||||
*/
|
||||
@Test
|
||||
fun `a file with no caption still says what it is to an older build`() {
|
||||
@@ -75,16 +65,14 @@ class FileShareWireTest {
|
||||
|
||||
@Test
|
||||
fun `a caption somebody wrote is not replaced by the filename`() {
|
||||
val parsed = assertNotNull(
|
||||
Content.parse(Content.attachment(ref(1, emptyList()), caption = "the numbers")))
|
||||
val parsed =
|
||||
assertNotNull(Content.parse(Content.attachment(ref(1, emptyList()), caption = "the numbers")))
|
||||
assertEquals("the numbers", parsed.text)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 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
|
||||
* because the canonical encoding is authenticated data elsewhere.
|
||||
* Compartment order is normalised on the way out, so two senders who chose
|
||||
* the same areas produce the same bytes.
|
||||
*/
|
||||
@Test
|
||||
fun `compartments arrive in a stable order`() {
|
||||
@@ -92,16 +80,21 @@ 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
|
||||
* classification here to respect, and there is not.
|
||||
* An image must NOT gain a label.
|
||||
*/
|
||||
@Test
|
||||
fun `an image carries no label at all`() {
|
||||
val image = Attachment.Ref(
|
||||
kind = Attachment.IMAGE, id = "b", key = ByteArray(32), digest = ByteArray(32),
|
||||
bytes = 10, mime = "image/jpeg", width = 4, height = 4,
|
||||
)
|
||||
val image =
|
||||
Attachment.Ref(
|
||||
kind = Attachment.IMAGE,
|
||||
id = "b",
|
||||
key = ByteArray(32),
|
||||
digest = ByteArray(32),
|
||||
bytes = 10,
|
||||
mime = "image/jpeg",
|
||||
width = 4,
|
||||
height = 4,
|
||||
)
|
||||
val json = org.json.JSONObject(Content.attachment(image))
|
||||
assertTrue(!json.has("cls"), "an image envelope is claiming a classification")
|
||||
assertTrue(!json.has("cmp"), "an image envelope is claiming compartments")
|
||||
|
||||
@@ -6,30 +6,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
|
||||
* 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
|
||||
* 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.**
|
||||
* 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
|
||||
* guard there can be.
|
||||
*/
|
||||
class FilesUsesTheAccountPassphraseTest {
|
||||
|
||||
private fun source(name: String): String {
|
||||
var dir = File(System.getProperty("user.dir"))
|
||||
while (!File(dir, "app/src/main/kotlin").isDirectory && dir.parentFile != null) {
|
||||
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,9 +36,7 @@ class FilesUsesTheAccountPassphraseTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 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.
|
||||
* The exact phrase that caused it.
|
||||
*/
|
||||
@Test
|
||||
fun `no screen tells somebody to invent a second secret`() {
|
||||
@@ -61,7 +44,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",
|
||||
)
|
||||
|
||||
@@ -9,6 +9,9 @@ import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.AfterTest
|
||||
@@ -16,32 +19,16 @@ import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
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`.**
|
||||
* 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
|
||||
* 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
|
||||
// module, so its unit-test classpath has the stubbed `org.json` that returns
|
||||
// null from everything, and the vault stores JSON.
|
||||
// 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)
|
||||
@Config(sdk = [34])
|
||||
class GroupNameClaimTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
/** Who the relay will say is in the group, for this run. */
|
||||
@@ -49,68 +36,101 @@ 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
|
||||
* on order hands one of those the other's body and fails on a parse error
|
||||
* a long way from the cause.
|
||||
*/
|
||||
private inner class Relay : okhttp3.mockwebserver.Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
return when {
|
||||
"/e2e/log/" in path -> MockResponse().setResponseCode(404)
|
||||
"/e2e/groups" in path -> json(
|
||||
"""{"groups":[{"gid":"g1","name":"Group","owner":7,"members":[""" +
|
||||
roster.joinToString(",") { """{"user_id":$it}""" } + "]}]}")
|
||||
"/e2e/messages" in path -> json("""{"messages":[]}""")
|
||||
else -> json("{}")
|
||||
"/e2e/log/" in path -> {
|
||||
MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
"/e2e/groups" in path -> {
|
||||
json(
|
||||
"""{"groups":[{"gid":"g1","name":"Group","owner":7,"members":[""" +
|
||||
roster.joinToString(",") { """{"user_id":$it}""" } + "]}]}",
|
||||
)
|
||||
}
|
||||
|
||||
"/e2e/messages" in path -> {
|
||||
json("""{"messages":[]}""")
|
||||
}
|
||||
|
||||
else -> {
|
||||
json("{}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.dispatcher = Relay(); it.start() }
|
||||
server =
|
||||
MockWebServer().also {
|
||||
it.dispatcher = Relay()
|
||||
it.start()
|
||||
}
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
private val records = mutableMapOf<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun key(): SecretKey =
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
private fun session(): Pair<Session, Vault> {
|
||||
val identity = DeviceIdentity.create()
|
||||
val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) }
|
||||
val base = server.url("/").toString()
|
||||
val s = Session(
|
||||
handle = "alice",
|
||||
userId = 1,
|
||||
identity = identity,
|
||||
vault = vault,
|
||||
relay = RelayClient(base, "alice", "app pass word"),
|
||||
api = MessengerApi(base, "alice", "app pass word"),
|
||||
)
|
||||
val s =
|
||||
Session(
|
||||
handle = "alice",
|
||||
userId = 1,
|
||||
identity = identity,
|
||||
vault = vault,
|
||||
relay = RelayClient(base, "alice", "app pass word"),
|
||||
api = MessengerApi(base, "alice", "app pass word"),
|
||||
)
|
||||
return s to vault
|
||||
}
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
/** Put a claim in the queue the way an arriving announcement does. */
|
||||
private fun claim(session: Session, fromUser: Long, gid: String, name: String) {
|
||||
private fun claim(
|
||||
session: Session,
|
||||
fromUser: Long,
|
||||
gid: String,
|
||||
name: String,
|
||||
) {
|
||||
session.conversations.consumeControlForTesting(
|
||||
fromUser,
|
||||
Content.parse(SealedSender.deliveryKeyEnvelope(
|
||||
ByteArray(32), groupId = gid, groupName = name)))
|
||||
Content.parse(
|
||||
SealedSender.deliveryKeyEnvelope(ByteArray(32), groupId = gid, groupName = name),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -125,8 +145,8 @@ class GroupNameClaimTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The whole point. A stranger naming a group they are not in must get
|
||||
* nowhere, however well-formed their envelope is.
|
||||
* A stranger naming a group they are not in must get nowhere, however well-
|
||||
* formed their envelope is.
|
||||
*/
|
||||
@Test
|
||||
fun `a name from a non-member is refused`() {
|
||||
@@ -140,8 +160,7 @@ class GroupNameClaimTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 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.
|
||||
* A gid the relay does not list for us is not our group.
|
||||
*/
|
||||
@Test
|
||||
fun `a name for a group we are not in is refused`() {
|
||||
@@ -155,9 +174,7 @@ class GroupNameClaimTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 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.
|
||||
* Draining is not the same as accepting.
|
||||
*/
|
||||
@Test
|
||||
fun `a refused claim is consumed, not left to retry`() {
|
||||
@@ -168,7 +185,7 @@ class GroupNameClaimTest {
|
||||
session.receive()
|
||||
assertNull(vault.groupName("g1"))
|
||||
|
||||
// Now the stranger IS a member. The old claim must not be waiting.
|
||||
// Now the stranger IS a member.
|
||||
roster = listOf(1L, 7L, 99L)
|
||||
session.receive()
|
||||
assertNull(vault.groupName("g1"))
|
||||
|
||||
@@ -4,6 +4,10 @@ import io.flamenet.client.MessengerApi
|
||||
import io.flamenet.client.RelayClient
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.AfterTest
|
||||
@@ -12,43 +16,43 @@ import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
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
|
||||
* 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
|
||||
* 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
|
||||
* thread, because that is what the spec singles out.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class GroupPinResolvesTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
private val records = mutableMapOf<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun key(): SecretKey =
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
private val gid = "abc123"
|
||||
private val thread = Vault.threadForGroup("abc123")
|
||||
@@ -57,17 +61,25 @@ class GroupPinResolvesTest {
|
||||
val identity = DeviceIdentity.create()
|
||||
val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) }
|
||||
val base = server.url("/").toString()
|
||||
return Session("alice", 1, identity, vault,
|
||||
return Session(
|
||||
"alice",
|
||||
1,
|
||||
identity,
|
||||
vault,
|
||||
RelayClient(base, "alice", "app pass word"),
|
||||
MessengerApi(base, "alice", "app pass word"))
|
||||
MessengerApi(base, "alice", "app pass word"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a group transcript records a message id`() {
|
||||
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")
|
||||
assertEquals(
|
||||
"gm1",
|
||||
s.history(thread).single().id,
|
||||
"a group line carries no id, the spec's stated reason would hold",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -80,16 +92,17 @@ 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")
|
||||
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
|
||||
* words no longer in the conversation.
|
||||
* An id, never a copy.
|
||||
*/
|
||||
@Test
|
||||
fun `the pin follows an edit rather than quoting the old words`() {
|
||||
@@ -100,8 +113,11 @@ class GroupPinResolvesTest {
|
||||
|
||||
val pinned = assertNotNull(s.pinnedMessage(thread))
|
||||
val target = s.history(thread).firstOrNull { it.id == pinned }
|
||||
assertEquals("after", assertNotNull(target).text,
|
||||
"the banner is quoting words that are no longer in the conversation")
|
||||
assertEquals(
|
||||
"after",
|
||||
assertNotNull(target).text,
|
||||
"the banner is quoting words that are no longer in the conversation",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -8,33 +8,14 @@ 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
|
||||
* 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
|
||||
* 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
|
||||
* in somebody's conversation.
|
||||
*
|
||||
* ⚠️ 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.
|
||||
*/
|
||||
class InboundControlTest {
|
||||
|
||||
private fun parse(json: String): Content = Content.parse(json)!!
|
||||
|
||||
// ---- What must not be drawn -----------------------------------------
|
||||
// ---- What must not be drawn -----------------------------------------.
|
||||
|
||||
/**
|
||||
* 🔴 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.
|
||||
* The case that shipped a duplicate bubble.
|
||||
*/
|
||||
@Test
|
||||
fun `an edit is not renderable, even though it carries text`() {
|
||||
@@ -61,27 +42,24 @@ class InboundControlTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **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
|
||||
* time a new control message is added on the other platform.
|
||||
* The case an allowlist cannot cover, and the reason the app branches on
|
||||
* `isRenderable` rather than on a list of kinds it knows.
|
||||
*/
|
||||
@Test
|
||||
fun `a control envelope from a newer peer is not renderable`() {
|
||||
val fromTheFuture = parse(
|
||||
"""{"fnc":1,"kind":"something-invented-later","ctl":1,"text":"{}"}"""
|
||||
)
|
||||
val fromTheFuture =
|
||||
parse(
|
||||
"""{"fnc":1,"kind":"something-invented-later","ctl":1,"text":"{}"}""",
|
||||
)
|
||||
assertTrue(fromTheFuture.isControl, "the ctl flag was not honoured")
|
||||
assertFalse(fromTheFuture.isRenderable,
|
||||
"an unknown control kind would be drawn as a message")
|
||||
assertFalse(
|
||||
fromTheFuture.isRenderable,
|
||||
"an unknown control kind would be drawn as a message",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 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
|
||||
* shipped once already.
|
||||
* Both shapes of the flag.
|
||||
*/
|
||||
@Test
|
||||
fun `the control flag is honoured as a number and as a boolean`() {
|
||||
@@ -89,11 +67,10 @@ class InboundControlTest {
|
||||
assertFalse(parse("""{"fnc":1,"kind":"x","ctl":true,"text":"{}"}""").isRenderable)
|
||||
}
|
||||
|
||||
// ---- What must still be drawn ---------------------------------------
|
||||
// ---- What must still be drawn ---------------------------------------.
|
||||
|
||||
/**
|
||||
* 🔴 The guard must not swallow ordinary messages. A `continue` in the wrong
|
||||
* place is a conversation that silently stops receiving.
|
||||
* The guard must not swallow ordinary messages.
|
||||
*/
|
||||
@Test
|
||||
fun `an ordinary message is still renderable`() {
|
||||
@@ -103,31 +80,33 @@ class InboundControlTest {
|
||||
|
||||
@Test
|
||||
fun `a reply is still renderable`() {
|
||||
val reply = parse(
|
||||
Content.text("answering", messageId = "mid-2",
|
||||
about = Content.Reference("mid-1", 7L, "the question")),
|
||||
)
|
||||
val reply =
|
||||
parse(
|
||||
Content.text(
|
||||
"answering",
|
||||
messageId = "mid-2",
|
||||
about = Content.Reference("mid-1", 7L, "the question"),
|
||||
),
|
||||
)
|
||||
assertTrue(reply.isRenderable)
|
||||
assertTrue(reply.about?.messageId == "mid-1")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **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.
|
||||
*/
|
||||
@Test
|
||||
fun `a forwarded message is renderable, and says so`() {
|
||||
val forwarded = parse(
|
||||
Content.text("passed along", messageId = "mid-3", forwarded = true),
|
||||
)
|
||||
val forwarded =
|
||||
parse(
|
||||
Content.text("passed along", messageId = "mid-3", forwarded = true),
|
||||
)
|
||||
assertTrue(forwarded.isRenderable, "a forward was treated as control")
|
||||
assertTrue(forwarded.isForwarded, "the fwd flag was lost")
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 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.
|
||||
* And the same both-shapes rule for `fwd`, for the same reason.
|
||||
*/
|
||||
@Test
|
||||
fun `the forwarded flag is honoured as a number and as a boolean`() {
|
||||
|
||||
@@ -4,6 +4,10 @@ import io.flamenet.client.MessengerApi
|
||||
import io.flamenet.client.RelayClient
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.AfterTest
|
||||
@@ -12,46 +16,43 @@ import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
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
|
||||
* 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.
|
||||
*
|
||||
* ⚠️ 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
|
||||
* app-side application needs a `Vault` and is out of scope there. This is that
|
||||
* run.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class InboundEditAppliedTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
private val records = mutableMapOf<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun key(): SecretKey =
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
private val me = 1L
|
||||
private val them = 2L
|
||||
@@ -61,13 +62,17 @@ class InboundEditAppliedTest {
|
||||
val identity = DeviceIdentity.create()
|
||||
val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) }
|
||||
val base = server.url("/").toString()
|
||||
return Session("alice", me, identity, vault,
|
||||
return Session(
|
||||
"alice",
|
||||
me,
|
||||
identity,
|
||||
vault,
|
||||
RelayClient(base, "alice", "app pass word"),
|
||||
MessengerApi(base, "alice", "app pass word"))
|
||||
MessengerApi(base, "alice", "app pass word"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun Session.textOf(mid: String): String? =
|
||||
history(thread).firstOrNull { it.id == mid }?.text
|
||||
private fun Session.textOf(mid: String): String? = history(thread).firstOrNull { it.id == mid }?.text
|
||||
|
||||
@Test
|
||||
fun `an edit from the author rewrites the message it names`() {
|
||||
@@ -80,21 +85,21 @@ 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()
|
||||
s.record(thread, "before", mine = false, author = them, messageId = "m1")
|
||||
s.applyEdit(thread, "m1", author = them, newText = "after")
|
||||
assertEquals("after", s.textOf("m1"))
|
||||
assertTrue(s.history(thread).single().editedAt != null,
|
||||
"the bubble has no way to show that it was edited")
|
||||
assertTrue(
|
||||
s.history(thread).single().editedAt != null,
|
||||
"the bubble has no way to show that it was edited",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **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.
|
||||
* An edit from anyone but the author is refused.
|
||||
*/
|
||||
@Test
|
||||
fun `an edit from somebody who did not write it is refused`() {
|
||||
|
||||
@@ -7,17 +7,18 @@ 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
|
||||
* 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.
|
||||
*/
|
||||
class MentionPickerTest {
|
||||
|
||||
private fun buddy(id: Long, handle: String, display: String = "") =
|
||||
MessengerApi.Buddy(userId = id, handle = handle, displayName = display,
|
||||
presence = "offline")
|
||||
private fun buddy(
|
||||
id: Long,
|
||||
handle: String,
|
||||
display: String = "",
|
||||
) = MessengerApi.Buddy(
|
||||
userId = id,
|
||||
handle = handle,
|
||||
displayName = display,
|
||||
presence = "offline",
|
||||
)
|
||||
|
||||
// MARK: - When the list is open
|
||||
|
||||
@@ -39,9 +40,8 @@ class MentionPickerTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 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.
|
||||
* The list closes on a space, and closing has to be driven by the text
|
||||
* rather than by a flag.
|
||||
*/
|
||||
@Test
|
||||
fun `a space after the at closes the list`() {
|
||||
@@ -69,10 +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 —
|
||||
* being un-mentionable is worse than being ugly, because the alternative
|
||||
* is a group where some people cannot be reached.
|
||||
* The relay stores ids and no names, so a member who is not in your
|
||||
* contacts has nothing to show.
|
||||
*/
|
||||
@Test
|
||||
fun `a member who is not a contact is still mentionable`() {
|
||||
|
||||
@@ -6,6 +6,9 @@ import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.AfterTest
|
||||
@@ -16,46 +19,56 @@ import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
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
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class MessageReactionTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
private class NoKeyLog : okhttp3.mockwebserver.QueueDispatcher() {
|
||||
override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse =
|
||||
if ("/e2e/log/" in request.path.orEmpty()) MockResponse().setResponseCode(404)
|
||||
else super.dispatch(request)
|
||||
if ("/e2e/log/" in request.path.orEmpty()) {
|
||||
MockResponse().setResponseCode(404)
|
||||
} else {
|
||||
super.dispatch(request)
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.dispatcher = NoKeyLog(); it.start() }
|
||||
server =
|
||||
MockWebServer().also {
|
||||
it.dispatcher = NoKeyLog()
|
||||
it.start()
|
||||
}
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
private val records = mutableMapOf<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun key(): SecretKey =
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
private fun session(): Session {
|
||||
val identity = DeviceIdentity.create()
|
||||
@@ -71,10 +84,11 @@ class MessageReactionTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
private val peer = 99L
|
||||
private val thread get() = Vault.threadForUser(peer)
|
||||
@@ -82,9 +96,8 @@ class MessageReactionTest {
|
||||
// MARK: - The id
|
||||
|
||||
/**
|
||||
* 🔴 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.
|
||||
* A sent message carries an id, and the copy in our own transcript carries
|
||||
* the SAME one.
|
||||
*/
|
||||
@Test
|
||||
fun `a sent message and its local copy share one id`() {
|
||||
@@ -99,9 +112,7 @@ class MessageReactionTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 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.
|
||||
* The queued retry has to be the SAME message, id included.
|
||||
*/
|
||||
@Test
|
||||
fun `the queued retry carries the same id as the local copy`() {
|
||||
@@ -112,9 +123,15 @@ class MessageReactionTest {
|
||||
|
||||
val mid = session.history(thread).single().id
|
||||
assertNotNull(mid)
|
||||
val queued = session.vault.outbox().getJSONObject(0).getString("text")
|
||||
assertTrue(queued.contains(mid!!),
|
||||
"the queued copy does not carry the id the transcript recorded")
|
||||
val queued =
|
||||
session.vault
|
||||
.outbox()
|
||||
.getJSONObject(0)
|
||||
.getString("text")
|
||||
assertTrue(
|
||||
queued.contains(mid!!),
|
||||
"the queued copy does not carry the id the transcript recorded",
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Applying one
|
||||
@@ -130,15 +147,12 @@ 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
|
||||
// own is how a thread becomes unreadable.
|
||||
// And it stays one message.
|
||||
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,
|
||||
* which is not what anybody means by reacting.
|
||||
* One emoji per person.
|
||||
*/
|
||||
@Test
|
||||
fun `reacting again replaces the previous one`() {
|
||||
@@ -180,9 +194,8 @@ class MessageReactionTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 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.
|
||||
* A reaction to a message this device does not have does nothing, and says
|
||||
* so.
|
||||
*/
|
||||
@Test
|
||||
fun `a reaction to a message we do not have is ignored, not invented`() {
|
||||
@@ -196,9 +209,8 @@ class MessageReactionTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 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.
|
||||
* A line stored before ids existed has none, and must not be given one on
|
||||
* read.
|
||||
*/
|
||||
@Test
|
||||
fun `a message stored before ids existed keeps no id`() {
|
||||
|
||||
@@ -7,35 +7,25 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* A photo sent as a file must lose its location, like one sent as a photo.
|
||||
*
|
||||
* 🔴 **The bug this pins was found by an audit, not by a test, because no test
|
||||
* went down the file path at all** (android#17, ios#69). The picker path was
|
||||
* checked and correct; `Session.sendFile` handed stored bytes straight to
|
||||
* `sendAttachment`. The two doors disagreed, and the safer one was the one that
|
||||
* looked like a shortcut.
|
||||
*
|
||||
* ⚠️ **The format sniffing is what is tested here, not the re-encode.**
|
||||
* `BitmapFactory` needs a device, so the pixel half belongs to the instrumented
|
||||
* suite; iOS covers the round trip with a real GPS-tagged JPEG. What a JVM test
|
||||
* CAN prove is the decision — and the decision is where the bug was, because
|
||||
* the old code never asked the question at all.
|
||||
*/
|
||||
class MetadataScrubTest {
|
||||
|
||||
private fun bytes(vararg v: Int) = v.map { it.toByte() }.toByteArray()
|
||||
|
||||
@Test
|
||||
fun `an image is recognised from its first bytes`() {
|
||||
assertEquals(MetadataScrub.ImageKind.JPEG, MetadataScrub.kind(bytes(0xFF, 0xD8, 0xFF, 0xE0)))
|
||||
assertEquals(MetadataScrub.ImageKind.PNG,
|
||||
MetadataScrub.kind(bytes(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)))
|
||||
assertEquals(
|
||||
MetadataScrub.ImageKind.PNG,
|
||||
MetadataScrub.kind(bytes(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)),
|
||||
)
|
||||
assertEquals(MetadataScrub.ImageKind.GIF, MetadataScrub.kind(bytes(0x47, 0x49, 0x46, 0x38)))
|
||||
assertEquals(MetadataScrub.ImageKind.TIFF, MetadataScrub.kind(bytes(0x49, 0x49, 0x2A, 0x00)))
|
||||
assertEquals(MetadataScrub.ImageKind.TIFF, MetadataScrub.kind(bytes(0x4D, 0x4D, 0x00, 0x2A)))
|
||||
}
|
||||
|
||||
/** ⚠️ Both markers, with the size field between them — matching only the
|
||||
* RIFF header would claim every WAV file is an image. */
|
||||
/**
|
||||
* Both markers, with the size field between them, matching only the.
|
||||
*/
|
||||
@Test
|
||||
fun `webp needs both of its markers`() {
|
||||
val webp = bytes(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50)
|
||||
@@ -47,20 +37,17 @@ class MetadataScrubTest {
|
||||
/** HEIC is an ISO-BMFF box, so the brand at offset 8 is what decides. */
|
||||
@Test
|
||||
fun `heic is recognised and its cousins are not`() {
|
||||
fun ftyp(brand: String) = bytes(0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70) +
|
||||
brand.toByteArray(Charsets.US_ASCII)
|
||||
fun ftyp(brand: String) =
|
||||
bytes(0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70) +
|
||||
brand.toByteArray(Charsets.US_ASCII)
|
||||
assertEquals(MetadataScrub.ImageKind.HEIC, MetadataScrub.kind(ftyp("heic")))
|
||||
assertEquals(MetadataScrub.ImageKind.HEIC, MetadataScrub.kind(ftyp("mif1")))
|
||||
// ⚠️ An MP4 shares the ftyp box. Claiming it is an image would send it
|
||||
// through a bitmap decoder, which fails, and then be passed through
|
||||
// anyway — harmless here, but the check should still be honest.
|
||||
// An MP4 shares the ftyp box.
|
||||
assertNull(MetadataScrub.kind(ftyp("isom")))
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ **Only images are touched.** "Send this exact file" is what a file send
|
||||
* means; a scrubber that mangles somebody's PDF is worse than the leak it
|
||||
* was fixing.
|
||||
* Only images are touched.
|
||||
*/
|
||||
@Test
|
||||
fun `anything that is not an image goes byte for byte`() {
|
||||
|
||||
@@ -8,42 +8,31 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* What is in the tab bar.
|
||||
*
|
||||
* 🔴 **This test exists because nothing could see the bar.** It was a hand-written
|
||||
* run of `NavButton` calls inside a Composable, so its contents were invisible to
|
||||
* every test in the project: a destination could be dropped in a refactor and
|
||||
* nothing would go red. That is not hypothetical — Files and Wallet lived only
|
||||
* inside the Account screen, most of a scroll down, while iOS carried both in
|
||||
* its bar. Somebody testing the Android build reasonably concluded they had not
|
||||
* been built at all.
|
||||
*
|
||||
* ⚠️ Pinning the exact list is the point, not an accident of writing it this
|
||||
* way. A test that only asserted "at least four tabs" would have passed
|
||||
* throughout the disagreement it is here to prevent.
|
||||
*/
|
||||
class NavDestinationsTest {
|
||||
|
||||
/**
|
||||
* 🔴 **The order matches iOS**, which puts Files and Wallet between Stories
|
||||
* and Account. The same destination in the same place on both phones is
|
||||
* most of what "the apps stay in sync" means to somebody holding one of
|
||||
* each.
|
||||
* The order matches iOS, which puts Files and Wallet between Stories and
|
||||
* Account.
|
||||
*/
|
||||
@Test
|
||||
fun `the bar is the seven destinations iOS has, in the same order`() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
Screen.CONTACTS, Screen.MESSAGES, Screen.CALLS, Screen.STORIES,
|
||||
Screen.FILES, Screen.WALLET, Screen.ACCOUNT,
|
||||
Screen.CONTACTS,
|
||||
Screen.MESSAGES,
|
||||
Screen.CALLS,
|
||||
Screen.STORIES,
|
||||
Screen.FILES,
|
||||
Screen.WALLET,
|
||||
Screen.ACCOUNT,
|
||||
),
|
||||
NavDestinations.tabs(storiesHidden = false).map { it.screen },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Supervision removes the surface rather than showing an empty one —
|
||||
* and removes only that one. A parental control that quietly took Files or
|
||||
* the wallet with it would be a data-access change nobody asked for.
|
||||
* Supervision removes the surface rather than showing an empty one, and
|
||||
* removes only that one.
|
||||
*/
|
||||
@Test
|
||||
fun `supervision hides stories and nothing else`() {
|
||||
@@ -56,25 +45,27 @@ class NavDestinationsTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The tags are how the instrumented suite finds the bar. A renamed tag
|
||||
* is a test that stops testing and still passes, because
|
||||
* `onNodeWithTag(...).performClick()` on a node that does not exist is
|
||||
* caught, but `nodesWithTag(...).isEmpty()` guards throughout that suite
|
||||
* turn a missing tag into a silent early return.
|
||||
* The tags are how the instrumented suite finds the bar.
|
||||
*/
|
||||
@Test
|
||||
fun `every tab carries the tag the instrumented suite looks for`() {
|
||||
assertEquals(
|
||||
listOf("nav-contacts", "nav-messages", "nav-calls", "nav-stories",
|
||||
"nav-files", "nav-wallet", "nav-account"),
|
||||
listOf(
|
||||
"nav-contacts",
|
||||
"nav-messages",
|
||||
"nav-calls",
|
||||
"nav-stories",
|
||||
"nav-files",
|
||||
"nav-wallet",
|
||||
"nav-account",
|
||||
),
|
||||
NavDestinations.tabs(storiesHidden = false).map { it.tag },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ The label is not decoration: the bar draws icons only, so this string
|
||||
* is the accessibility name and the only thing TalkBack reads. A blank one
|
||||
* makes the tab an unnamed button.
|
||||
* The label is not decoration: the bar draws icons only, so this string is
|
||||
* the accessibility name and the only thing TalkBack reads.
|
||||
*/
|
||||
@Test
|
||||
fun `every tab is named for a screen reader`() {
|
||||
@@ -84,10 +75,8 @@ class NavDestinationsTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 Every destination a person can reach from the bar has to be somewhere
|
||||
* the app can actually draw. `NOTIFICATIONS` is deliberately NOT in the bar
|
||||
* — it left on 2026-08-28 and is reached by tapping the Account badge — so
|
||||
* this checks the bar is a subset of the screens, not equal to them.
|
||||
* Every destination a person can reach from the bar has to be somewhere the
|
||||
* app can actually draw.
|
||||
*/
|
||||
@Test
|
||||
fun `no tab points at a screen that does not exist`() {
|
||||
|
||||
@@ -7,16 +7,8 @@ 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
|
||||
* 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
|
||||
* a string nothing can test.
|
||||
*/
|
||||
class NetworkStatusLineTest {
|
||||
|
||||
private val bridge = Obfs4Bridge("198.51.100.7", 4444, ByteArray(20), ByteArray(32))
|
||||
|
||||
@Test
|
||||
@@ -26,9 +18,7 @@ class NetworkStatusLineTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **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.
|
||||
* The load-bearing case.
|
||||
*/
|
||||
@Test
|
||||
fun `a saved bridge that has carried nothing does not claim to be working`() {
|
||||
@@ -47,9 +37,7 @@ class NetworkStatusLineTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 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.
|
||||
* Every state has to repeat that the relay is unaffected.
|
||||
*/
|
||||
@Test
|
||||
fun `the working state does not imply anything is hidden from the server`() {
|
||||
|
||||
@@ -6,28 +6,20 @@ 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 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
|
||||
* `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.
|
||||
private fun messageText(
|
||||
previews: Boolean,
|
||||
threads: Int,
|
||||
from: String?,
|
||||
) = NotificationSettings.messageText(previews, threads, from)
|
||||
|
||||
// 🔴 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
|
||||
// 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)
|
||||
|
||||
private fun replyText(previews: Boolean, count: Int, from: String?) =
|
||||
NotificationSettings.replyReminderText(previews, count, from)
|
||||
private fun replyText(
|
||||
previews: Boolean,
|
||||
count: Int,
|
||||
from: String?,
|
||||
) = NotificationSettings.replyReminderText(previews, count, from)
|
||||
|
||||
@Test
|
||||
fun `with previews off no name reaches the lock screen`() {
|
||||
@@ -44,9 +36,7 @@ class NotificationTextTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 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.
|
||||
* Several threads means no name even with previews on.
|
||||
*/
|
||||
@Test
|
||||
fun `several threads are counted, never named`() {
|
||||
@@ -55,9 +45,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
|
||||
* cross-relay peer, or a contact it has never seen.
|
||||
* A caller that has no name must not produce "New message from null" or a
|
||||
* dangling "from".
|
||||
*/
|
||||
@Test
|
||||
fun `a missing name falls back to the count rather than showing a hole`() {
|
||||
|
||||
@@ -6,6 +6,9 @@ import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.AfterTest
|
||||
@@ -13,71 +16,63 @@ import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
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".**
|
||||
* 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
|
||||
* 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
|
||||
* 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 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.
|
||||
*/
|
||||
// ⚠️ 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.
|
||||
// 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.
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class OutboxTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
/**
|
||||
* 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 —
|
||||
* 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
|
||||
* about the outbox and nothing else.
|
||||
*/
|
||||
private class NoKeyLog : okhttp3.mockwebserver.QueueDispatcher() {
|
||||
override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse =
|
||||
// `contains`, not `startsWith`: every route sits under the REST
|
||||
// mount, so the recorded path begins with that prefix.
|
||||
if ("/e2e/log/" in request.path.orEmpty()) MockResponse().setResponseCode(404)
|
||||
else super.dispatch(request)
|
||||
if ("/e2e/log/" in request.path.orEmpty()) {
|
||||
MockResponse().setResponseCode(404)
|
||||
} else {
|
||||
super.dispatch(request)
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.dispatcher = NoKeyLog(); it.start() }
|
||||
server =
|
||||
MockWebServer().also {
|
||||
it.dispatcher = NoKeyLog()
|
||||
it.start()
|
||||
}
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
private val records = mutableMapOf<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun key(): SecretKey =
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
private fun session(): Session {
|
||||
val identity = DeviceIdentity.create()
|
||||
@@ -93,21 +88,22 @@ class OutboxTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
private val peer = 99L
|
||||
|
||||
@Test
|
||||
fun `a message to somebody with no device is kept, not lost and not dropped on the sender`() {
|
||||
val session = session()
|
||||
server.enqueue(json("""{"devices":[]}""")) // they are signed out
|
||||
server.enqueue(json("""{"devices":[]}""")) // they are signed out
|
||||
|
||||
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)
|
||||
@@ -116,8 +112,11 @@ class OutboxTest {
|
||||
assertEquals(Session.Delivery.PENDING, thread[0].delivery)
|
||||
|
||||
// And it is queued, so nobody has to remember to send it again.
|
||||
assertEquals(1, session.vault.outbox().length(),
|
||||
"the message must be waiting in the outbox")
|
||||
assertEquals(
|
||||
1,
|
||||
session.vault.outbox().length(),
|
||||
"the message must be waiting in the outbox",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -126,12 +125,15 @@ class OutboxTest {
|
||||
server.enqueue(json("""{"devices":[]}"""))
|
||||
session.send(peer, "still nobody home")
|
||||
|
||||
server.enqueue(json("""{"devices":[]}""")) // still signed out
|
||||
server.enqueue(json("""{"devices":[]}""")) // still signed out
|
||||
val touched = session.flushOutbox()
|
||||
|
||||
assertTrue(touched.isEmpty(), "nothing changed, so nothing to redraw")
|
||||
assertEquals(1, session.vault.outbox().length(),
|
||||
"a failed retry must not discard the message")
|
||||
assertEquals(
|
||||
1,
|
||||
session.vault.outbox().length(),
|
||||
"a failed retry must not discard the message",
|
||||
)
|
||||
assertEquals(
|
||||
Session.Delivery.PENDING,
|
||||
session.history(Vault.threadForUser(peer))[0].delivery,
|
||||
@@ -149,22 +151,15 @@ class OutboxTest {
|
||||
server.enqueue(MockResponse().setResponseCode(500))
|
||||
session.flushOutbox()
|
||||
|
||||
assertEquals(1, session.vault.outbox().length(),
|
||||
"a transport failure must not eat the message either")
|
||||
assertEquals(
|
||||
1,
|
||||
session.vault.outbox().length(),
|
||||
"a transport failure must not eat the message either",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **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
|
||||
* "will send", which is a promise the app cannot keep.
|
||||
*
|
||||
* ⚠️ 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.
|
||||
* A refusal waiting cannot fix must STOP (ios#22).
|
||||
*/
|
||||
@Test
|
||||
fun `a send refused for changed keys stops retrying and says why`() {
|
||||
@@ -173,58 +168,74 @@ 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 ->
|
||||
val opk = id.generateOneTimePreKeys(1).first()
|
||||
id.registrationWire().put(
|
||||
"opk", org.json.JSONObject()
|
||||
"opk",
|
||||
org.json
|
||||
.JSONObject()
|
||||
.put("id", opk.id)
|
||||
.put("pub", android.util.Base64.encodeToString(
|
||||
opk.publicKey, android.util.Base64.NO_WRAP)))
|
||||
.put(
|
||||
"pub",
|
||||
android.util.Base64.encodeToString(opk.publicKey, android.util.Base64.NO_WRAP),
|
||||
),
|
||||
)
|
||||
}
|
||||
val first = bundleOf(them)
|
||||
server.enqueue(json(org.json.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(first)).toString()))
|
||||
server.enqueue(json(org.json.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(first)).toString()))
|
||||
server.enqueue(
|
||||
json(
|
||||
org.json
|
||||
.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(first))
|
||||
.toString(),
|
||||
),
|
||||
)
|
||||
server.enqueue(
|
||||
json(
|
||||
org.json
|
||||
.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(first))
|
||||
.toString(),
|
||||
),
|
||||
)
|
||||
server.enqueue(json("""{"ok":true}"""))
|
||||
session.flushOutbox()
|
||||
assertEquals(0, session.vault.outbox().length())
|
||||
|
||||
// 🔴 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
|
||||
// Now they reinstall: a DIFFERENT identity under the same user.
|
||||
server.enqueue(json("""{"devices":[]}""")) // queues the second message
|
||||
session.send(peer, "you there?")
|
||||
assertEquals(1, session.vault.outbox().length())
|
||||
|
||||
val second = bundleOf(DeviceIdentity.create())
|
||||
val newDirectory = org.json.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(second)).toString()
|
||||
val newDirectory =
|
||||
org.json
|
||||
.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(second))
|
||||
.toString()
|
||||
// Enqueued generously: the refusal happens at the directory, but the
|
||||
// exact number of calls before it is not what this test is about.
|
||||
repeat(4) { server.enqueue(json(newDirectory)) }
|
||||
session.flushOutbox()
|
||||
|
||||
assertEquals(0, session.vault.outbox().length(),
|
||||
"a permanently refused send must not sit in the queue for ever")
|
||||
assertEquals(
|
||||
0,
|
||||
session.vault.outbox().length(),
|
||||
"a permanently refused send must not sit in the queue for ever",
|
||||
)
|
||||
val line = session.history(Vault.threadForUser(peer)).last { it.mine }
|
||||
assertEquals(Session.Delivery.FAILED, line.delivery,
|
||||
"the bubble must stop promising it will send")
|
||||
assertEquals(
|
||||
Session.Delivery.FAILED,
|
||||
line.delivery,
|
||||
"the bubble must stop promising it will send",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **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
|
||||
* 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.
|
||||
* The promise, end to end.
|
||||
*/
|
||||
@Test
|
||||
fun `when they register a device the queued message goes and the bubble updates`() {
|
||||
@@ -236,27 +247,50 @@ class OutboxTest {
|
||||
// They reinstall: a device with real keys appears.
|
||||
val them = DeviceIdentity.create()
|
||||
val opk = them.generateOneTimePreKeys(1).first()
|
||||
val bundle = them.registrationWire().put(
|
||||
"opk", org.json.JSONObject()
|
||||
.put("id", opk.id)
|
||||
// ⚠️ `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)))
|
||||
val bundle =
|
||||
them.registrationWire().put(
|
||||
"opk",
|
||||
org.json
|
||||
.JSONObject()
|
||||
.put("id", opk.id)
|
||||
// `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.
|
||||
server.enqueue(json(org.json.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(bundle)).toString()))
|
||||
server.enqueue(json(org.json.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(bundle)).toString()))
|
||||
server.enqueue(json("""{"ok":true}""")) // the envelope post
|
||||
// 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()
|
||||
.put("devices", org.json.JSONArray().put(bundle))
|
||||
.toString(),
|
||||
),
|
||||
)
|
||||
server.enqueue(json("""{"ok":true}""")) // the envelope post
|
||||
|
||||
val touched = session.flushOutbox()
|
||||
|
||||
assertEquals(0, session.vault.outbox().length(),
|
||||
"a delivered message must leave the outbox")
|
||||
assertTrue(Vault.threadForUser(peer) in touched,
|
||||
"the thread has to be redrawn or the bubble keeps its old label")
|
||||
assertEquals(
|
||||
0,
|
||||
session.vault.outbox().length(),
|
||||
"a delivered message must leave the outbox",
|
||||
)
|
||||
assertTrue(
|
||||
Vault.threadForUser(peer) in touched,
|
||||
"the thread has to be redrawn or the bubble keeps its old label",
|
||||
)
|
||||
assertEquals(
|
||||
Session.Delivery.DELIVERED,
|
||||
session.history(Vault.threadForUser(peer))[0].delivery,
|
||||
@@ -265,11 +299,10 @@ 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
|
||||
* content kind. This asserts all three parts: queued, delivered AS A
|
||||
* STICKER once a device exists, and the bubble restated.
|
||||
* 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.
|
||||
*/
|
||||
@Test
|
||||
fun `a sticker to somebody with no device is queued and later delivered as a sticker`() {
|
||||
@@ -281,47 +314,70 @@ class OutboxTest {
|
||||
assertEquals(1, thread.size)
|
||||
assertEquals("wave", thread[0].sticker?.id, "the bubble is a sticker, not text")
|
||||
assertEquals(Session.Delivery.PENDING, thread[0].delivery)
|
||||
assertEquals(1, session.vault.outbox().length(),
|
||||
"the sticker must be waiting in the outbox")
|
||||
assertEquals(
|
||||
1,
|
||||
session.vault.outbox().length(),
|
||||
"the sticker must be waiting in the outbox",
|
||||
)
|
||||
|
||||
// They reinstall.
|
||||
val them = DeviceIdentity.create()
|
||||
val opk = them.generateOneTimePreKeys(1).first()
|
||||
val bundle = them.registrationWire().put(
|
||||
"opk", org.json.JSONObject()
|
||||
.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.
|
||||
server.enqueue(json(org.json.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(bundle)).toString()))
|
||||
server.enqueue(json(org.json.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(bundle)).toString()))
|
||||
val bundle =
|
||||
them.registrationWire().put(
|
||||
"opk",
|
||||
org.json
|
||||
.JSONObject()
|
||||
.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.
|
||||
server.enqueue(
|
||||
json(
|
||||
org.json
|
||||
.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(bundle))
|
||||
.toString(),
|
||||
),
|
||||
)
|
||||
server.enqueue(
|
||||
json(
|
||||
org.json
|
||||
.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(bundle))
|
||||
.toString(),
|
||||
),
|
||||
)
|
||||
server.enqueue(json("""{"ok":true}"""))
|
||||
|
||||
session.flushOutbox()
|
||||
|
||||
assertEquals(0, session.vault.outbox().length())
|
||||
assertEquals(Session.Delivery.DELIVERED,
|
||||
session.history(Vault.threadForUser(peer))[0].delivery,
|
||||
"the sticker bubble must stop saying undelivered")
|
||||
assertEquals("wave",
|
||||
session.history(Vault.threadForUser(peer))[0].sticker?.id,
|
||||
"restating the delivery must not turn the sticker into text")
|
||||
assertEquals(
|
||||
Session.Delivery.DELIVERED,
|
||||
session.history(Vault.threadForUser(peer))[0].delivery,
|
||||
"the sticker bubble must stop saying undelivered",
|
||||
)
|
||||
assertEquals(
|
||||
"wave",
|
||||
session.history(Vault.threadForUser(peer))[0].sticker?.id,
|
||||
"restating the delivery must not turn the sticker into text",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 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
|
||||
* being true for exactly the people it was still true for.
|
||||
* The group half of the promise, with its completion rule made observable.
|
||||
* the bubble flips only when the LAST queued member is reached.
|
||||
*/
|
||||
@Test
|
||||
fun `a group message waits for every queued member before the bubble flips`() {
|
||||
val session = session()
|
||||
val group = io.flamenet.client.RelayClient.Group(
|
||||
id = "g1", name = "test", owner = 1, memberIds = listOf(101L, 102L))
|
||||
val group =
|
||||
io.flamenet.client.RelayClient
|
||||
.Group(id = "g1", name = "test", owner = 1, memberIds = listOf(101L, 102L))
|
||||
|
||||
// Both members signed out: two device lookups, both empty.
|
||||
server.enqueue(json("""{"devices":[]}"""))
|
||||
@@ -330,46 +386,84 @@ class OutboxTest {
|
||||
|
||||
val thread = Vault.threadForGroup("g1")
|
||||
assertEquals(Session.Delivery.PENDING, session.history(thread)[0].delivery)
|
||||
assertEquals(2, session.vault.outbox().length(),
|
||||
"one entry per member nothing reached")
|
||||
assertEquals(
|
||||
2,
|
||||
session.vault.outbox().length(),
|
||||
"one entry per member nothing reached",
|
||||
)
|
||||
|
||||
// Member 101 registers; 102 is still away.
|
||||
val m1 = DeviceIdentity.create()
|
||||
val opk1 = m1.generateOneTimePreKeys(1).first()
|
||||
val m1Wire = org.json.JSONObject().put("devices",
|
||||
org.json.JSONArray().put(m1.registrationWire().put("opk",
|
||||
org.json.JSONObject().put("id", opk1.id).put("pub",
|
||||
android.util.Base64.encodeToString(opk1.publicKey,
|
||||
android.util.Base64.NO_WRAP))))).toString()
|
||||
server.enqueue(json(m1Wire)) // the directory
|
||||
server.enqueue(json(m1Wire)) // the key route
|
||||
val m1Wire =
|
||||
org.json
|
||||
.JSONObject()
|
||||
.put(
|
||||
"devices",
|
||||
org.json.JSONArray().put(
|
||||
m1.registrationWire().put(
|
||||
"opk",
|
||||
org.json.JSONObject().put("id", opk1.id).put(
|
||||
"pub",
|
||||
android.util.Base64.encodeToString(
|
||||
opk1.publicKey,
|
||||
android.util.Base64.NO_WRAP,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
).toString()
|
||||
server.enqueue(json(m1Wire)) // the directory
|
||||
server.enqueue(json(m1Wire)) // the key route
|
||||
server.enqueue(json("""{"ok":true}"""))
|
||||
server.enqueue(json("""{"devices":[]}""")) // 102 still gone
|
||||
server.enqueue(json("""{"devices":[]}""")) // 102 still gone
|
||||
session.flushOutbox()
|
||||
|
||||
assertEquals(1, session.vault.outbox().length(),
|
||||
"the reached member leaves the queue; the other stays")
|
||||
assertEquals(Session.Delivery.PENDING, session.history(thread)[0].delivery,
|
||||
"one member reached is not the group delivered")
|
||||
assertEquals(
|
||||
1,
|
||||
session.vault.outbox().length(),
|
||||
"the reached member leaves the queue; the other stays",
|
||||
)
|
||||
assertEquals(
|
||||
Session.Delivery.PENDING,
|
||||
session.history(thread)[0].delivery,
|
||||
"one member reached is not the group delivered",
|
||||
)
|
||||
|
||||
// Member 102 comes back.
|
||||
val m2 = DeviceIdentity.create()
|
||||
val opk2 = m2.generateOneTimePreKeys(1).first()
|
||||
val m2Wire = org.json.JSONObject().put("devices",
|
||||
org.json.JSONArray().put(m2.registrationWire().put("opk",
|
||||
org.json.JSONObject().put("id", opk2.id).put("pub",
|
||||
android.util.Base64.encodeToString(opk2.publicKey,
|
||||
android.util.Base64.NO_WRAP))))).toString()
|
||||
server.enqueue(json(m2Wire)) // the directory
|
||||
server.enqueue(json(m2Wire)) // the key route
|
||||
val m2Wire =
|
||||
org.json
|
||||
.JSONObject()
|
||||
.put(
|
||||
"devices",
|
||||
org.json.JSONArray().put(
|
||||
m2.registrationWire().put(
|
||||
"opk",
|
||||
org.json.JSONObject().put("id", opk2.id).put(
|
||||
"pub",
|
||||
android.util.Base64.encodeToString(
|
||||
opk2.publicKey,
|
||||
android.util.Base64.NO_WRAP,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
).toString()
|
||||
server.enqueue(json(m2Wire)) // the directory
|
||||
server.enqueue(json(m2Wire)) // the key route
|
||||
server.enqueue(json("""{"ok":true}"""))
|
||||
val touched = session.flushOutbox()
|
||||
|
||||
assertEquals(0, session.vault.outbox().length())
|
||||
assertTrue(thread in touched, "the GROUP thread is what gets redrawn")
|
||||
assertEquals(Session.Delivery.DELIVERED, session.history(thread)[0].delivery)
|
||||
assertEquals("meeting moved to three", session.history(thread)[0].text,
|
||||
"the group body must not be replaced by the wire form")
|
||||
assertEquals(
|
||||
"meeting moved to three",
|
||||
session.history(thread)[0].text,
|
||||
"the group body must not be replaced by the wire form",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -380,14 +474,14 @@ 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
|
||||
// whichever came first every time somebody repeats themselves.
|
||||
// Two identical messages.
|
||||
val outbox = session.vault.outbox()
|
||||
assertEquals(2, outbox.length())
|
||||
val first = outbox.getJSONObject(0).getLong("at")
|
||||
val second = outbox.getJSONObject(1).getLong("at")
|
||||
assertTrue(first != second || session.history(Vault.threadForUser(peer)).size == 2,
|
||||
"two sends must be two distinguishable entries")
|
||||
assertTrue(
|
||||
first != second || session.history(Vault.threadForUser(peer)).size == 2,
|
||||
"two sends must be two distinguishable entries",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,19 +11,12 @@ 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
|
||||
* 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.
|
||||
*/
|
||||
class PendingCallTest {
|
||||
|
||||
@AfterTest fun tidy() = PendingIncomingCall.clearAll()
|
||||
|
||||
private fun offer(id: String = "call-1") = Content.CallSignal(
|
||||
action = Content.CallSignal.OFFER, callId = id, sdp = "v=0", reason = null, video = false)
|
||||
private fun offer(id: String = "call-1") =
|
||||
Content.CallSignal(action = Content.CallSignal.OFFER, callId = id, sdp = "v=0", reason = null, video = false)
|
||||
|
||||
@Test
|
||||
fun `nothing is stale when nothing is parked`() {
|
||||
@@ -37,18 +30,15 @@ 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(
|
||||
42L, offer(), System.currentTimeMillis() - CallManager.DIAL_TIMEOUT_MS - 1_000)
|
||||
PendingIncomingCall.park(42L, offer(), System.currentTimeMillis() - CallManager.DIAL_TIMEOUT_MS - 1_000)
|
||||
assertTrue(PendingIncomingCall.isStale())
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 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.
|
||||
* Asking must not consume.
|
||||
*/
|
||||
@Test
|
||||
fun `checking staleness does not take the offer`() {
|
||||
@@ -65,7 +55,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,21 +7,8 @@ import java.io.File
|
||||
|
||||
/**
|
||||
* A photo must arrive the way up it was taken.
|
||||
*
|
||||
* 🔴 Reported from a real phone: a photo auto-rotated. `BitmapFactory` does
|
||||
* NOT apply EXIF orientation, and every image this app sends is re-encoded to
|
||||
* strip EXIF — right, because that block carries the GPS coordinates the shot
|
||||
* was taken at. But the same block carries the tag saying which way is up, and
|
||||
* a phone camera rarely writes upright pixels: it writes landscape pixels plus
|
||||
* "turn this 90°". Decode ignoring the tag, then throw the tag away, and every
|
||||
* portrait photo lands on its side.
|
||||
*
|
||||
* ⚠️ These are source-level checks. The rotation itself needs a real Bitmap,
|
||||
* which is an instrumented test — but the wiring is what regressed and is what
|
||||
* a unit test can hold. See `PARITY.md`.
|
||||
*/
|
||||
class PhotoOrientationTest {
|
||||
|
||||
private fun source(path: String): String {
|
||||
val file = File("src/main/kotlin/io/flamenet/messenger/app/$path")
|
||||
assertTrue("missing $path", file.exists())
|
||||
@@ -32,58 +19,74 @@ class PhotoOrientationTest {
|
||||
@Test
|
||||
fun preparingAPhotoAppliesTheExifTag() {
|
||||
val prep = source("ImagePrep.kt")
|
||||
assertTrue("prepare() must orient before encoding",
|
||||
prep.contains("orientedFrom(source, decodeScaled(source, bounds, maxSide)"))
|
||||
assertTrue("must read the tag rather than guess",
|
||||
prep.contains("ExifInterface.TAG_ORIENTATION"))
|
||||
assertTrue(
|
||||
"prepare() must orient before encoding",
|
||||
prep.contains("orientedFrom(source, decodeScaled(source, bounds, maxSide)"),
|
||||
)
|
||||
assertTrue(
|
||||
"must read the tag rather than guess",
|
||||
prep.contains("ExifInterface.TAG_ORIENTATION"),
|
||||
)
|
||||
}
|
||||
|
||||
/** 🔴 And so must the scrubber, whose entire job is removing that block. */
|
||||
/** And so must the scrubber, whose entire job is removing that block. */
|
||||
@Test
|
||||
fun scrubbingAppliesTheExifTagBeforeRemovingIt() {
|
||||
assertTrue("scrub() must orient before re-encoding",
|
||||
source("MetadataScrub.kt").contains("ImagePrep.orientedFrom(data, raw)"))
|
||||
assertTrue(
|
||||
"scrub() must orient before re-encoding",
|
||||
source("MetadataScrub.kt").contains("ImagePrep.orientedFrom(data, raw)"),
|
||||
)
|
||||
}
|
||||
|
||||
/** Images received from a peer carry the tag too. */
|
||||
@Test
|
||||
fun renderingAReceivedImageAppliesTheTag() {
|
||||
assertTrue("decodeBounded must orient what it decodes",
|
||||
source("ImagePrep.kt").contains("?.let { orientedFrom(source, it) }"))
|
||||
assertTrue(
|
||||
"decodeBounded must orient what it decodes",
|
||||
source("ImagePrep.kt").contains("?.let { orientedFrom(source, it) }"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ The mirrored tags are rare but real — a front camera writes them, and
|
||||
* a selfie coming back flipped is the same class of bug as one coming back
|
||||
* The mirrored tags are rare but real, a front camera writes them, and a
|
||||
* selfie coming back flipped is the same class of bug as one coming back
|
||||
* sideways.
|
||||
*/
|
||||
@Test
|
||||
fun everyOrientationTagIsHandled() {
|
||||
val prep = source("ImagePrep.kt")
|
||||
for (tag in listOf(
|
||||
"ORIENTATION_ROTATE_90", "ORIENTATION_ROTATE_180", "ORIENTATION_ROTATE_270",
|
||||
"ORIENTATION_FLIP_HORIZONTAL", "ORIENTATION_FLIP_VERTICAL",
|
||||
"ORIENTATION_TRANSPOSE", "ORIENTATION_TRANSVERSE",
|
||||
"ORIENTATION_ROTATE_90",
|
||||
"ORIENTATION_ROTATE_180",
|
||||
"ORIENTATION_ROTATE_270",
|
||||
"ORIENTATION_FLIP_HORIZONTAL",
|
||||
"ORIENTATION_FLIP_VERTICAL",
|
||||
"ORIENTATION_TRANSPOSE",
|
||||
"ORIENTATION_TRANSVERSE",
|
||||
)) {
|
||||
assertTrue("$tag is not handled", prep.contains(tag))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 `windowSoftInputMode="adjustResize"` is a no-op at targetSdk 35:
|
||||
* Android 15 forces edge-to-edge, and an edge-to-edge window does not
|
||||
* resize for the keyboard. The insets have to be handled in Compose.
|
||||
* `windowSoftInputMode="adjustResize"` is a no-op at targetSdk 35: Android
|
||||
* 15 forces edge-to-edge, and an edge-to-edge window does not resize for
|
||||
* the keyboard.
|
||||
*/
|
||||
@Test
|
||||
fun theKeyboardDoesNotCoverWhatIsBeingTypedInto() {
|
||||
val activity = source("MainActivity.kt")
|
||||
assertTrue("the root must consume the IME inset",
|
||||
activity.contains("Modifier.fillMaxSize().imePadding()"))
|
||||
assertTrue("and import it", activity.contains(
|
||||
"import androidx.compose.foundation.layout.imePadding"))
|
||||
assertTrue(
|
||||
"the root must consume the IME inset",
|
||||
activity.contains("Modifier.fillMaxSize().imePadding()"),
|
||||
)
|
||||
assertTrue(
|
||||
"and import it",
|
||||
activity.contains("import androidx.compose.foundation.layout.imePadding"),
|
||||
)
|
||||
|
||||
// The manifest line is kept — it still applies on older targets — but
|
||||
// it must never again be the only thing relied on.
|
||||
// The manifest line is kept, it still applies on older targets, but it
|
||||
// must never again be the only thing relied on.
|
||||
val target = File("build.gradle.kts").readText()
|
||||
assertTrue("this test's premise is targetSdk 35", target.contains("targetSdk = 35"))
|
||||
}
|
||||
|
||||
@@ -6,20 +6,8 @@ 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
|
||||
* 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
|
||||
* 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
|
||||
* control that reported success and did nothing.
|
||||
*
|
||||
* Both ends are fixed. These assertions are what stops the vocabulary drifting
|
||||
* apart again, since nothing in either build checks it.
|
||||
*/
|
||||
class PresenceTest {
|
||||
|
||||
/** Every chip must be a word `ChosenStatus` in flamenet-relay accepts. */
|
||||
@Test
|
||||
fun `the chips are exactly the statuses the relay accepts`() {
|
||||
@@ -30,9 +18,9 @@ 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
|
||||
* and the screen looks broken right after a successful change.
|
||||
* 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
|
||||
fun `invisible is shown as offline`() {
|
||||
@@ -56,6 +44,5 @@ class PresenceTest {
|
||||
assertEquals("dnd", displayPresenceFor("dnd"))
|
||||
}
|
||||
|
||||
private fun displayPresenceFor(stored: String): String =
|
||||
if (stored == "invisible") "offline" else stored
|
||||
private fun displayPresenceFor(stored: String): String = if (stored == "invisible") "offline" else stored
|
||||
}
|
||||
|
||||
@@ -7,29 +7,11 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The one button on a dead-end screen has to be a way out.
|
||||
*
|
||||
* 🔴 **"Erase and start fresh" erased nothing.** The `VaultUnreadable` screen —
|
||||
* shown when the keys on this phone cannot be opened — offered exactly one
|
||||
* action, and it called `model::signOut`. `signOut` defaults to
|
||||
* `erasingDevice = false`, the door that deliberately KEEPS the device so
|
||||
* coming back is silent. So it set the phase to SignedOut, left the unreadable
|
||||
* vault exactly where it was, and the next `restore()` opened it, failed again,
|
||||
* and came straight back to the same screen.
|
||||
*
|
||||
* ⚠️ **It looked like it worked.** The screen changed. That is the whole
|
||||
* problem with a recovery action that does not recover: the feedback is the
|
||||
* claim, and the person only finds out on the next launch — or, as here, when
|
||||
* they try to sign in and land back where they started.
|
||||
*
|
||||
* A source scan, coarse on purpose, in the same shape as
|
||||
* `DestructiveLabelsTest`. It cannot prove the recovery is correct. It proves
|
||||
* this particular wrong call cannot come back.
|
||||
*/
|
||||
class RecoveryActuallyRecoversTest {
|
||||
|
||||
private fun source(name: String): String {
|
||||
val f = File("src/main/kotlin/io/flamenet/messenger/app/$name")
|
||||
assertTrue(f.isFile, "cannot find $name — this test reads the sources")
|
||||
assertTrue(f.isFile, "cannot find $name, this test reads the sources")
|
||||
return f.readText()
|
||||
}
|
||||
|
||||
@@ -47,7 +29,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,9 +43,7 @@ class RecoveryActuallyRecoversTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 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.
|
||||
* And that the discard actually destroys something.
|
||||
*/
|
||||
@Test
|
||||
fun `discarding an unreadable vault destroys it and reopens a fresh one`() {
|
||||
@@ -71,12 +51,18 @@ class RecoveryActuallyRecoversTest {
|
||||
val start = s.indexOf("fun discardUnreadableVault(")
|
||||
assertTrue(start > 0, "discardUnreadableVault is gone")
|
||||
val body = s.substring(start, minOf(start + 1200, s.length))
|
||||
assertTrue(body.contains("KeystoreVault.destroy"),
|
||||
"discardUnreadableVault never destroys the vault")
|
||||
assertTrue(body.contains("KeystoreVault.open"),
|
||||
assertTrue(
|
||||
body.contains("KeystoreVault.destroy"),
|
||||
"discardUnreadableVault never destroys the vault",
|
||||
)
|
||||
assertTrue(
|
||||
body.contains("KeystoreVault.open"),
|
||||
"discardUnreadableVault destroys the vault without opening a fresh one, " +
|
||||
"so the next sign-in has nowhere to store keys")
|
||||
assertTrue(body.contains("Phase.SignedOut"),
|
||||
"discardUnreadableVault does not leave the app on the sign-in screen")
|
||||
"so the next sign-in has nowhere to store keys",
|
||||
)
|
||||
assertTrue(
|
||||
body.contains("Phase.SignedOut"),
|
||||
"discardUnreadableVault does not leave the app on the sign-in screen",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,8 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The device row's "Registered <date>" line.
|
||||
*
|
||||
* 🔴 **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
|
||||
* could test it either, because it was built inline inside a `Text(...)`.
|
||||
*/
|
||||
class RegisteredLineTest {
|
||||
|
||||
@Test
|
||||
fun `a real timestamp becomes a date, not template source`() {
|
||||
val line = registeredLine("2026-08-29T14:35:00Z")
|
||||
@@ -31,7 +24,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))
|
||||
|
||||
@@ -8,15 +8,8 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Starting a reply, on the wire (android#16).
|
||||
*
|
||||
* ⚠️ **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
|
||||
* about the outbound envelope, which is the half that did not exist.
|
||||
*/
|
||||
class ReplyWireTest {
|
||||
|
||||
private fun parse(json: String) = Content.parse(json)
|
||||
|
||||
@Test
|
||||
@@ -31,11 +24,7 @@ class ReplyWireTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **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
|
||||
* it, and dropping the field to save bytes is not a free optimisation.
|
||||
* The preview travels, and this is why.
|
||||
*/
|
||||
@Test
|
||||
fun `the quoted words travel with the reply`() {
|
||||
@@ -45,9 +34,8 @@ class ReplyWireTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 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.
|
||||
* A message with nothing extra to carry still goes out as raw bytes, so an
|
||||
* older peer reads it as text.
|
||||
*/
|
||||
@Test
|
||||
fun `a message that is not a reply is still a bare body`() {
|
||||
|
||||
@@ -8,23 +8,20 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Scheduled send: the drain rule.
|
||||
*
|
||||
* 🔴 **"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
|
||||
* predicate the drain uses, which is the whole of the risk.
|
||||
*/
|
||||
class ScheduledSendTest {
|
||||
|
||||
private val now = 1_700_000_000_000L
|
||||
|
||||
/** The drain's rule, mirrored: hold while `not_before` is in the future. */
|
||||
private fun isHeld(item: JSONObject, at: Long) = item.optLong("not_before", 0L) > at
|
||||
private fun isHeld(
|
||||
item: JSONObject,
|
||||
at: Long,
|
||||
) = item.optLong("not_before", 0L) > at
|
||||
|
||||
@Test
|
||||
fun `an entry with no schedule sends immediately`() {
|
||||
// ⚠️ Entries queued before scheduling existed carry no `not_before`,
|
||||
// and must not be held for a date they have no value for.
|
||||
// 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 +37,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)
|
||||
@@ -50,13 +47,15 @@ class ScheduledSendTest {
|
||||
/** Soonest-first ordering is what the banner names and Cancel removes. */
|
||||
@Test
|
||||
fun `scheduled entries are ordered soonest first`() {
|
||||
val pending = JSONArray()
|
||||
.put(JSONObject().put("to", 1).put("text", "later").put("not_before", now + 7_200_000))
|
||||
.put(JSONObject().put("to", 1).put("text", "sooner").put("not_before", now + 60_000))
|
||||
val ordered = (0 until pending.length())
|
||||
.map { pending.getJSONObject(it) }
|
||||
.filter { it.optLong("not_before") > now }
|
||||
.sortedBy { it.optLong("not_before") }
|
||||
val pending =
|
||||
JSONArray()
|
||||
.put(JSONObject().put("to", 1).put("text", "later").put("not_before", now + 7_200_000))
|
||||
.put(JSONObject().put("to", 1).put("text", "sooner").put("not_before", now + 60_000))
|
||||
val ordered =
|
||||
(0 until pending.length())
|
||||
.map { pending.getJSONObject(it) }
|
||||
.filter { it.optLong("not_before") > now }
|
||||
.sortedBy { it.optLong("not_before") }
|
||||
assertEquals("sooner", ordered.first().optString("text"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ package io.flamenet.messenger.app
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.flamenet.client.MessengerApi
|
||||
@@ -21,278 +21,376 @@ import org.robolectric.annotation.GraphicsMode
|
||||
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.**
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
* beside the Sketch artboard and compared directly rather than mentally
|
||||
* rescaled from a 1080x2400 phone.
|
||||
*
|
||||
* Output lands in `app/build/screenshots`.
|
||||
* Renders screens to PNG on the JVM.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w375dp-h812dp-normal-long-notround-any-440dpi-keyshidden-nonav")
|
||||
class ScreenshotTest {
|
||||
|
||||
// ⚠️ **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
|
||||
// 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.
|
||||
// An Activity rule, not `createComposeRule()`.
|
||||
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val out = File("build/screenshots").also { it.mkdirs() }
|
||||
|
||||
/** Your address, with the QR the Kotlin encoder produces. */
|
||||
@Test fun addressCard() = shoot("address-card") {
|
||||
AddressCard(
|
||||
fnid = "fnid:kx7q2m4n8p3r5t7v9wxyz0123456789abcdefghjkmn",
|
||||
relay = "relay.flamenet.io",
|
||||
)
|
||||
}
|
||||
@Test fun addressCard() =
|
||||
shoot("address-card") {
|
||||
AddressCard(
|
||||
fnid = "fnid:kx7q2m4n8p3r5t7v9wxyz0123456789abcdefghjkmn",
|
||||
relay = "relay.flamenet.io",
|
||||
)
|
||||
}
|
||||
|
||||
/** The same card when the address could not be derived. */
|
||||
@Test fun addressCardMissing() = shoot("address-card-missing") {
|
||||
AddressCard(fnid = null, relay = "relay.flamenet.io")
|
||||
}
|
||||
@Test fun addressCardMissing() =
|
||||
shoot("address-card-missing") {
|
||||
AddressCard(fnid = null, relay = "relay.flamenet.io")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* something is tapped, so an unstarted model is enough to render them. It
|
||||
* 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.
|
||||
*/
|
||||
@Test fun contactsScreen() = shootFullScreen("screen-contacts") { model, phase ->
|
||||
androidx.compose.foundation.layout.Column { SignedIn(model, phase) }
|
||||
}
|
||||
@Test fun contactsScreen() =
|
||||
shootFullScreen("screen-contacts") { model, phase ->
|
||||
androidx.compose.foundation.layout
|
||||
.Column { SignedIn(model, phase) }
|
||||
}
|
||||
|
||||
@Test fun accountScreen() = shootFullScreen("screen-account") { model, phase ->
|
||||
androidx.compose.foundation.layout.Column { AccountScreen(model, phase) }
|
||||
}
|
||||
@Test fun accountScreen() =
|
||||
shootFullScreen("screen-account") { model, phase ->
|
||||
androidx.compose.foundation.layout
|
||||
.Column { AccountScreen(model, phase) }
|
||||
}
|
||||
|
||||
/** The feed, with a couple of stories on it. */
|
||||
@Test fun storiesFeed() = shootFullScreen("screen-stories") { model, phase ->
|
||||
androidx.compose.foundation.layout.Column { StoriesFeed(model, phase) }
|
||||
}
|
||||
|
||||
@Test fun storiesFeedEmpty() = shoot("screen-stories-empty") {
|
||||
val app = androidx.test.core.app.ApplicationProvider
|
||||
.getApplicationContext<android.app.Application>()
|
||||
androidx.compose.foundation.layout.Column {
|
||||
StoriesFeed(MessengerViewModel(app), samplePhase().copy(stories = emptyList()))
|
||||
@Test fun storiesFeed() =
|
||||
shootFullScreen("screen-stories") { model, phase ->
|
||||
androidx.compose.foundation.layout
|
||||
.Column { StoriesFeed(model, phase) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun callsScreen() = shootFullScreen("screen-calls") { model, phase ->
|
||||
androidx.compose.foundation.layout.Column { CallsScreen(model, phase) }
|
||||
}
|
||||
|
||||
@Test fun callsEmpty() = shoot("screen-calls-empty") {
|
||||
val app = androidx.test.core.app.ApplicationProvider
|
||||
.getApplicationContext<android.app.Application>()
|
||||
androidx.compose.foundation.layout.Column {
|
||||
CallsScreen(MessengerViewModel(app),
|
||||
samplePhase().copy(calls = emptyList()))
|
||||
@Test fun storiesFeedEmpty() =
|
||||
shoot("screen-stories-empty") {
|
||||
val app =
|
||||
androidx.test.core.app.ApplicationProvider
|
||||
.getApplicationContext<android.app.Application>()
|
||||
androidx.compose.foundation.layout.Column {
|
||||
StoriesFeed(MessengerViewModel(app), samplePhase().copy(stories = emptyList()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun alertsScreen() = shootFullScreen("screen-alerts") { model, phase ->
|
||||
androidx.compose.foundation.layout.Column { NotificationsScreen(model, phase) }
|
||||
}
|
||||
@Test fun callsScreen() =
|
||||
shootFullScreen("screen-calls") { model, phase ->
|
||||
androidx.compose.foundation.layout
|
||||
.Column { CallsScreen(model, phase) }
|
||||
}
|
||||
|
||||
@Test fun callsEmpty() =
|
||||
shoot("screen-calls-empty") {
|
||||
val app =
|
||||
androidx.test.core.app.ApplicationProvider
|
||||
.getApplicationContext<android.app.Application>()
|
||||
androidx.compose.foundation.layout.Column {
|
||||
CallsScreen(
|
||||
MessengerViewModel(app),
|
||||
samplePhase().copy(calls = emptyList()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun alertsScreen() =
|
||||
shootFullScreen("screen-alerts") { model, phase ->
|
||||
androidx.compose.foundation.layout
|
||||
.Column { NotificationsScreen(model, phase) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A conversation. The one screen with no render until now — it was checked
|
||||
* on the emulator before the palette changed, and not since.
|
||||
* A conversation.
|
||||
*/
|
||||
@Test fun chatScreen() = shootFullScreen("screen-chat") { model, phase ->
|
||||
androidx.compose.foundation.layout.Column {
|
||||
ChatScreen(model, sampleChat(), error = null, buddies = phase.buddies)
|
||||
@Test fun chatScreen() =
|
||||
shootFullScreen("screen-chat") { model, phase ->
|
||||
androidx.compose.foundation.layout.Column {
|
||||
ChatScreen(model, sampleChat(), error = null, buddies = phase.buddies)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sampleChat() = MessengerViewModel.Phase.Chat(
|
||||
threadId = "t1",
|
||||
title = "Hoang Nguyen",
|
||||
peerId = 33,
|
||||
trust = io.flamenet.client.IdentityTrust.PINNED,
|
||||
safetyNumber = "12345 67890 12345 67890 12345 67890",
|
||||
lines = listOf(
|
||||
Session.Line("sup", mine = true, at = 1_756_300_200_000),
|
||||
Session.Line("Notta", mine = false, at = 1_756_300_260_000),
|
||||
Session.Line(
|
||||
"A longer one, so the corner that points at its author is "
|
||||
+ "visible on a bubble that actually wraps.",
|
||||
mine = false, at = 1_756_300_320_000),
|
||||
Session.Line("And the same on this side.", mine = true, at = 1_756_300_380_000,
|
||||
delivery = Session.Delivery.DELIVERED),
|
||||
// The state that used to be invisible: sent while they were signed
|
||||
// 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
|
||||
// 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")),
|
||||
),
|
||||
)
|
||||
private fun sampleChat() =
|
||||
MessengerViewModel.Phase.Chat(
|
||||
threadId = "t1",
|
||||
title = "Hoang Nguyen",
|
||||
peerId = 33,
|
||||
trust = io.flamenet.client.IdentityTrust.PINNED,
|
||||
safetyNumber = "12345 67890 12345 67890 12345 67890",
|
||||
lines =
|
||||
listOf(
|
||||
Session.Line("sup", mine = true, at = 1_756_300_200_000),
|
||||
Session.Line("Notta", mine = false, at = 1_756_300_260_000),
|
||||
Session.Line(
|
||||
"A longer one, so the corner that points at its author is " +
|
||||
"visible on a bubble that actually wraps.",
|
||||
mine = false,
|
||||
at = 1_756_300_320_000,
|
||||
),
|
||||
Session.Line(
|
||||
"And the same on this side.",
|
||||
mine = true,
|
||||
at = 1_756_300_380_000,
|
||||
delivery = Session.Delivery.DELIVERED,
|
||||
),
|
||||
// The state that used to be invisible: sent while they were
|
||||
// signed out, so no device took it.
|
||||
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 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"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
/** One contact, the kit's way: cover, name plate, floating action rail. */
|
||||
@Test fun contactDetail() = shootFullScreen("screen-contact") { model, phase ->
|
||||
androidx.compose.foundation.layout.Column {
|
||||
ContactDetail(model, phase, phase.buddies[1]) {}
|
||||
@Test fun contactDetail() =
|
||||
shootFullScreen("screen-contact") { model, phase ->
|
||||
androidx.compose.foundation.layout.Column {
|
||||
ContactDetail(model, phase, phase.buddies[1]) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A call in progress — renderable because the look takes plain values. */
|
||||
@Test fun callActive() = shoot("screen-call-active") {
|
||||
CallScreenBody(
|
||||
peerName = "Hoang Nguyen",
|
||||
status = "On a call",
|
||||
actions = listOf(
|
||||
CallAction("mute", KitIcon.Mic, "Mute") {},
|
||||
CallAction("end", KitIcon.CallEnd, "End", isPrimary = true,
|
||||
tint = androidx.compose.material3.MaterialTheme.colorScheme.error,
|
||||
iconColor = androidx.compose.ui.graphics.Color.White) {},
|
||||
),
|
||||
)
|
||||
}
|
||||
/** A call in progress, renderable because the look takes plain values. */
|
||||
@Test fun callActive() =
|
||||
shoot("screen-call-active") {
|
||||
CallScreenBody(
|
||||
peerName = "Hoang Nguyen",
|
||||
status = "On a call",
|
||||
actions =
|
||||
listOf(
|
||||
CallAction("mute", KitIcon.Mic, "Mute") {},
|
||||
CallAction(
|
||||
"end",
|
||||
KitIcon.CallEnd,
|
||||
"End",
|
||||
isPrimary = true,
|
||||
tint = androidx.compose.material3.MaterialTheme.colorScheme.error,
|
||||
iconColor = androidx.compose.ui.graphics.Color.White,
|
||||
) {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun callRinging() = shoot("screen-call-ringing") {
|
||||
CallScreenBody(
|
||||
peerName = "Hoang Nguyen",
|
||||
status = "Incoming call",
|
||||
actions = listOf(
|
||||
CallAction("decline", KitIcon.CallEnd, "Decline",
|
||||
tint = androidx.compose.material3.MaterialTheme.colorScheme.error,
|
||||
iconColor = androidx.compose.ui.graphics.Color.White) {},
|
||||
CallAction("accept", KitIcon.Call, "Answer", isPrimary = true,
|
||||
tint = Palette.Teal,
|
||||
iconColor = androidx.compose.ui.graphics.Color.White) {},
|
||||
),
|
||||
warning = "This server has no call relay, so the call only connects "
|
||||
+ "if a direct path exists between the two networks.",
|
||||
)
|
||||
}
|
||||
@Test fun callRinging() =
|
||||
shoot("screen-call-ringing") {
|
||||
CallScreenBody(
|
||||
peerName = "Hoang Nguyen",
|
||||
status = "Incoming call",
|
||||
actions =
|
||||
listOf(
|
||||
CallAction(
|
||||
"decline",
|
||||
KitIcon.CallEnd,
|
||||
"Decline",
|
||||
tint = androidx.compose.material3.MaterialTheme.colorScheme.error,
|
||||
iconColor = androidx.compose.ui.graphics.Color.White,
|
||||
) {},
|
||||
CallAction(
|
||||
"accept",
|
||||
KitIcon.Call,
|
||||
"Answer",
|
||||
isPrimary = true,
|
||||
tint = Palette.Teal,
|
||||
iconColor = androidx.compose.ui.graphics.Color.White,
|
||||
) {},
|
||||
),
|
||||
warning =
|
||||
"This server has no call relay, so the call only connects " +
|
||||
"if a direct path exists between the two networks.",
|
||||
)
|
||||
}
|
||||
|
||||
/** The kit's buttons, so a change to one is visible without hunting. */
|
||||
@Test fun buttons() = shoot("buttons") {
|
||||
Column {
|
||||
KitPrimaryButton(text = "Create group", onClick = {})
|
||||
KitSecondaryButton(text = "Accept new keys", onClick = {}, danger = true)
|
||||
KitChip(text = "online", selected = true, onClick = {})
|
||||
KitChip(text = "away", selected = false, onClick = {})
|
||||
@Test fun buttons() =
|
||||
shoot("buttons") {
|
||||
Column {
|
||||
KitPrimaryButton(text = "Create group", onClick = {})
|
||||
KitSecondaryButton(text = "Accept new keys", onClick = {}, danger = true)
|
||||
KitChip(text = "online", selected = true, onClick = {})
|
||||
KitChip(text = "away", selected = false, onClick = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one composable inside the app's theme and write it out.
|
||||
*
|
||||
* ⚠️ `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.
|
||||
*/
|
||||
/** A phase with enough in it to exercise every row shape on a screen. */
|
||||
private fun samplePhase() = MessengerViewModel.Phase.SignedIn(
|
||||
handle = "fnreviewalpha",
|
||||
userId = 31,
|
||||
deviceId = "74d869d7-4667-4d97-854b-80ee684aaacc",
|
||||
relay = "relay.flamenet.io",
|
||||
fnid = "fnid:kx7q2m4n8p3r5t7v9wxyz0123456789abcdefghjkmn",
|
||||
status = "Signed in on this device.",
|
||||
buddies = listOf(
|
||||
MessengerApi.Buddy(userId = 32, handle = "fnreviewbeta",
|
||||
displayName = "fnreviewbeta", presence = "online"),
|
||||
MessengerApi.Buddy(userId = 33, handle = "hoang",
|
||||
displayName = "Hoang Nguyen", presence = "away"),
|
||||
),
|
||||
buddiesLoaded = true,
|
||||
groupsLoaded = true,
|
||||
// The row's right-hand column and second line only exist when there is
|
||||
// a thread behind them, so the render has to have one or it photographs
|
||||
// the empty state and looks like the change never landed.
|
||||
previews = mapOf(
|
||||
32L to Session.Preview(
|
||||
text = "Sure! Please give me the campaign details soon",
|
||||
at = System.currentTimeMillis() - 25 * 60_000L, mine = false),
|
||||
33L to Session.Preview(
|
||||
text = "okay let me see",
|
||||
at = System.currentTimeMillis() - 26 * 3600_000L, mine = true),
|
||||
),
|
||||
unread = setOf(io.flamenet.e2e.Vault.threadForUser(32)),
|
||||
stories = listOf(
|
||||
io.flamenet.e2e.Vault.Story(
|
||||
id = "s1", authorId = 33, mine = false,
|
||||
ref = io.flamenet.e2e.Attachment.Ref(
|
||||
kind = io.flamenet.e2e.Attachment.STORY,
|
||||
id = "blob1", key = ByteArray(32), digest = ByteArray(32),
|
||||
bytes = 1024, mime = "image/jpeg", width = 1200, height = 900,
|
||||
expires = System.currentTimeMillis() / 1000.0 + 7 * 3600),
|
||||
caption = "The view from the workshop this morning.",
|
||||
postedAt = System.currentTimeMillis(), seen = false),
|
||||
io.flamenet.e2e.Vault.Story(
|
||||
id = "s2", authorId = 32, mine = false,
|
||||
ref = io.flamenet.e2e.Attachment.Ref(
|
||||
kind = io.flamenet.e2e.Attachment.STORY,
|
||||
id = "blob2", key = ByteArray(32), digest = ByteArray(32),
|
||||
bytes = 1024, mime = "image/jpeg", width = 1200, height = 900,
|
||||
expires = System.currentTimeMillis() / 1000.0 + 1800),
|
||||
caption = "", postedAt = System.currentTimeMillis(), seen = true),
|
||||
),
|
||||
calls = listOf(
|
||||
Session.CallEntry(peerId = 32, incoming = true, connected = false,
|
||||
at = System.currentTimeMillis() - 40 * 60_000L),
|
||||
Session.CallEntry(peerId = 33, incoming = false, connected = true,
|
||||
at = System.currentTimeMillis() - 5 * 3600_000L),
|
||||
Session.CallEntry(peerId = 32, incoming = true, connected = true,
|
||||
at = System.currentTimeMillis() - 30 * 3600_000L),
|
||||
),
|
||||
notifications = listOf(
|
||||
MessengerApi.Notification(1, "contact request",
|
||||
"Hoang Nguyen wants to add you", read = false),
|
||||
MessengerApi.Notification(2, "missed call",
|
||||
"You missed a call from fnreviewbeta", read = true),
|
||||
),
|
||||
)
|
||||
|
||||
/** 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>()
|
||||
androidx.compose.foundation.layout.Column {
|
||||
NotificationsScreen(MessengerViewModel(app),
|
||||
samplePhase().copy(notifications = emptyList()))
|
||||
/** A phase with enough in it to exercise every row shape on a screen. */
|
||||
private fun samplePhase() =
|
||||
MessengerViewModel.Phase.SignedIn(
|
||||
handle = "fnreviewalpha",
|
||||
userId = 31,
|
||||
deviceId = "74d869d7-4667-4d97-854b-80ee684aaacc",
|
||||
relay = "relay.flamenet.io",
|
||||
fnid = "fnid:kx7q2m4n8p3r5t7v9wxyz0123456789abcdefghjkmn",
|
||||
status = "Signed in on this device.",
|
||||
buddies =
|
||||
listOf(
|
||||
MessengerApi.Buddy(
|
||||
userId = 32,
|
||||
handle = "fnreviewbeta",
|
||||
displayName = "fnreviewbeta",
|
||||
presence = "online",
|
||||
),
|
||||
MessengerApi.Buddy(
|
||||
userId = 33,
|
||||
handle = "hoang",
|
||||
displayName = "Hoang Nguyen",
|
||||
presence = "away",
|
||||
),
|
||||
),
|
||||
buddiesLoaded = true,
|
||||
groupsLoaded = true,
|
||||
// The row's right-hand column and second line only exist when there
|
||||
// is a thread behind them, so the render has to have one or it
|
||||
// photographs the empty state and looks like the change never
|
||||
// landed.
|
||||
previews =
|
||||
mapOf(
|
||||
32L to
|
||||
Session.Preview(
|
||||
text = "Sure! Please give me the campaign details soon",
|
||||
at = System.currentTimeMillis() - 25 * 60_000L,
|
||||
mine = false,
|
||||
),
|
||||
33L to
|
||||
Session.Preview(
|
||||
text = "okay let me see",
|
||||
at = System.currentTimeMillis() - 26 * 3600_000L,
|
||||
mine = true,
|
||||
),
|
||||
),
|
||||
unread =
|
||||
setOf(
|
||||
io.flamenet.e2e.Vault
|
||||
.threadForUser(32),
|
||||
),
|
||||
stories =
|
||||
listOf(
|
||||
io.flamenet.e2e.Vault.Story(
|
||||
id = "s1",
|
||||
authorId = 33,
|
||||
mine = false,
|
||||
ref =
|
||||
io.flamenet.e2e.Attachment.Ref(
|
||||
kind = io.flamenet.e2e.Attachment.STORY,
|
||||
id = "blob1",
|
||||
key = ByteArray(32),
|
||||
digest = ByteArray(32),
|
||||
bytes = 1024,
|
||||
mime = "image/jpeg",
|
||||
width = 1200,
|
||||
height = 900,
|
||||
expires = System.currentTimeMillis() / 1000.0 + 7 * 3600,
|
||||
),
|
||||
caption = "The view from the workshop this morning.",
|
||||
postedAt = System.currentTimeMillis(),
|
||||
seen = false,
|
||||
),
|
||||
io.flamenet.e2e.Vault.Story(
|
||||
id = "s2",
|
||||
authorId = 32,
|
||||
mine = false,
|
||||
ref =
|
||||
io.flamenet.e2e.Attachment.Ref(
|
||||
kind = io.flamenet.e2e.Attachment.STORY,
|
||||
id = "blob2",
|
||||
key = ByteArray(32),
|
||||
digest = ByteArray(32),
|
||||
bytes = 1024,
|
||||
mime = "image/jpeg",
|
||||
width = 1200,
|
||||
height = 900,
|
||||
expires = System.currentTimeMillis() / 1000.0 + 1800,
|
||||
),
|
||||
caption = "",
|
||||
postedAt = System.currentTimeMillis(),
|
||||
seen = true,
|
||||
),
|
||||
),
|
||||
calls =
|
||||
listOf(
|
||||
Session.CallEntry(
|
||||
peerId = 32,
|
||||
incoming = true,
|
||||
connected = false,
|
||||
at = System.currentTimeMillis() - 40 * 60_000L,
|
||||
),
|
||||
Session.CallEntry(
|
||||
peerId = 33,
|
||||
incoming = false,
|
||||
connected = true,
|
||||
at = System.currentTimeMillis() - 5 * 3600_000L,
|
||||
),
|
||||
Session.CallEntry(
|
||||
peerId = 32,
|
||||
incoming = true,
|
||||
connected = true,
|
||||
at = System.currentTimeMillis() - 30 * 3600_000L,
|
||||
),
|
||||
),
|
||||
notifications =
|
||||
listOf(
|
||||
MessengerApi.Notification(
|
||||
1,
|
||||
"contact request",
|
||||
"Hoang Nguyen wants to add you",
|
||||
read = false,
|
||||
),
|
||||
MessengerApi.Notification(
|
||||
2,
|
||||
"missed call",
|
||||
"You missed a call from fnreviewbeta",
|
||||
read = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
/** 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>()
|
||||
androidx.compose.foundation.layout.Column {
|
||||
NotificationsScreen(
|
||||
MessengerViewModel(app),
|
||||
samplePhase().copy(notifications = emptyList()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shootFullScreen(
|
||||
name: String,
|
||||
content: @Composable (MessengerViewModel, MessengerViewModel.Phase.SignedIn) -> Unit,
|
||||
) {
|
||||
val app = androidx.test.core.app.ApplicationProvider
|
||||
.getApplicationContext<android.app.Application>()
|
||||
val app =
|
||||
androidx.test.core.app.ApplicationProvider
|
||||
.getApplicationContext<android.app.Application>()
|
||||
val model = MessengerViewModel(app)
|
||||
val phase = samplePhase()
|
||||
shoot(name) { content(model, phase) }
|
||||
}
|
||||
|
||||
private fun shoot(name: String, content: @Composable () -> Unit) {
|
||||
private fun shoot(
|
||||
name: String,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
compose.setContent {
|
||||
MessengerTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
@@ -302,20 +400,16 @@ class ScreenshotTest {
|
||||
}
|
||||
compose.waitForIdle()
|
||||
|
||||
// 🔴 **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
|
||||
// hung composition and is nothing of the sort. `View.draw` into a
|
||||
// software canvas produces the same pixels with no window involved.
|
||||
// Draw the view, do not `captureToImage()`.
|
||||
var shot: Bitmap? = null
|
||||
compose.activityRule.scenario.onActivity { activity ->
|
||||
val view = activity.window.decorView
|
||||
val bitmap = Bitmap.createBitmap(
|
||||
view.width.coerceAtLeast(1),
|
||||
view.height.coerceAtLeast(1),
|
||||
Bitmap.Config.ARGB_8888,
|
||||
)
|
||||
val bitmap =
|
||||
Bitmap.createBitmap(
|
||||
view.width.coerceAtLeast(1),
|
||||
view.height.coerceAtLeast(1),
|
||||
Bitmap.Config.ARGB_8888,
|
||||
)
|
||||
view.draw(Canvas(bitmap))
|
||||
shot = bitmap
|
||||
}
|
||||
@@ -326,6 +420,8 @@ class ScreenshotTest {
|
||||
println("wrote ${File(out, "$name.png").absolutePath} (${bitmap.width}x${bitmap.height})")
|
||||
}
|
||||
|
||||
private fun <T : Any> requireNonNull(value: T?, message: String): T =
|
||||
value ?: throw AssertionError(message)
|
||||
private fun <T : Any> requireNonNull(
|
||||
value: T?,
|
||||
message: String,
|
||||
): T = value ?: throw AssertionError(message)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.AfterTest
|
||||
@@ -15,27 +18,13 @@ import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
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 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
|
||||
* a group, which is the whole thing sealing exists to stop.
|
||||
*
|
||||
* ⚠️ 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.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class SealedGroupListTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
/** The blob the relay hands back for the sealed fetch. */
|
||||
@@ -45,67 +34,102 @@ 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() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
return when {
|
||||
"/e2e/groups/fetch" in path -> json(
|
||||
"""{"groups":[{"gid":"g1","owner_key":"","roster_blob":"$sealedBlob",""" +
|
||||
""""roster_version":1}]}""")
|
||||
"/e2e/groups" in path -> json(
|
||||
"""{"groups":[{"gid":"g1","name":"Group","owner":1,"members":[""" +
|
||||
plaintextRoster.joinToString(",") { """{"user_id":$it}""" } + "]}]}")
|
||||
else -> json("{}")
|
||||
"/e2e/groups/fetch" in path -> {
|
||||
json(
|
||||
"""{"groups":[{"gid":"g1","owner_key":"","roster_blob":"$sealedBlob",""" +
|
||||
""""roster_version":1}]}""",
|
||||
)
|
||||
}
|
||||
|
||||
"/e2e/groups" in path -> {
|
||||
json(
|
||||
"""{"groups":[{"gid":"g1","name":"Group","owner":1,"members":[""" +
|
||||
plaintextRoster.joinToString(",") { """{"user_id":$it}""" } + "]}]}",
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
json("{}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.dispatcher = Relay(); it.start() }
|
||||
server =
|
||||
MockWebServer().also {
|
||||
it.dispatcher = Relay()
|
||||
it.start()
|
||||
}
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
private val records = mutableMapOf<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun key(): SecretKey =
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
private fun session(): Pair<Session, Vault> {
|
||||
val identity = DeviceIdentity.create()
|
||||
val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) }
|
||||
val base = server.url("/").toString()
|
||||
val s = Session(
|
||||
handle = "alice",
|
||||
userId = 1,
|
||||
identity = identity,
|
||||
vault = vault,
|
||||
relay = RelayClient(base, "alice", "app pass word"),
|
||||
api = MessengerApi(base, "alice", "app pass word"),
|
||||
)
|
||||
val s =
|
||||
Session(
|
||||
handle = "alice",
|
||||
userId = 1,
|
||||
identity = identity,
|
||||
vault = vault,
|
||||
relay = RelayClient(base, "alice", "app pass word"),
|
||||
api = MessengerApi(base, "alice", "app pass word"),
|
||||
)
|
||||
return s to vault
|
||||
}
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
/** Hold the tag and key for `g1`, sealing the roster the test names. */
|
||||
private fun holdSealed(vault: Vault, members: List<Long>, name: String) {
|
||||
private fun holdSealed(
|
||||
vault: Vault,
|
||||
members: List<Long>,
|
||||
name: String,
|
||||
) {
|
||||
val groupKey = SealedRoster.newGroupKey()
|
||||
val tags = members.associate { it.toString() to SealedRoster.newTag() }
|
||||
sealedBlob = SealedRoster.seal(SealedRoster.Contents(members, tags, name), groupKey)
|
||||
vault.saveGroupSecrets("g1", Vault.GroupSecrets(
|
||||
tag = tags["1"] ?: "", key = groupKey, ownerPrivate = null, version = 1))
|
||||
vault.saveGroupSecrets(
|
||||
"g1",
|
||||
Vault.GroupSecrets(tag = tags["1"] ?: "", key = groupKey, ownerPrivate = null, version = 1),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -122,8 +146,8 @@ class SealedGroupListTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 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.
|
||||
* The relay names a third account as a member of a group whose roster is
|
||||
* sealed.
|
||||
*/
|
||||
@Test
|
||||
fun `the relay cannot add a member to a sealed group`() {
|
||||
@@ -133,8 +157,11 @@ class SealedGroupListTest {
|
||||
plaintextRoster = listOf(1L, 2L, 99L)
|
||||
|
||||
val g1 = session.namedGroups().first { it.id == "g1" }
|
||||
assertEquals(listOf(1L, 2L), g1.memberIds.sorted(),
|
||||
"the relay's roster replaced the one out of the blob")
|
||||
assertEquals(
|
||||
listOf(1L, 2L),
|
||||
g1.memberIds.sorted(),
|
||||
"the relay's roster replaced the one out of the blob",
|
||||
)
|
||||
assertTrue(99L !in g1.memberIds, "a relay-supplied member entered a sealed group")
|
||||
}
|
||||
|
||||
@@ -145,14 +172,15 @@ class SealedGroupListTest {
|
||||
holdSealed(vault, listOf(1L, 2L), "Book club")
|
||||
plaintextRoster = listOf(1L, 2L)
|
||||
|
||||
assertEquals(1, session.namedGroups().count { it.id == "g1" },
|
||||
"the group is listed twice, sealed and plaintext")
|
||||
assertEquals(
|
||||
1,
|
||||
session.namedGroups().count { it.id == "g1" },
|
||||
"the group is listed twice, sealed and plaintext",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 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.
|
||||
* A group we hold no secrets for is still the relay's to describe.
|
||||
*/
|
||||
@Test
|
||||
fun `an unsealed group still comes from the relay`() {
|
||||
|
||||
@@ -9,23 +9,16 @@ import org.junit.Test
|
||||
|
||||
/**
|
||||
* The relay is asked what it is; the answer has to survive the trip to the UI.
|
||||
*
|
||||
* 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:
|
||||
* 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.
|
||||
*/
|
||||
class SiteConfigIdentityTest {
|
||||
|
||||
private val shared = RelayDiscovery.Descriptor(
|
||||
audience = "relay.flamenet.io",
|
||||
accounts = RelayDiscovery.Accounts.SHARED,
|
||||
accountsUrl = "https://accounts.flamenet.io",
|
||||
federation = "closed",
|
||||
protocolVersions = listOf(1, 2, 3),
|
||||
)
|
||||
private val shared =
|
||||
RelayDiscovery.Descriptor(
|
||||
audience = "relay.flamenet.io",
|
||||
accounts = RelayDiscovery.Accounts.SHARED,
|
||||
accountsUrl = "https://accounts.flamenet.io",
|
||||
federation = "closed",
|
||||
protocolVersions = listOf(1, 2, 3),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a discovered config is not equal to the same relay undiscovered`() {
|
||||
|
||||
@@ -7,24 +7,28 @@ 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.**
|
||||
* 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
|
||||
* 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
|
||||
* rejects is a report somebody believed they had filed.
|
||||
* The wire values must match what the relay accepts.
|
||||
*/
|
||||
@Test
|
||||
fun `every reason matches the relays enumeration`() {
|
||||
val accepted = setOf("abuse", "harassment", "spam",
|
||||
"impersonation", "csam", "other")
|
||||
assertEquals(accepted,
|
||||
MessengerViewModel.ReportReason.entries.map { it.wire }.toSet())
|
||||
val accepted =
|
||||
setOf(
|
||||
"abuse",
|
||||
"harassment",
|
||||
"spam",
|
||||
"impersonation",
|
||||
"csam",
|
||||
"other",
|
||||
)
|
||||
assertEquals(
|
||||
accepted,
|
||||
MessengerViewModel.ReportReason.entries
|
||||
.map { it.wire }
|
||||
.toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
/** A raw wire value in a picker is the wire format leaking into the UI. */
|
||||
@@ -37,23 +41,27 @@ class StoryReportTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 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.
|
||||
* 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.
|
||||
*/
|
||||
@Test
|
||||
fun `csam is its own reason`() {
|
||||
assertTrue(MessengerViewModel.ReportReason.entries
|
||||
.any { it == MessengerViewModel.ReportReason.CSAM })
|
||||
assertTrue(
|
||||
MessengerViewModel.ReportReason.entries
|
||||
.any { it == MessengerViewModel.ReportReason.CSAM },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Without one, the picker forces a person to mis-categorise in order to
|
||||
* report at all.
|
||||
* Without one, the picker forces a person to mis-categorise to report at
|
||||
* all.
|
||||
*/
|
||||
@Test
|
||||
fun `there is a reason for anything else`() {
|
||||
assertTrue(MessengerViewModel.ReportReason.entries
|
||||
.any { it == MessengerViewModel.ReportReason.OTHER })
|
||||
assertTrue(
|
||||
MessengerViewModel.ReportReason.entries
|
||||
.any { it == MessengerViewModel.ReportReason.OTHER },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,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
|
||||
* 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
|
||||
* these cover the arithmetic underneath it.
|
||||
*/
|
||||
class WalletDisplayTest {
|
||||
|
||||
// ---- Lamports as SOL -------------------------------------------------
|
||||
// ---- Lamports as SOL -------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `a whole number of SOL reads without a decimal tail`() {
|
||||
@@ -30,8 +24,7 @@ class WalletDisplayTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 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.
|
||||
* Zero is a real balance and reads as "0", not as an empty string.
|
||||
*/
|
||||
@Test
|
||||
fun `an empty account reads as zero, not as nothing`() {
|
||||
@@ -39,20 +32,17 @@ class WalletDisplayTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Five SOL already exceeds Int.MAX_VALUE in lamports. Anything holding a
|
||||
* balance in an Int would overflow long before the number got interesting.
|
||||
* Five SOL already exceeds Int.MAX_VALUE in lamports.
|
||||
*/
|
||||
@Test
|
||||
fun `a large balance is not truncated`() {
|
||||
assertEquals("9223372036.854775807", WalletChain.sol(Long.MAX_VALUE))
|
||||
}
|
||||
|
||||
// ---- What can actually be sent ---------------------------------------
|
||||
// ---- What can actually be sent ---------------------------------------.
|
||||
|
||||
/**
|
||||
* 🔴 **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.
|
||||
* An account below the rent-exempt minimum is RECLAIMED by the network.
|
||||
*/
|
||||
@Test
|
||||
fun `spendable leaves the fee and the rent-exempt minimum behind`() {
|
||||
@@ -78,12 +68,10 @@ class WalletDisplayTest {
|
||||
assertEquals(1L, WalletChain.spendable(floor + 1))
|
||||
}
|
||||
|
||||
// ---- Which node ------------------------------------------------------
|
||||
// ---- Which node ------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* 🔴 **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.
|
||||
* http is ignored rather than honoured.
|
||||
*/
|
||||
@Test
|
||||
fun `the default endpoint is https`() {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -4,52 +4,23 @@ import io.flamenet.e2e.Ed25519
|
||||
import io.flamenet.e2e.FNID
|
||||
|
||||
/**
|
||||
* Turning a passphrase into an address, and telling relays where to deliver.
|
||||
*
|
||||
* ## 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
|
||||
* 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.
|
||||
*
|
||||
* The practical consequence is the one worth stating: sign in with the same
|
||||
* passphrase at a relay that has never heard of you and you get **the same
|
||||
* address**. Contacts who accepted you before still see the same person. A
|
||||
* relay shutting down costs a mailbox, not an identity.
|
||||
*
|
||||
* ## Why it is here and not in the engine
|
||||
*
|
||||
* The engine holds no network. Deriving the key and building the bytes to sign
|
||||
* are engine work and live in [FNID] and `AccountKdf`; presenting the result to
|
||||
* a relay is client work and lives here, so the engine stays testable without
|
||||
* one and this stays swappable without touching crypto.
|
||||
* Turning a passphrase into an address, and telling relays where to deliver. ##
|
||||
* Why this exists at all A relay's `user_id` is a row number it assigned
|
||||
* itself.
|
||||
*/
|
||||
object AccountIdentity {
|
||||
|
||||
/** The address for an account seed. Derived, never fetched. */
|
||||
fun address(seed: ByteArray): String = FNID.address(Ed25519.fromSeed(seed).publicKey)
|
||||
|
||||
/**
|
||||
* 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
|
||||
* relay checks the signature against, so a caller that inferred it from a
|
||||
* 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
|
||||
* us the identity.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Returns the address the relay recorded.
|
||||
*/
|
||||
fun claim(relay: RelayClient, seed: ByteArray, userId: Long): String {
|
||||
fun claim(
|
||||
relay: RelayClient,
|
||||
seed: ByteArray,
|
||||
userId: Long,
|
||||
): String {
|
||||
val key = Ed25519.fromSeed(seed)
|
||||
val pub = b64(key.publicKey)
|
||||
val audience = relay.relayDescriptor().getString("audience")
|
||||
@@ -59,26 +30,12 @@ object AccountIdentity {
|
||||
|
||||
/**
|
||||
* Publish where this account keeps mailboxes, to every relay in the list.
|
||||
*
|
||||
* Every listed relay is told, not just the one being added: a home that
|
||||
* does not know it is one will still accept mail, but a *sender* asking it
|
||||
* 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
|
||||
* 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
|
||||
* somewhere means registering there first, and a map makes that a
|
||||
* compile-time fact instead of a runtime surprise.
|
||||
*
|
||||
* Returns the relays that refused, with why. Reported rather than thrown on
|
||||
* the first failure: one home being unreachable while the rest accept is a
|
||||
* partial success the user can act on, and collapsing it into a single
|
||||
* error would hide which.
|
||||
*/
|
||||
fun publishHomes(
|
||||
seed: ByteArray, fnid: String, serial: Int, homes: Map<String, RelayClient>
|
||||
seed: ByteArray,
|
||||
fnid: String,
|
||||
serial: Int,
|
||||
homes: Map<String, RelayClient>,
|
||||
): Map<String, Throwable> {
|
||||
val relays = homes.keys.toList()
|
||||
val sig = b64(Ed25519.sign(seed, FNID.homesCanonical(fnid, serial, relays)))
|
||||
@@ -93,12 +50,11 @@ object AccountIdentity {
|
||||
/**
|
||||
* The next serial to publish, read from what is already out there rather
|
||||
* 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
|
||||
* check that exists to stop an attacker doing the same thing.
|
||||
*/
|
||||
fun nextSerial(fnid: String, clients: Collection<RelayClient>): Int {
|
||||
fun nextSerial(
|
||||
fnid: String,
|
||||
clients: Collection<RelayClient>,
|
||||
): Int {
|
||||
var highest = 0
|
||||
for (c in clients) {
|
||||
val record = runCatching { c.homes(fnid) }.getOrNull() ?: continue
|
||||
@@ -109,12 +65,13 @@ 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 —
|
||||
* which is the point. A sender must not have to ask a relay whether that
|
||||
* relay is telling the truth about where to deliver.
|
||||
*/
|
||||
fun verifyHomes(fnid: String, serial: Int, relays: List<String>, signatureB64: String): Boolean {
|
||||
fun verifyHomes(
|
||||
fnid: String,
|
||||
serial: Int,
|
||||
relays: List<String>,
|
||||
signatureB64: String,
|
||||
): Boolean {
|
||||
val pub = FNID.decode(fnid) ?: return false
|
||||
val sig = runCatching { unb64(signatureB64) }.getOrNull() ?: return false
|
||||
return Ed25519.verify(pub, sig, FNID.homesCanonical(fnid, serial, relays))
|
||||
@@ -123,27 +80,6 @@ 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
|
||||
* 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
|
||||
* the sender delivers to one relay believing it is delivering to all of
|
||||
* them.
|
||||
*
|
||||
* That is the attack multi-homing exists to survive, and until this both
|
||||
* 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
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
fun verifiedRelays(
|
||||
record: org.json.JSONObject,
|
||||
@@ -171,9 +107,8 @@ object AccountIdentity {
|
||||
}
|
||||
|
||||
// The module's own helper, so this encodes identically to every other blob
|
||||
// the transport sends. `android.util.Base64` is deliberately not reachable
|
||||
// here: this module is plain JVM precisely so its tests run against a real
|
||||
// relay with no emulator.
|
||||
// the transport sends.
|
||||
private fun b64(b: ByteArray): String = Wire.b64(b)
|
||||
|
||||
private fun unb64(s: String): ByteArray = Wire.unb64(s)
|
||||
}
|
||||
|
||||
@@ -10,52 +10,32 @@ 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 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
|
||||
* [SiteConfig], which decides from the hostname rather than from anything the
|
||||
* relay says about itself.
|
||||
*/
|
||||
class AccountsClient(
|
||||
baseUrl: String = "https://accounts.flamenet.io",
|
||||
private val http: OkHttpClient = RelayClient.defaultHttpClient(),
|
||||
) {
|
||||
|
||||
private val base = baseUrl.trimEnd('/').toHttpUrl()
|
||||
|
||||
/**
|
||||
* The product a session is scoped to.
|
||||
*
|
||||
* ⚠️ **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.
|
||||
*/
|
||||
private val defaultProduct = "messenger"
|
||||
|
||||
/**
|
||||
* What the service advertises for a handle.
|
||||
*
|
||||
* ⚠️ 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" —
|
||||
* a UI that says "no such account" here undoes the whole design.
|
||||
*/
|
||||
data class Params(val salt: ByteArray, val kdfId: Int, val iterations: Int)
|
||||
data class Params(
|
||||
val salt: ByteArray,
|
||||
val kdfId: Int,
|
||||
val iterations: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* @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
|
||||
* why it comes back here rather than being asked for separately.
|
||||
* this account's address.
|
||||
*/
|
||||
data class Session(
|
||||
val token: String,
|
||||
@@ -64,54 +44,64 @@ class AccountsClient(
|
||||
val accountKeySeed: ByteArray? = null,
|
||||
)
|
||||
|
||||
sealed class Failure(message: String) : Exception(message) {
|
||||
sealed class Failure(
|
||||
message: String,
|
||||
) : Exception(message) {
|
||||
/** Wrong handle and wrong passphrase are the same answer, on purpose. */
|
||||
object BadCredentials : Failure("that handle or passphrase isn't right")
|
||||
|
||||
/**
|
||||
* The session is no longer accepted — expired, or revoked elsewhere.
|
||||
*
|
||||
* 🔴 **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
|
||||
* 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.
|
||||
* The session is no longer accepted, expired, or revoked elsewhere.
|
||||
*/
|
||||
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.**
|
||||
* The relay answers 409 for a taken handle, a registered email, AND a
|
||||
* 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.
|
||||
*
|
||||
* The status is the transport's opinion; the `code` in the body is the
|
||||
* relay's, and it is the one that means something.
|
||||
* A 409 is not always a taken handle, and saying so was wrong.
|
||||
*/
|
||||
object EmailTaken : Failure("an account with that email already exists")
|
||||
|
||||
/** The handle folds onto an existing one — see the relay's
|
||||
* `User.confusableSkeleton`. Changing a digit will not help. */
|
||||
/**
|
||||
* The handle folds onto an existing one, see the relay's.
|
||||
*/
|
||||
object HandleConfusable : Failure("that handle is too easily mistaken for another")
|
||||
|
||||
object DisposableEmail : Failure("that email provider isn't accepted")
|
||||
|
||||
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")
|
||||
class Service(val status: Int) : Failure("the account service returned $status")
|
||||
class Transport(cause: String) : Failure("couldn't reach the account service: $cause")
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
fun parameters(handle: String): Params {
|
||||
val url = base.newBuilder().addPathSegment("accounts").addPathSegment(handle)
|
||||
.addPathSegment("params").build()
|
||||
val json = send(Request.Builder().url(url).get().build())
|
||||
val url =
|
||||
base
|
||||
.newBuilder()
|
||||
.addPathSegment("accounts")
|
||||
.addPathSegment(handle)
|
||||
.addPathSegment("params")
|
||||
.build()
|
||||
val json =
|
||||
send(
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build(),
|
||||
)
|
||||
return Params(
|
||||
salt = Wire.unb64(json.getString("kdfSalt")),
|
||||
kdfId = json.getInt("kdfID"),
|
||||
@@ -119,26 +109,30 @@ class AccountsClient(
|
||||
)
|
||||
}
|
||||
|
||||
fun signIn(handle: String, passphrase: String, product: String = defaultProduct): Session {
|
||||
fun signIn(
|
||||
handle: String,
|
||||
passphrase: String,
|
||||
product: String = defaultProduct,
|
||||
): Session {
|
||||
val params = parameters(handle)
|
||||
// ⚠️ 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
|
||||
// locks out every older account.
|
||||
val effective = AccountKdf.Params(
|
||||
id = params.kdfId,
|
||||
iterations = params.iterations,
|
||||
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
|
||||
// seconds on a phone, spent twice, to sign in once.
|
||||
// The ADVERTISED iteration count wins over the local constant.
|
||||
val effective =
|
||||
AccountKdf.Params(
|
||||
id = params.kdfId,
|
||||
iterations = params.iterations,
|
||||
saltBytes = params.salt.size,
|
||||
)
|
||||
// Both secrets from ONE stretch.
|
||||
val (secret, accountKeySeed) = AccountKdf.signInSecrets(passphrase, params.salt, effective)
|
||||
|
||||
val json = post("sessions", JSONObject()
|
||||
.put("handle", handle)
|
||||
.put("authSecret", Wire.b64(secret))
|
||||
.put("product", product))
|
||||
val json =
|
||||
post(
|
||||
"sessions",
|
||||
JSONObject()
|
||||
.put("handle", handle)
|
||||
.put("authSecret", Wire.b64(secret))
|
||||
.put("product", product),
|
||||
)
|
||||
return Session(
|
||||
token = json.getString("token"),
|
||||
pseudonym = json.optString("pseudonym", ""),
|
||||
@@ -148,89 +142,117 @@ class AccountsClient(
|
||||
}
|
||||
|
||||
/** Create an account. The salt is generated here, not by the service. */
|
||||
fun register(handle: String, passphrase: String) {
|
||||
fun register(
|
||||
handle: String,
|
||||
passphrase: String,
|
||||
) {
|
||||
val params = AccountKdf.Params.PBKDF2_V1
|
||||
val salt = ByteArray(params.saltBytes).also { SecureRandom().nextBytes(it) }
|
||||
val secret = AccountKdf.authSecret(passphrase, salt, params)
|
||||
post("accounts", JSONObject()
|
||||
.put("handle", handle)
|
||||
.put("authSecret", Wire.b64(secret))
|
||||
.put("kdfSalt", Wire.b64(salt))
|
||||
.put("kdfIterations", params.iterations))
|
||||
post(
|
||||
"accounts",
|
||||
JSONObject()
|
||||
.put("handle", handle)
|
||||
.put("authSecret", Wire.b64(secret))
|
||||
.put("kdfSalt", Wire.b64(salt))
|
||||
.put("kdfIterations", params.iterations),
|
||||
)
|
||||
}
|
||||
|
||||
private fun post(path: String, body: JSONObject): JSONObject = send(
|
||||
Request.Builder()
|
||||
.url(base.newBuilder().addPathSegments(path).build())
|
||||
.post(body.toString().toRequestBody(JSON))
|
||||
.header("Accept", "application/json")
|
||||
.build(),
|
||||
// Only these two actually present a passphrase, so only these two can
|
||||
// meaningfully report one as wrong.
|
||||
carriesCredentials = path == "sessions" || path == "accounts",
|
||||
)
|
||||
private fun post(
|
||||
path: String,
|
||||
body: JSONObject,
|
||||
): JSONObject =
|
||||
send(
|
||||
Request
|
||||
.Builder()
|
||||
.url(base.newBuilder().addPathSegments(path).build())
|
||||
.post(body.toString().toRequestBody(JSON))
|
||||
.header("Accept", "application/json")
|
||||
.build(),
|
||||
// Only these two actually present a passphrase, so only these two
|
||||
// can meaningfully report one as wrong.
|
||||
carriesCredentials = path == "sessions" || path == "accounts",
|
||||
)
|
||||
|
||||
private fun send(request: Request, carriesCredentials: Boolean = false): JSONObject {
|
||||
val response = try {
|
||||
http.newCall(request).execute()
|
||||
} catch (e: Exception) {
|
||||
throw Failure.Transport(e.message ?: e.toString())
|
||||
}
|
||||
private fun send(
|
||||
request: Request,
|
||||
carriesCredentials: Boolean = false,
|
||||
): JSONObject {
|
||||
val response =
|
||||
try {
|
||||
http.newCall(request).execute()
|
||||
} catch (e: Exception) {
|
||||
throw Failure.Transport(e.message ?: e.toString())
|
||||
}
|
||||
response.use {
|
||||
val text = it.body?.string().orEmpty()
|
||||
when (it.code) {
|
||||
in 200..299 -> return if (text.isBlank()) JSONObject() else try {
|
||||
JSONObject(text)
|
||||
} catch (_: Exception) {
|
||||
throw Failure.Transport("unreadable response")
|
||||
in 200..299 -> {
|
||||
return if (text.isBlank()) {
|
||||
JSONObject()
|
||||
} else {
|
||||
try {
|
||||
JSONObject(text)
|
||||
} catch (_: Exception) {
|
||||
throw Failure.Transport("unreadable response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The SAME status means different things depending on what was
|
||||
// sent.
|
||||
401, 403 -> {
|
||||
throw if (carriesCredentials) {
|
||||
Failure.BadCredentials
|
||||
} else {
|
||||
Failure.SessionExpired
|
||||
}
|
||||
}
|
||||
|
||||
// Read the relay's own code, not just the status.
|
||||
409, 400 -> {
|
||||
throw failureFor(code(text), status = it.code)
|
||||
}
|
||||
|
||||
429 -> {
|
||||
throw Failure.RateLimited
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw Failure.Service(it.code)
|
||||
}
|
||||
// ⚠️ 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
|
||||
// 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
|
||||
// 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
|
||||
// here ever read it.
|
||||
409, 400 -> throw failureFor(code(text), status = it.code)
|
||||
429 -> throw Failure.RateLimited
|
||||
else -> throw Failure.Service(it.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* a reason to lose the status we already have.
|
||||
*/
|
||||
private fun code(body: String): String? = runCatching {
|
||||
JSONObject(body).optString("code").takeIf { it.isNotEmpty() }
|
||||
}.getOrNull()
|
||||
private fun code(body: String): String? =
|
||||
runCatching {
|
||||
JSONObject(body).optString("code").takeIf { it.isNotEmpty() }
|
||||
}.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.
|
||||
* success or a crash.
|
||||
*/
|
||||
private fun failureFor(code: String?, status: Int): Failure = when (code) {
|
||||
"username_taken" -> Failure.HandleTaken
|
||||
"email_taken" -> Failure.EmailTaken
|
||||
"username_confusable" -> Failure.HandleConfusable
|
||||
"disposable_email" -> Failure.DisposableEmail
|
||||
"bad_username" -> Failure.BadHandle
|
||||
"bad_email" -> Failure.BadEmail
|
||||
"weak_password" -> Failure.WeakPassword
|
||||
else -> if (status == 409) Failure.HandleTaken else Failure.Service(status)
|
||||
}
|
||||
private fun failureFor(
|
||||
code: String?,
|
||||
status: Int,
|
||||
): Failure =
|
||||
when (code) {
|
||||
"username_taken" -> Failure.HandleTaken
|
||||
"email_taken" -> Failure.EmailTaken
|
||||
"username_confusable" -> Failure.HandleConfusable
|
||||
"disposable_email" -> Failure.DisposableEmail
|
||||
"bad_username" -> Failure.BadHandle
|
||||
"bad_email" -> Failure.BadEmail
|
||||
"weak_password" -> Failure.WeakPassword
|
||||
else -> if (status == 409) Failure.HandleTaken else Failure.Service(status)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val JSON = "application/json".toMediaType()
|
||||
|
||||
@@ -9,63 +9,63 @@ 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
|
||||
* 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
|
||||
* 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
|
||||
* works at all, not because it is private.
|
||||
*/
|
||||
class ChainRpc(
|
||||
val endpoint: String,
|
||||
private val http: OkHttpClient = OkHttpClient(),
|
||||
) {
|
||||
|
||||
sealed class Failure(message: String) : Exception(message) {
|
||||
sealed class Failure(
|
||||
message: String,
|
||||
) : Exception(message) {
|
||||
/** The transport refused before any JSON-RPC happened. */
|
||||
class Http(val code: Int) : Failure("The node answered with $code.")
|
||||
class Http(
|
||||
val code: Int,
|
||||
) : Failure("The node answered with $code.")
|
||||
|
||||
/**
|
||||
* The node's own words. It knows why it refused and we do not, so its
|
||||
* message is carried rather than replaced with a guess.
|
||||
* The node's own words.
|
||||
*/
|
||||
class Node(val detail: String) : Failure(detail)
|
||||
class Node(
|
||||
val detail: String,
|
||||
) : Failure(detail)
|
||||
|
||||
class Malformed(val what: String) :
|
||||
Failure("That node's answer wasn't something this app understands.")
|
||||
class Malformed(
|
||||
val what: String,
|
||||
) : Failure("That node's answer wasn't something this app understands.")
|
||||
}
|
||||
|
||||
// ---- Wire ------------------------------------------------------------
|
||||
// ---- Wire ------------------------------------------------------------.
|
||||
|
||||
private fun call(method: String, params: JSONArray): Any {
|
||||
val body = JSONObject()
|
||||
.put("jsonrpc", "2.0")
|
||||
.put("id", 1)
|
||||
.put("method", method)
|
||||
.put("params", params)
|
||||
.toString()
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
private fun call(
|
||||
method: String,
|
||||
params: JSONArray,
|
||||
): Any {
|
||||
val body =
|
||||
JSONObject()
|
||||
.put("jsonrpc", "2.0")
|
||||
.put("id", 1)
|
||||
.put("method", method)
|
||||
.put("params", params)
|
||||
.toString()
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder().url(endpoint).post(body).build()
|
||||
val text = http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) throw Failure.Http(response.code)
|
||||
response.body?.string().orEmpty()
|
||||
}
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(endpoint)
|
||||
.post(body)
|
||||
.build()
|
||||
val text =
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) throw Failure.Http(response.code)
|
||||
response.body?.string().orEmpty()
|
||||
}
|
||||
|
||||
val json = runCatching { JSONObject(text) }.getOrNull()
|
||||
?: throw Failure.Malformed("not an object")
|
||||
val json =
|
||||
runCatching { JSONObject(text) }.getOrNull()
|
||||
?: throw Failure.Malformed("not an object")
|
||||
|
||||
// 🔴 **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
|
||||
// the difference between "insufficient funds for rent" on screen and a
|
||||
// parse error nobody can act on.
|
||||
// A JSON-RPC error arrives with HTTP 200.
|
||||
json.optJSONObject("error")?.let { error ->
|
||||
throw Failure.Node(error.optString("message").ifEmpty { "The node refused." })
|
||||
}
|
||||
@@ -73,14 +73,10 @@ class ChainRpc(
|
||||
return json.get("result")
|
||||
}
|
||||
|
||||
// ---- Reading ---------------------------------------------------------
|
||||
// ---- Reading ---------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* The balance in lamports — the chain's smallest unit.
|
||||
*
|
||||
* ⚠️ 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.
|
||||
* The balance in lamports, the chain's smallest unit.
|
||||
*/
|
||||
fun balance(address: String): Long {
|
||||
val result = call("getBalance", JSONArray().put(address))
|
||||
@@ -90,45 +86,48 @@ class ChainRpc(
|
||||
}
|
||||
|
||||
/** What has happened to an address, newest first. */
|
||||
fun history(address: String, limit: Int = 25): List<Entry> {
|
||||
val result = call(
|
||||
"getSignaturesForAddress",
|
||||
JSONArray().put(address).put(JSONObject().put("limit", limit)),
|
||||
)
|
||||
fun history(
|
||||
address: String,
|
||||
limit: Int = 25,
|
||||
): List<Entry> {
|
||||
val result =
|
||||
call(
|
||||
"getSignaturesForAddress",
|
||||
JSONArray().put(address).put(JSONObject().put("limit", limit)),
|
||||
)
|
||||
val rows = result as? JSONArray ?: throw Failure.Malformed("history")
|
||||
return (0 until rows.length()).mapNotNull { Entry.from(rows.optJSONObject(it)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* One line of an address's history.
|
||||
*
|
||||
* 🔴 **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.
|
||||
*/
|
||||
data class Entry(
|
||||
val signature: String,
|
||||
val slot: Long,
|
||||
/**
|
||||
* Absent while a transaction is very new — the node has it but has not
|
||||
* dated it yet. Null means "just now", never 1970.
|
||||
* Absent while a transaction is very new, the node has it but has not
|
||||
* dated it yet.
|
||||
*/
|
||||
val blockTimeSeconds: Long?,
|
||||
val failed: Boolean,
|
||||
) {
|
||||
companion object {
|
||||
fun from(json: JSONObject?): Entry? {
|
||||
val signature = json?.optString("signature")?.takeIf { it.isNotEmpty() }
|
||||
?: return null
|
||||
val signature =
|
||||
json?.optString("signature")?.takeIf { it.isNotEmpty() }
|
||||
?: return null
|
||||
return Entry(
|
||||
signature = signature,
|
||||
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
|
||||
// failure, and `null` when there was none. Testing for
|
||||
// "not null" rather than for a shape is what makes both
|
||||
// kinds of failure visible.
|
||||
blockTimeSeconds =
|
||||
if (json.isNull("blockTime")) {
|
||||
null
|
||||
} else {
|
||||
json.optLong("blockTime")
|
||||
},
|
||||
// `err` is an object or an array depending on the failure,
|
||||
// and `null` when there was none.
|
||||
failed = !json.isNull("err"),
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,10 @@
|
||||
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).
|
||||
*
|
||||
* 🔴 **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.
|
||||
*
|
||||
* ⚠️ **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
|
||||
* it would be a growing record of who you talked to and when, which is the
|
||||
* metadata this project spends its effort not keeping.
|
||||
* What the client already knows about delivery, kept where a person can read it
|
||||
* (ios#21, ios#23, the Android half).
|
||||
*/
|
||||
object DeliveryDiagnostics {
|
||||
|
||||
data class Peer(
|
||||
var envelopesSeen: Int = 0,
|
||||
var delivered: Int = 0,
|
||||
@@ -33,9 +18,7 @@ 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
|
||||
* something is not settling, and that is worth saying out loud.
|
||||
* A client that repeatedly repairs is a symptom, not a cure.
|
||||
*/
|
||||
val repairsLookAbnormal: Boolean get() = repairs >= 3
|
||||
}
|
||||
@@ -54,27 +37,39 @@ object DeliveryDiagnostics {
|
||||
peers.getOrPut(from) { Peer() }.delivered++
|
||||
}
|
||||
|
||||
@Synchronized fun dropped(reason: String, from: Long) {
|
||||
@Synchronized fun dropped(
|
||||
reason: String,
|
||||
from: Long,
|
||||
) {
|
||||
val p = peers.getOrPut(from) { Peer() }
|
||||
val slug = normalisedReason(reason)
|
||||
p.drops[slug] = (p.drops[slug] ?: 0) + 1
|
||||
}
|
||||
|
||||
@Synchronized fun repaired(userId: Long, at: Long = System.currentTimeMillis()) {
|
||||
@Synchronized fun repaired(
|
||||
userId: Long,
|
||||
at: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
val p = peers.getOrPut(userId) { Peer() }
|
||||
p.repairs++
|
||||
p.lastRepairAt = at
|
||||
}
|
||||
|
||||
@Synchronized fun sendFailed(reason: String, to: Long,
|
||||
at: Long = System.currentTimeMillis()) {
|
||||
@Synchronized fun sendFailed(
|
||||
reason: String,
|
||||
to: Long,
|
||||
at: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
val p = peers.getOrPut(to) { Peer() }
|
||||
p.lastSendFailure = reason
|
||||
p.lastSendFailureAt = at
|
||||
}
|
||||
|
||||
@Synchronized fun clearSendFailure(to: Long) {
|
||||
peers[to]?.let { it.lastSendFailure = null; it.lastSendFailureAt = 0L }
|
||||
peers[to]?.let {
|
||||
it.lastSendFailure = null
|
||||
it.lastSendFailureAt = 0L
|
||||
}
|
||||
}
|
||||
|
||||
/** Signing out ends the session this describes. */
|
||||
@@ -82,16 +77,6 @@ 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".
|
||||
* 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
|
||||
* 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`.
|
||||
*/
|
||||
fun normalisedReason(reason: String): String =
|
||||
reason
|
||||
|
||||
@@ -9,46 +9,11 @@ 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
|
||||
* 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
|
||||
* is running behind keeps a message the sender was told would be gone, and
|
||||
* nobody involved can tell.
|
||||
*
|
||||
* That asymmetry decides the whole design. Being wrong in one direction costs
|
||||
* 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
|
||||
* 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
|
||||
* 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.**
|
||||
* 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
|
||||
* `@Volatile` and the pair is updated under a lock. A torn read of "skew from
|
||||
* now, timestamp from an hour ago" would age out an observation that had just
|
||||
* been made, or trust one that had not.
|
||||
*/
|
||||
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
|
||||
* clock may have been corrected, or the phone rebooted with a fresh answer
|
||||
* from the network.
|
||||
* milliseconds.
|
||||
*/
|
||||
@Volatile
|
||||
private var skewMillis: Long? = null
|
||||
@@ -61,10 +26,6 @@ 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.
|
||||
*/
|
||||
const val SUSPECT_THRESHOLD_MS = 300_000L
|
||||
|
||||
@@ -73,11 +34,11 @@ object DeviceClock {
|
||||
|
||||
/**
|
||||
* Learn the skew from a response's `Date` header.
|
||||
*
|
||||
* ⚠️ 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()) {
|
||||
fun observe(
|
||||
serverMillis: Long,
|
||||
deviceNow: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
synchronized(lock) {
|
||||
skewMillis = serverMillis - deviceNow
|
||||
observedAt = deviceNow
|
||||
@@ -85,7 +46,10 @@ object DeviceClock {
|
||||
}
|
||||
|
||||
/** Parse and record a `Date` header, if there is a usable one. */
|
||||
fun observe(header: String?, deviceNow: Long = System.currentTimeMillis()): Boolean {
|
||||
fun observe(
|
||||
header: String?,
|
||||
deviceNow: Long = System.currentTimeMillis(),
|
||||
): Boolean {
|
||||
val parsed = header?.let { parseHttpDate(it) } ?: return false
|
||||
observe(parsed, deviceNow)
|
||||
return true
|
||||
@@ -100,12 +64,13 @@ object DeviceClock {
|
||||
}
|
||||
|
||||
/** The skew, if it is recent enough to mean anything. */
|
||||
fun usableSkew(now: Long = System.currentTimeMillis()): Long? = synchronized(lock) {
|
||||
val skew = skewMillis ?: return null
|
||||
val at = observedAt ?: return null
|
||||
if (now - at >= OBSERVATION_LIFETIME_MS) return null
|
||||
skew
|
||||
}
|
||||
fun usableSkew(now: Long = System.currentTimeMillis()): Long? =
|
||||
synchronized(lock) {
|
||||
val skew = skewMillis ?: return null
|
||||
val at = observedAt ?: return null
|
||||
if (now - at >= OBSERVATION_LIFETIME_MS) return null
|
||||
skew
|
||||
}
|
||||
|
||||
/** Whether the clock is far enough out to be worth telling somebody about. */
|
||||
fun isSuspect(now: Long = System.currentTimeMillis()): Boolean {
|
||||
@@ -115,10 +80,6 @@ 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
|
||||
* vanishing does not think "my clock is wrong"; they think the app is
|
||||
* broken.
|
||||
*/
|
||||
fun warning(now: Long = System.currentTimeMillis()): String? {
|
||||
val skew = usableSkew(now) ?: return null
|
||||
@@ -133,14 +94,6 @@ 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
|
||||
* 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
|
||||
* lose anyway.
|
||||
*/
|
||||
fun nowForExpiry(deviceNow: Long = System.currentTimeMillis()): Long {
|
||||
val skew = usableSkew(deviceNow) ?: return deviceNow
|
||||
@@ -149,21 +102,11 @@ object DeviceClock {
|
||||
|
||||
/**
|
||||
* The same in seconds, which is how the vault stores expiries.
|
||||
*
|
||||
* ⚠️ 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.
|
||||
*/
|
||||
fun expirySeconds(deviceNow: Long = System.currentTimeMillis()): Double =
|
||||
nowForExpiry(deviceNow) / 1000.0
|
||||
fun expirySeconds(deviceNow: Long = System.currentTimeMillis()): Double = nowForExpiry(deviceNow) / 1000.0
|
||||
|
||||
/**
|
||||
* 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`
|
||||
* 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.
|
||||
*/
|
||||
private fun parseHttpDate(header: String): Long? {
|
||||
val formatter = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US)
|
||||
|
||||
@@ -1,33 +1,22 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import io.flamenet.e2e.Content
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* about to share that URL, from this address, before the message is sent. That
|
||||
* is what the setting is for.
|
||||
*/
|
||||
object LinkPreviewFetcher {
|
||||
|
||||
/** A short timeout: a preview is a decoration and must never delay a send. */
|
||||
private val http = OkHttpClient.Builder()
|
||||
.connectTimeout(4, TimeUnit.SECONDS)
|
||||
.readTimeout(4, TimeUnit.SECONDS)
|
||||
.callTimeout(6, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private val http =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.connectTimeout(4, TimeUnit.SECONDS)
|
||||
.readTimeout(4, TimeUnit.SECONDS)
|
||||
.callTimeout(6, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/** 64 KiB is plenty for a `<head>`, and a cap is what bounds a hostile page. */
|
||||
private const val MAX_BYTES = 64 * 1024
|
||||
@@ -38,59 +27,79 @@ 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
|
||||
* it, which is a perfectly good thing to be.
|
||||
* Fetch and parse Open Graph tags.
|
||||
*/
|
||||
fun fetch(url: String): Content.LinkPreview? = runCatching {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("Accept", "text/html")
|
||||
// Identifies as a link preview rather than impersonating a browser:
|
||||
// a site that wants to refuse these should be able to.
|
||||
.header("User-Agent", "FlamenetLinkPreview/1.0")
|
||||
.build()
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
val head = response.body?.source()?.let { source ->
|
||||
source.request(MAX_BYTES.toLong())
|
||||
source.buffer.snapshot(minOf(MAX_BYTES.toLong(), source.buffer.size).toInt())
|
||||
.utf8()
|
||||
} ?: return null
|
||||
fun fetch(url: String): Content.LinkPreview? =
|
||||
runCatching {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.header("Accept", "text/html")
|
||||
// Identifies as a link preview rather than impersonating a
|
||||
// browser. a site that wants to refuse these should be able
|
||||
// to.
|
||||
.header("User-Agent", "FlamenetLinkPreview/1.0")
|
||||
.build()
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
val head =
|
||||
response.body?.source()?.let { source ->
|
||||
source.request(MAX_BYTES.toLong())
|
||||
source.buffer
|
||||
.snapshot(minOf(MAX_BYTES.toLong(), source.buffer.size).toInt())
|
||||
.utf8()
|
||||
} ?: return null
|
||||
|
||||
val title = meta(head, "og:title") ?: titleTag(head) ?: return null
|
||||
val desc = meta(head, "og:description") ?: metaName(head, "description") ?: ""
|
||||
Content.LinkPreview.of(url, title, desc)
|
||||
}
|
||||
}.getOrNull()
|
||||
val title = meta(head, "og:title") ?: titleTag(head) ?: return null
|
||||
val desc = meta(head, "og:description") ?: metaName(head, "description") ?: ""
|
||||
Content.LinkPreview.of(url, title, desc)
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
// ⚠️ 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.
|
||||
// Regex over a bounded prefix, deliberately.
|
||||
|
||||
private fun meta(html: String, property: String): String? =
|
||||
Regex("""<meta[^>]+property=["']${Regex.escape(property)}["'][^>]+content=["']([^"']*)["']""",
|
||||
RegexOption.IGNORE_CASE).find(html)?.groupValues?.get(1)?.let(::clean)
|
||||
?: Regex("""<meta[^>]+content=["']([^"']*)["'][^>]+property=["']${Regex.escape(property)}["']""",
|
||||
RegexOption.IGNORE_CASE).find(html)?.groupValues?.get(1)?.let(::clean)
|
||||
private fun meta(
|
||||
html: String,
|
||||
property: String,
|
||||
): String? =
|
||||
Regex(
|
||||
"""<meta[^>]+property=["']${Regex.escape(property)}["'][^>]+content=["']([^"']*)["']""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
).find(html)?.groupValues?.get(1)?.let(::clean)
|
||||
?: Regex(
|
||||
"""<meta[^>]+content=["']([^"']*)["'][^>]+property=["']${Regex.escape(property)}["']""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
).find(html)?.groupValues?.get(1)?.let(::clean)
|
||||
|
||||
private fun metaName(html: String, name: String): String? =
|
||||
Regex("""<meta[^>]+name=["']${Regex.escape(name)}["'][^>]+content=["']([^"']*)["']""",
|
||||
RegexOption.IGNORE_CASE).find(html)?.groupValues?.get(1)?.let(::clean)
|
||||
private fun metaName(
|
||||
html: String,
|
||||
name: String,
|
||||
): String? =
|
||||
Regex(
|
||||
"""<meta[^>]+name=["']${Regex.escape(name)}["'][^>]+content=["']([^"']*)["']""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
).find(html)?.groupValues?.get(1)?.let(::clean)
|
||||
|
||||
private fun titleTag(html: String): String? =
|
||||
Regex("""<title[^>]*>([^<]*)</title>""", RegexOption.IGNORE_CASE)
|
||||
.find(html)?.groupValues?.get(1)?.let(::clean)
|
||||
.find(html)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.let(::clean)
|
||||
|
||||
/**
|
||||
* The handful of entities that actually appear in titles. Anything else is
|
||||
* left alone — a literal `©` is a cosmetic flaw, and expanding
|
||||
* arbitrary entities is how a parser grows teeth.
|
||||
* The handful of entities that actually appear in titles.
|
||||
*/
|
||||
private fun clean(s: String): String = s
|
||||
.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
.replace(""", "\"").replace("'", "'").replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() } ?: ""
|
||||
private fun clean(s: String): String =
|
||||
s
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() } ?: ""
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import io.flamenet.e2e.FilesKdf
|
||||
import io.flamenet.e2e.Hkdf
|
||||
import io.flamenet.e2e.Label
|
||||
import io.flamenet.e2e.Random
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
@@ -16,42 +18,9 @@ import java.util.Base64
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Files that live on this phone.
|
||||
*
|
||||
* 🔴 **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.
|
||||
*
|
||||
* 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
|
||||
* rows lived in Postgres, `session_dominates(classification, compartments)` was
|
||||
* 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
|
||||
* level, and the clearance shown in the interface now means "the keys this
|
||||
* 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
|
||||
* 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`
|
||||
* rather than a decision, and pinning Kotlin to it would make an accident into a
|
||||
* 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
|
||||
* than a coroutine `Mutex` because this module deliberately has no coroutines
|
||||
* dependency, which is what lets its tests run without an emulator.
|
||||
*/
|
||||
class LocalVault(
|
||||
private val root: File,
|
||||
@@ -59,60 +28,39 @@ class LocalVault(
|
||||
private val identityRawPrivate: ByteArray,
|
||||
/**
|
||||
* 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
|
||||
* vault that looks like it has lost half its contents, with nothing
|
||||
* anywhere saying why.
|
||||
*/
|
||||
private val references: ReferenceResolver,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Where a row's bytes actually are.
|
||||
*
|
||||
* 🔴 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
|
||||
* adding any, the label is advisory until it is sent, and the row breaks
|
||||
* if the file is moved or deleted.
|
||||
*/
|
||||
enum class Storage(val wire: String) {
|
||||
enum class Storage(
|
||||
val wire: String,
|
||||
) {
|
||||
VAULT("vault"),
|
||||
REFERENCE("reference");
|
||||
REFERENCE("reference"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* ⚠️ 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
|
||||
* 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.
|
||||
* 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 before referencing existed failed to decode, which
|
||||
* presents as a vault that has lost everything.
|
||||
*/
|
||||
fun parse(raw: String?): Storage =
|
||||
entries.firstOrNull { it.wire == raw } ?: VAULT
|
||||
fun parse(raw: String?): Storage = entries.firstOrNull { it.wire == raw } ?: VAULT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How a referenced file is reached again after a relaunch.
|
||||
*
|
||||
* The [bookmark] is opaque here on purpose. On Android it is a Storage
|
||||
* Access Framework document URI whose read permission was persisted; on iOS
|
||||
* it is a security-scoped bookmark. Neither belongs in this module, which
|
||||
* has no platform in it.
|
||||
*/
|
||||
interface ReferenceResolver {
|
||||
/**
|
||||
* ⚠️ 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.
|
||||
* 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.
|
||||
*/
|
||||
fun exists(bookmark: String): Boolean
|
||||
|
||||
@@ -147,20 +95,7 @@ class LocalVault(
|
||||
val label: Label get() = Label(classification, compartments.toSet())
|
||||
|
||||
/**
|
||||
* 🔴 **Every field, with the byte arrays compared by CONTENT.**
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* ⚠️ 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.
|
||||
* Every field, with the byte arrays compared by CONTENT.
|
||||
*/
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is Node &&
|
||||
@@ -172,8 +107,10 @@ class LocalVault(
|
||||
other.wrappedNodeKey.contentEquals(wrappedNodeKey) &&
|
||||
other.nameDigest.contentEquals(nameDigest) &&
|
||||
other.wrappedName.contentEquals(wrappedName) &&
|
||||
(other.wrappedContentKey?.contentEquals(wrappedContentKey ?: ByteArray(0))
|
||||
?: (wrappedContentKey == null)) &&
|
||||
(
|
||||
other.wrappedContentKey?.contentEquals(wrappedContentKey ?: ByteArray(0))
|
||||
?: (wrappedContentKey == null)
|
||||
) &&
|
||||
other.byteCount == byteCount &&
|
||||
other.addedAtMillis == addedAtMillis &&
|
||||
other.storage == storage &&
|
||||
@@ -202,8 +139,7 @@ class LocalVault(
|
||||
val node: Node,
|
||||
val name: String?,
|
||||
/**
|
||||
* ⚠️ True when this is a reference whose file is no longer where it was.
|
||||
* Surfaced rather than hidden.
|
||||
* True when this is a reference whose file is no longer where it was.
|
||||
*/
|
||||
val isMissing: Boolean,
|
||||
) {
|
||||
@@ -223,70 +159,57 @@ class LocalVault(
|
||||
val isCompletelyDone: Boolean get() = keptNonEmpty.isEmpty()
|
||||
}
|
||||
|
||||
class VaultException(message: String) : Exception(message)
|
||||
class VaultException(
|
||||
message: String,
|
||||
) : Exception(message)
|
||||
|
||||
private val lock = ReentrantLock()
|
||||
private var nodes = mutableListOf<Node>()
|
||||
|
||||
private val indexFile: File get() = File(root, "index.json")
|
||||
private val blobs: File get() = File(root, "blobs")
|
||||
|
||||
private fun blobFile(id: UUID) = File(blobs, id.toString())
|
||||
|
||||
// ---- Keys ------------------------------------------------------------
|
||||
// ---- Keys ------------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* The key for a label, derived rather than fetched.
|
||||
*
|
||||
* 🔴 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
|
||||
* broker, minus the broker.
|
||||
*
|
||||
* ⚠️ **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.
|
||||
*/
|
||||
internal fun labelKey(label: Label): ByteArray = Hkdf.derive(
|
||||
ikm = identityRawPrivate,
|
||||
salt = label.bytes(),
|
||||
info = FilesKdf.Context.LOCAL_LABEL.toByteArray(Charsets.UTF_8),
|
||||
length = 32,
|
||||
)
|
||||
internal fun labelKey(label: Label): ByteArray =
|
||||
Hkdf.derive(
|
||||
ikm = identityRawPrivate,
|
||||
salt = label.bytes(),
|
||||
info = FilesKdf.Context.LOCAL_LABEL.toByteArray(Charsets.UTF_8),
|
||||
length = 32,
|
||||
)
|
||||
|
||||
/**
|
||||
* The name, or null when this device cannot read the row.
|
||||
*
|
||||
* ⚠️ 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
|
||||
* says so.
|
||||
*/
|
||||
private fun nameOf(node: Node): String? = try {
|
||||
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label)
|
||||
FileNames.decrypt(nodeKey, node.wrappedName, node.nameDigest, node.label)
|
||||
} catch (e: Aead.DecryptionFailed) {
|
||||
null
|
||||
} catch (e: FilesEngineException) {
|
||||
null
|
||||
}
|
||||
private fun nameOf(node: Node): String? =
|
||||
try {
|
||||
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label)
|
||||
FileNames.decrypt(nodeKey, node.wrappedName, node.nameDigest, node.label)
|
||||
} catch (e: Aead.DecryptionFailed) {
|
||||
null
|
||||
} catch (e: FilesEngineException) {
|
||||
null
|
||||
}
|
||||
|
||||
// ---- Storage ---------------------------------------------------------
|
||||
// ---- Storage ---------------------------------------------------------.
|
||||
|
||||
fun load() = lock.withLock {
|
||||
blobs.mkdirs()
|
||||
val text = if (indexFile.exists()) indexFile.readText() else return@withLock
|
||||
val root = JSONObject(text)
|
||||
val array = root.optJSONArray("nodes") ?: JSONArray()
|
||||
nodes = (0 until array.length()).mapTo(mutableListOf()) { decode(array.getJSONObject(it)) }
|
||||
}
|
||||
fun load() =
|
||||
lock.withLock {
|
||||
blobs.mkdirs()
|
||||
val text = if (indexFile.exists()) indexFile.readText() else return@withLock
|
||||
val root = JSONObject(text)
|
||||
val array = root.optJSONArray("nodes") ?: JSONArray()
|
||||
nodes = (0 until array.length()).mapTo(mutableListOf()) { decode(array.getJSONObject(it)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 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.
|
||||
* Written to a temporary file and moved into place.
|
||||
*/
|
||||
private fun save() {
|
||||
root.mkdirs()
|
||||
@@ -295,59 +218,44 @@ class LocalVault(
|
||||
JSONObject()
|
||||
.put("version", 1)
|
||||
.put("nodes", JSONArray().also { array -> nodes.forEach { array.put(encode(it)) } })
|
||||
.toString()
|
||||
.toString(),
|
||||
)
|
||||
Files.move(
|
||||
temporary.toPath(), indexFile.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE,
|
||||
temporary.toPath(),
|
||||
indexFile.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun b64(bytes: ByteArray) = Base64.getEncoder().encodeToString(bytes)
|
||||
|
||||
private fun unb64(text: String) = Base64.getDecoder().decode(text)
|
||||
|
||||
private fun encode(node: Node): JSONObject = JSONObject()
|
||||
.put("id", node.id.toString())
|
||||
.put("parentID", node.parentId?.toString() ?: JSONObject.NULL)
|
||||
.put("isFolder", node.isFolder)
|
||||
.put("classification", node.classification.toInt())
|
||||
.put("compartments", JSONArray(node.compartments))
|
||||
.put("wrappedNodeKey", b64(node.wrappedNodeKey))
|
||||
.put("nameDigest", b64(node.nameDigest))
|
||||
.put("wrappedName", b64(node.wrappedName))
|
||||
.put("wrappedContentKey", node.wrappedContentKey?.let { b64(it) } ?: JSONObject.NULL)
|
||||
.put("byteCount", node.byteCount)
|
||||
.put("addedAt", node.addedAtMillis)
|
||||
.put("storage", node.storage.wire)
|
||||
.put("bookmark", node.bookmark ?: JSONObject.NULL)
|
||||
private fun encode(node: Node): JSONObject =
|
||||
JSONObject()
|
||||
.put("id", node.id.toString())
|
||||
.put("parentID", node.parentId?.toString() ?: JSONObject.NULL)
|
||||
.put("isFolder", node.isFolder)
|
||||
.put("classification", node.classification.toInt())
|
||||
.put("compartments", JSONArray(node.compartments))
|
||||
.put("wrappedNodeKey", b64(node.wrappedNodeKey))
|
||||
.put("nameDigest", b64(node.nameDigest))
|
||||
.put("wrappedName", b64(node.wrappedName))
|
||||
.put("wrappedContentKey", node.wrappedContentKey?.let { b64(it) } ?: JSONObject.NULL)
|
||||
.put("byteCount", node.byteCount)
|
||||
.put("addedAt", node.addedAtMillis)
|
||||
.put("storage", node.storage.wire)
|
||||
.put("bookmark", node.bookmark ?: JSONObject.NULL)
|
||||
|
||||
/**
|
||||
* 🔴 **`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
|
||||
* 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.
|
||||
* - `wrappedContentKey` was worse, because it did NOT throw: `"null"` is
|
||||
* valid base64, so the node came back with a plausible three-byte content
|
||||
* key and simply refused to decrypt.
|
||||
* - `bookmark` produced a reference pointing at `"null"`, permanently
|
||||
* missing.
|
||||
*
|
||||
* ⚠️ 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.
|
||||
*
|
||||
* Found by running the app on a device (android!52). No JVM test could have
|
||||
* caught it, which is the argument for the device run, not against the
|
||||
* tests.
|
||||
* `optString` is not safe on a nullable field, and the JVM will not tell
|
||||
* you.
|
||||
*/
|
||||
private fun optional(o: JSONObject, key: String): String? {
|
||||
private fun optional(
|
||||
o: JSONObject,
|
||||
key: String,
|
||||
): String? {
|
||||
if (o.isNull(key)) return null
|
||||
val value = o.optString(key, "")
|
||||
return value.takeIf { it.isNotEmpty() && it != "null" }
|
||||
@@ -372,19 +280,21 @@ class LocalVault(
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Reading ---------------------------------------------------------
|
||||
// ---- Reading ---------------------------------------------------------.
|
||||
|
||||
fun list(parentId: UUID?): List<Entry> = lock.withLock {
|
||||
nodes.filter { it.parentId == parentId }
|
||||
.map { Entry(it, nameOf(it), isMissing = isMissing(it)) }
|
||||
.sortedWith(
|
||||
// Folders first, then by name; unreadable rows last, where they
|
||||
// are least in the way of the ones you can act on.
|
||||
compareByDescending<Entry> { it.isFolder }
|
||||
.thenBy { it.name == null }
|
||||
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" }
|
||||
)
|
||||
}
|
||||
fun list(parentId: UUID?): List<Entry> =
|
||||
lock.withLock {
|
||||
nodes
|
||||
.filter { it.parentId == parentId }
|
||||
.map { Entry(it, nameOf(it), isMissing = isMissing(it)) }
|
||||
.sortedWith(
|
||||
// Folders first, then by name; unreadable rows last, where
|
||||
// they are least in the way of the ones you can act on.
|
||||
compareByDescending<Entry> { it.isFolder }
|
||||
.thenBy { it.name == null }
|
||||
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" },
|
||||
)
|
||||
}
|
||||
|
||||
private fun isMissing(node: Node): Boolean =
|
||||
node.storage == Storage.REFERENCE &&
|
||||
@@ -396,51 +306,35 @@ 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
|
||||
* 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
|
||||
* 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
|
||||
* both terminates AND returns the right answer, and needs no magic number.
|
||||
*/
|
||||
fun descendants(of: UUID): List<UUID> = lock.withLock {
|
||||
val found = mutableListOf<UUID>()
|
||||
val seen = mutableSetOf(of)
|
||||
val frontier = ArrayDeque(listOf(of))
|
||||
while (frontier.isNotEmpty()) {
|
||||
val next = frontier.removeLast()
|
||||
for (child in nodes.filter { it.parentId == next }) {
|
||||
if (!seen.add(child.id)) continue
|
||||
found += child.id
|
||||
frontier.addLast(child.id)
|
||||
fun descendants(of: UUID): List<UUID> =
|
||||
lock.withLock {
|
||||
val found = mutableListOf<UUID>()
|
||||
val seen = mutableSetOf(of)
|
||||
val frontier = ArrayDeque(listOf(of))
|
||||
while (frontier.isNotEmpty()) {
|
||||
val next = frontier.removeLast()
|
||||
for (child in nodes.filter { it.parentId == next }) {
|
||||
if (!seen.add(child.id)) continue
|
||||
found += child.id
|
||||
frontier.addLast(child.id)
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)`,
|
||||
* 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
|
||||
* reads as "it isn't there". Filed as ios#68.
|
||||
*
|
||||
* ⚠️ 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
|
||||
* 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.
|
||||
*/
|
||||
fun allFiles(): List<Entry> = lock.withLock {
|
||||
nodes.filter { !it.isFolder }
|
||||
.map { Entry(it, nameOf(it), isMissing = isMissing(it)) }
|
||||
.filter { it.name != null && !it.isMissing }
|
||||
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" })
|
||||
}
|
||||
fun allFiles(): List<Entry> =
|
||||
lock.withLock {
|
||||
nodes
|
||||
.filter { !it.isFolder }
|
||||
.map { Entry(it, nameOf(it), isMissing = isMissing(it)) }
|
||||
.filter { it.name != null && !it.isMissing }
|
||||
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" })
|
||||
}
|
||||
|
||||
fun contents(of: Node): ByteArray = lock.withLock { readContents(of) }
|
||||
|
||||
@@ -450,77 +344,88 @@ class LocalVault(
|
||||
if (node.storage == Storage.REFERENCE) {
|
||||
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.")
|
||||
throw VaultException("That file isn't where it was, it may have been moved or deleted.")
|
||||
}
|
||||
return references.read(bookmark)
|
||||
}
|
||||
|
||||
val wrappedContentKey = node.wrappedContentKey
|
||||
?: throw VaultException("That file has no contents.")
|
||||
val wrappedContentKey =
|
||||
node.wrappedContentKey
|
||||
?: throw VaultException("That file has no contents.")
|
||||
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label)
|
||||
val contentKey = FileKeys.unwrapContentKey(wrappedContentKey, nodeKey, node.label)
|
||||
return FileContent.decrypt(blobFile(node.id).readBytes(), contentKey, node.label)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes, encrypted for sending — whichever way they are stored.
|
||||
*
|
||||
* 🔴 **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.
|
||||
* The bytes, encrypted for sending, whichever way they are stored.
|
||||
*/
|
||||
data class Sealed(val blob: ByteArray, val contentKey: ByteArray)
|
||||
data class Sealed(
|
||||
val blob: ByteArray,
|
||||
val contentKey: ByteArray,
|
||||
)
|
||||
|
||||
fun sealedForSending(node: Node): Sealed = lock.withLock {
|
||||
val plaintext = readContents(node)
|
||||
val contentKey = Random.bytes(32)
|
||||
Sealed(FileContent.encrypt(plaintext, contentKey, node.label), contentKey)
|
||||
}
|
||||
fun sealedForSending(node: Node): Sealed =
|
||||
lock.withLock {
|
||||
val plaintext = readContents(node)
|
||||
val contentKey = Random.bytes(32)
|
||||
Sealed(FileContent.encrypt(plaintext, contentKey, node.label), contentKey)
|
||||
}
|
||||
|
||||
// ---- Writing ---------------------------------------------------------
|
||||
// ---- Writing ---------------------------------------------------------.
|
||||
|
||||
fun add(bytes: ByteArray, name: String, label: Label, parentId: UUID? = null): Node =
|
||||
fun add(
|
||||
bytes: ByteArray,
|
||||
name: String,
|
||||
label: Label,
|
||||
parentId: UUID? = null,
|
||||
): Node =
|
||||
lock.withLock {
|
||||
val node = makeNode(name, label, parentId, isFolder = false, byteCount = bytes.size)
|
||||
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
|
||||
// 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.
|
||||
// Bytes to disk BEFORE the index.
|
||||
blobs.mkdirs()
|
||||
writeAtomically(blobFile(node.id), FileContent.encrypt(bytes, contentKey, label))
|
||||
|
||||
val stored = node.copy(
|
||||
wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, label))
|
||||
val stored =
|
||||
node.copy(wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, label))
|
||||
nodes += stored
|
||||
save()
|
||||
stored
|
||||
}
|
||||
|
||||
fun createFolder(name: String, label: Label, parentId: UUID? = null): Node = lock.withLock {
|
||||
val node = makeNode(name, label, parentId, isFolder = true, byteCount = 0)
|
||||
nodes += node
|
||||
save()
|
||||
node
|
||||
}
|
||||
fun createFolder(
|
||||
name: String,
|
||||
label: Label,
|
||||
parentId: UUID? = null,
|
||||
): Node =
|
||||
lock.withLock {
|
||||
val node = makeNode(name, label, parentId, isFolder = true, byteCount = 0)
|
||||
nodes += node
|
||||
save()
|
||||
node
|
||||
}
|
||||
|
||||
/**
|
||||
* File a document that stays where it is.
|
||||
*
|
||||
* 🔴 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.
|
||||
*/
|
||||
fun reference(bookmark: String, name: String, label: Label, parentId: UUID? = null): Node =
|
||||
fun reference(
|
||||
bookmark: String,
|
||||
name: String,
|
||||
label: Label,
|
||||
parentId: UUID? = null,
|
||||
): Node =
|
||||
lock.withLock {
|
||||
val node = makeNode(
|
||||
name, label, parentId, isFolder = false,
|
||||
byteCount = references.size(bookmark),
|
||||
).copy(storage = Storage.REFERENCE, bookmark = bookmark)
|
||||
val node =
|
||||
makeNode(
|
||||
name,
|
||||
label,
|
||||
parentId,
|
||||
isFolder = false,
|
||||
byteCount = references.size(bookmark),
|
||||
).copy(storage = Storage.REFERENCE, bookmark = bookmark)
|
||||
nodes += node
|
||||
save()
|
||||
node
|
||||
@@ -528,53 +433,38 @@ class LocalVault(
|
||||
|
||||
/**
|
||||
* Turn a reference into an encrypted copy, in place.
|
||||
*
|
||||
* 🔴 **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.
|
||||
*
|
||||
* ⚠️ 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
|
||||
* the encrypted one.
|
||||
*
|
||||
* Returns the bookmark the original was read from, so a caller can offer
|
||||
* that, or null when the row was already vaulted.
|
||||
*/
|
||||
fun encrypt(id: UUID): String? = lock.withLock {
|
||||
val position = nodes.indexOfFirst { it.id == id }
|
||||
if (position < 0) throw VaultException("That file isn't here any more.")
|
||||
val node = nodes[position]
|
||||
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.")
|
||||
fun encrypt(id: UUID): String? =
|
||||
lock.withLock {
|
||||
val position = nodes.indexOfFirst { it.id == id }
|
||||
if (position < 0) throw VaultException("That file isn't here any more.")
|
||||
val node = nodes[position]
|
||||
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.")
|
||||
}
|
||||
|
||||
val plaintext = readContents(node)
|
||||
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label)
|
||||
val contentKey = Random.bytes(32)
|
||||
|
||||
// Bytes before index, as everywhere else here.
|
||||
blobs.mkdirs()
|
||||
writeAtomically(blobFile(node.id), FileContent.encrypt(plaintext, contentKey, node.label))
|
||||
|
||||
nodes[position] =
|
||||
node.copy(
|
||||
storage = Storage.VAULT,
|
||||
// The bookmark goes.
|
||||
bookmark = null,
|
||||
wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, node.label),
|
||||
byteCount = plaintext.size,
|
||||
)
|
||||
save()
|
||||
origin
|
||||
}
|
||||
|
||||
val plaintext = readContents(node)
|
||||
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label)
|
||||
val contentKey = Random.bytes(32)
|
||||
|
||||
// Bytes before index, as everywhere else here.
|
||||
blobs.mkdirs()
|
||||
writeAtomically(blobFile(node.id), FileContent.encrypt(plaintext, contentKey, node.label))
|
||||
|
||||
nodes[position] = node.copy(
|
||||
storage = Storage.VAULT,
|
||||
// 🔴 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
|
||||
// vaulting it.
|
||||
bookmark = null,
|
||||
wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, node.label),
|
||||
byteCount = plaintext.size,
|
||||
)
|
||||
save()
|
||||
origin
|
||||
}
|
||||
|
||||
private fun makeNode(
|
||||
name: String,
|
||||
label: Label,
|
||||
@@ -590,10 +480,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
|
||||
// tree allows a Restricted file inside a Confidential folder, so a
|
||||
// folder-derived key would hand every folder-key holder content
|
||||
// above their clearance.
|
||||
// Wrapped to the node's OWN label key, never the parent's.
|
||||
wrappedNodeKey = FileKeys.wrapNodeKey(nodeKey, labelKey(label), label),
|
||||
nameDigest = sealed.digest,
|
||||
wrappedName = sealed.ciphertext,
|
||||
@@ -603,96 +490,84 @@ class LocalVault(
|
||||
)
|
||||
}
|
||||
|
||||
private fun writeAtomically(target: File, bytes: ByteArray) {
|
||||
private fun writeAtomically(
|
||||
target: File,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
val temporary = File(target.parentFile, target.name + ".new")
|
||||
temporary.writeBytes(bytes)
|
||||
Files.move(
|
||||
temporary.toPath(), target.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE,
|
||||
temporary.toPath(),
|
||||
target.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Deleting --------------------------------------------------------
|
||||
// ---- Deleting --------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* Removes a node and its bytes.
|
||||
*
|
||||
* ⚠️ 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.
|
||||
*/
|
||||
fun remove(id: UUID) = lock.withLock {
|
||||
val position = nodes.indexOfFirst { it.id == id }
|
||||
if (position < 0) return@withLock
|
||||
val node = nodes[position]
|
||||
if (node.isFolder && nodes.any { it.parentId == id }) {
|
||||
throw VaultException("That folder isn't empty.")
|
||||
fun remove(id: UUID) =
|
||||
lock.withLock {
|
||||
val position = nodes.indexOfFirst { it.id == id }
|
||||
if (position < 0) return@withLock
|
||||
val node = nodes[position]
|
||||
if (node.isFolder && nodes.any { it.parentId == id }) {
|
||||
throw VaultException("That folder isn't empty.")
|
||||
}
|
||||
nodes.removeAt(position)
|
||||
save()
|
||||
// Never the person's own file, see the note in the bulk version.
|
||||
if (node.storage == Storage.VAULT) blobFile(id).delete()
|
||||
}
|
||||
nodes.removeAt(position)
|
||||
save()
|
||||
// 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
|
||||
* 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
|
||||
* 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,
|
||||
* which they could not have reviewed before tapping.
|
||||
*/
|
||||
fun remove(ids: List<UUID>): Removal = lock.withLock {
|
||||
val removed = mutableListOf<UUID>()
|
||||
val keptNonEmpty = mutableListOf<UUID>()
|
||||
val removedVaultIds = mutableListOf<UUID>()
|
||||
val wanted = ids.toSet()
|
||||
fun remove(ids: List<UUID>): Removal =
|
||||
lock.withLock {
|
||||
val removed = mutableListOf<UUID>()
|
||||
val keptNonEmpty = mutableListOf<UUID>()
|
||||
val removedVaultIds = mutableListOf<UUID>()
|
||||
val wanted = ids.toSet()
|
||||
|
||||
// Depth first: a child must go before the parent that contains it.
|
||||
fun depth(id: UUID): Int {
|
||||
var steps = 0
|
||||
var current = nodes.firstOrNull { it.id == id }?.parentId
|
||||
while (current != null && steps < 64) {
|
||||
steps++
|
||||
current = nodes.firstOrNull { it.id == current }?.parentId
|
||||
}
|
||||
return steps
|
||||
}
|
||||
val ordered = ids.filter { id -> nodes.any { it.id == id } }
|
||||
.sortedByDescending { depth(it) }
|
||||
|
||||
for (id in ordered) {
|
||||
val position = nodes.indexOfFirst { it.id == id }
|
||||
if (position < 0) continue
|
||||
val node = nodes[position]
|
||||
if (node.isFolder) {
|
||||
// Anything left inside that was not itself selected?
|
||||
if (nodes.any { it.parentId == id && it.id !in wanted }) {
|
||||
keptNonEmpty += id
|
||||
continue
|
||||
// Depth first: a child must go before the parent that contains it.
|
||||
fun depth(id: UUID): Int {
|
||||
var steps = 0
|
||||
var current = nodes.firstOrNull { it.id == id }?.parentId
|
||||
while (current != null && steps < 64) {
|
||||
steps++
|
||||
current = nodes.firstOrNull { it.id == current }?.parentId
|
||||
}
|
||||
return steps
|
||||
}
|
||||
if (node.storage == Storage.VAULT) removedVaultIds += id
|
||||
nodes.removeAt(position)
|
||||
removed += id
|
||||
}
|
||||
val ordered =
|
||||
ids
|
||||
.filter { id -> nodes.any { it.id == id } }
|
||||
.sortedByDescending { depth(it) }
|
||||
|
||||
// 🔴 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
|
||||
// their document, and `blobFile` for a reference names a file that was
|
||||
// never written.
|
||||
removedVaultIds.forEach { blobFile(it).delete() }
|
||||
Removal(removed, keptNonEmpty)
|
||||
}
|
||||
for (id in ordered) {
|
||||
val position = nodes.indexOfFirst { it.id == id }
|
||||
if (position < 0) continue
|
||||
val node = nodes[position]
|
||||
if (node.isFolder) {
|
||||
// Anything left inside that was not itself selected?
|
||||
if (nodes.any { it.parentId == id && it.id !in wanted }) {
|
||||
keptNonEmpty += id
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (node.storage == Storage.VAULT) removedVaultIds += id
|
||||
nodes.removeAt(position)
|
||||
removed += id
|
||||
}
|
||||
|
||||
// One save for the whole batch, after the index is consistent.
|
||||
save()
|
||||
// Only ever our own blobs.
|
||||
removedVaultIds.forEach { blobFile(it).delete() }
|
||||
Removal(removed, keptNonEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user