139 Commits
Author SHA1 Message Date
extant2000git 2e9c2e0802 Merge pull request 'On-device tests for the app over an obfs4 bridge, and what they found' (#192) from test/the-app-over-a-bridge into main
ci / secret-sweep (push) Successful in 13s
ci / vectors-head (push) Successful in 4m48s
ci / unit-tests (push) Successful in 10m35s
ci / image (push) Successful in 11s
2026-10-14 22:27:21 +00:00
Paul HittandClaude Opus 5 4469df0bf3 Stop throwing away a sign-up that finished, and the app sends through a bridge
ci / image (pull_request) Successful in 8s
ci / secret-sweep (pull_request) Successful in 13s
ci / vectors-head (pull_request) Successful in 4m32s
ci / unit-tests (pull_request) Successful in 9m59s
The on-device test now passes. The app, on an emulator, with an obfs4
bridge in force, signs up on relay1, publishes its device, looks up a
contact, adds it and sends a message. Six obfs4 handshakes counted by the
app, five of them before the send, and the relay logged
POST /e2e/messages 200.

What was in the way was not the bridge.

🔴 **signIn discarded work that had already succeeded.** It ran
`withTimeoutOrNull(60s) { withContext(IO) { authenticate(...) } }`.
`authenticate` is blocking socket work, so cancelling the coroutine does
not stop it: the timeout fired, the screen said "The server did not
answer. It may be down, or the address may be wrong", and the
registration carried on and finished behind it. One run left the account
`andb7564513` on relay1 with a published identity and a device while the
person was told the server was unreachable. The relay answered sign-up in
397 ms and the app's next call came 115 seconds later.

Reproduced with no bridge (`-e direct true`), so this was never a bridge
defect. The work now runs in its own `async`, the first 60 seconds still
report, as "Still waiting for the server" rather than as a failure, and a
late success is adopted instead of dropped. After 180 more seconds it
gives up and cancels the work, so a success nobody is waiting for cannot
surprise somebody who moved on.

Spanish for the new string is in values-es.

Two things the test learned to stop believing:

- An outgoing line in the thread is not delivery. The app renders a
  message as soon as it is composed, and a run that asserted only the
  text reported OK while the relay never saw a POST at all. Delivery is
  now the absence of both the `pending` and `undelivered` markers.
- `Obfs4Settings` on main takes `add`, not `save`, and returns
  `AddResult`. The branch this was first written against had the older
  single-line API.

InstrumentedSuitesCanSignUpTest now accepts `typeIfPresent("birth-year")`
as well as the direct call. relay1 does not ask for a birth year, so a
suite driving it has to fill that field only when the relay puts it on
screen. The rule it enforces is unchanged.

Gate: `:app:testDebugUnitTest` is 671 tests with 267 failures on this
machine, identical with and without this change, measured by stashing it
and re-running. 266 of those are
`IllegalArgumentException: Unsupported class file major version` from a
JDK 26 toolchain, which is this laptop rather than the repository. The
one real failure was the guard above, and it passes now: 3 tests, 0
failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 16:24:51 -04:00
Paul HittandClaude Opus 5 adf44db7ca On-device tests for the app over an obfs4 bridge, and what they found
ci / secret-sweep (pull_request) Successful in 12s
ci / vectors-head (pull_request) Successful in 3m24s
ci / image (pull_request) Successful in 7s
ci / unit-tests (pull_request) Failing after 5m57s
The 2026-09-28 answers to OTF said bridge users can reach the relay from
anywhere. flamenet-e2e now carries a real message through the live bridge
from Node, which proves the bridge and the relay. It does not touch this
code: Obfs4SocketFactory under the app's own OkHttp, the sign-up, the
device registration and the send.

Two tests, both opt-in, both needing a live bridge.

BridgeTransportProbeTest opens the tunnel by hand and speaks one request
through it. PASSES on an emulator, and the numbers are worth keeping:
one socket plus TLS plus a GET in 1.3 seconds, and four concurrent
tunnels in 1.7 seconds with four handshakes counted and HTTP 200 on all
four. The certificate inside the tunnel is relay1's own. Concurrency was
the first suspect and it is not the problem.

BridgeMessageTest drives the app: bridge in force first, handshake
counter asserted at zero, then sign-up, add a contact, send. It does NOT
pass yet, and the reason is not the bridge.

🔴 **The app strands the person on the sign-up screen with an account
that exists.** After a transient failure during sign-up the screen keeps
saying "The server did not answer. It may be down, or the address may be
wrong", and it never navigates, while the relay's log shows signup 200,
token 200, devices 200, identity 200, buddies 200 and groups 200. The
account `andb7564513` from one such run answers on
`/e2e/account/identity` with a published identity. The person is told the
server is unreachable; their account and device are registered.

Reproduced with `-e direct true`, which runs the same path with no
bridge, so this is an app defect and not a bridge defect. The control
exists for exactly this reason: without it the flakiness looks like a
flaky tunnel.

Two numbers behind it: the relay answered sign-up in 397 ms, and the
app's next call came 115 seconds later. `RelayClient.defaultHttpClient`
sets `callTimeout` to 25 seconds, so the first attempt times out, the UI
latches the error, and the retry that succeeds changes nothing on
screen.

Four things these tests cost, each a silent failure rather than an
error:
- The sign-up form's fields depend on what the relay says about its
  identity mode, so filling them before discovery lands types into
  fields that then vanish, and the submit button stays disabled.
- `toggle-register` is a toggle. Clicking it blindly put the test into
  sign-in mode, where it pressed a button labelled "Sign in" and read
  the refusal as "Your session expired".
- The contact row shows the display name, so a peer whose display name
  differs from its handle is invisible to a search for the handle.
- The handshake counter has to be zeroed after the sign-out, because
  signing out of the previous run is itself a request through the bridge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 15:58:39 -04:00
extant2000git 416f2d8433 Merge pull request 'Android 0.32: merge the release branch into main, and move CI to Gitea Actions' (#191) from merge/0-32-unify into main
ci / image (push) Successful in 11s
ci / secret-sweep (push) Successful in 14s
ci / vectors-head (push) Successful in 5m6s
ci / unit-tests (push) Successful in 10m4s
All four Gitea Actions jobs green on a13adb66: secret-sweep, image, unit-tests (671 tests) and vectors-head. The tree compiles and the suites pass; the notification and padding merges still deserve a human read.
2026-10-14 15:12:40 +00:00
Paul HittandClaude Opus 5 a13adb66c4 ci: base64 from java.util, Bytes is internal to the engine
ci / vectors-head (pull_request) Successful in 3m25s
ci / image (pull_request) Successful in 13s
ci / secret-sweep (pull_request) Successful in 15s
ci / unit-tests (pull_request) Successful in 10m39s
StoryTakeDownTest reached for `io.flamenet.e2e.Bytes`, which is internal to
the engine module, so the app's test source set cannot see it. Both that
helper and the client's `Wire.b64` are `Base64.getEncoder().encodeToString`,
which is what the linked-device map is compared against, so the test uses it
directly, as the other app tests do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 11:00:41 -04:00
Paul HittandClaude Opus 5 bca26e2337 Four test expectations that the merge changed, each decided by the spec
ci / image (pull_request) Successful in 9s
ci / secret-sweep (pull_request) Successful in 11s
ci / vectors-head (pull_request) Failing after 4m46s
ci / unit-tests (pull_request) Failing after 6m1s
Each of these tests was written on the release branch against behaviour main
has since changed. The production behaviour stands and the expectation moves,
because in every case main's rule is the one the spec states.

InboundControlReachesTheThreadTest: a reaction and a pin were expected in
`absorb`'s return. Sections 6.9 and 6.19 say neither may notify, and main
applies both through `changedQuietly`, so the thread is redrawn and never
marked unread. The two tests now assert an empty unread set and the thread in
`takeQuietlyChanged`.

StoryTakeDownTest: the tablet was registered but never linked. Spec 17.3,
which main added, sends an own-account copy only to devices a person linked,
under the key they were linked with. A device signed in with the password is
a device of the account and nothing more. The test links it.

ASnoozedChatHoldsItsNotificationsTest read MessengerService for
`due(touched, ...)`. The service now takes quiet threads out first
(`val loud = touched - s.quietThreads`), per 14.5 and 17.2, and the test
checks both lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 10:54:18 -04:00
Paul HittandClaude Opus 5 52b291ffb2 Fix three more merge defects the compile found
ci / secret-sweep (pull_request) Successful in 13s
ci / image (pull_request) Successful in 9s
ci / vectors-head (pull_request) Successful in 4m55s
ci / unit-tests (pull_request) Failing after 8m17s
Session.kt declared `arrived` twice, once per side. Main's filter is the one
to keep: its own comment records that filtering on `isRenderable` here made
the reaction, edit and pin branches unreachable, so everything that arrived
was decrypted and then dropped. The release branch's declaration is gone and
the loop uses the broader list, which is what main's fix intended.

StoryFrame.kt was extracted from StoriesScreens and localized in the same
move, so it calls `stringResource(R.string.social_story_image_desc)` without
importing either name. Both imports added. The key is in values and values-es
already; parity is 860 keys per language.

SilentKindsTest pinned "bridge offer" to `Content.bridgeOffer(...)`, which
returned String on the release branch and String? on main, where a blank or
over-long line is refused (§24.4). That typed the whole map String? and the
two assertions stopped compiling. The test line is neither blank nor long, so
it is unwrapped at the fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 10:37:34 -04:00
Paul HittandClaude Opus 5 d1ddbba3de ci: name bash as the shell, dash has no pipefail
ci / secret-sweep (pull_request) Successful in 13s
ci / image (pull_request) Successful in 14s
ci / vectors-head (pull_request) Failing after 4m35s
ci / unit-tests (pull_request) Failing after 4m45s
The container job picked `sh -e {0}`, and sh on eclipse-temurin:jammy is
dash. Every `set -euo pipefail` step failed on line 2 with "Illegal option
-o pipefail" before running a command, so unit-tests and vectors-head went
red without compiling anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 10:11:36 -04:00
Paul HittandClaude Opus 5.5 465a192d7f ci: move CI to Gitea Actions
ci / secret-sweep (pull_request) Successful in 13s
ci / image (pull_request) Successful in 2m1s
ci / vectors-head (pull_request) Failing after 1m0s
ci / unit-tests (pull_request) Failing after 1m2s
Translates .gitlab-ci.yml to .gitea/workflows/ci.yml and deletes it. Kept:
secret sweep, the four-module unit run with --rerun-tasks against the
pinned vectors, the XML floors (595/405/68/515, 1580 total), vectors-head
(advisory).

Runs on mac1/mac2 inside a pinned toolchain image (ci/android-ci.Dockerfile:
Temurin 21.0.5, cmdline-tools 11076708, platform 35, build-tools 34.0.0 and
35.0.0, Node 22.11.0), never on pauls-imac. The `image` job pushes the tag
to git.extant2000.com when it is missing.

Vector and README links now point at git.extant2000.com/extant2000.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:03:52 -04:00
Paul HittandClaude Opus 5 92776e6f8c Fix the merge: one stray brace, and the five vector paths the release CI dropped
Two real defects in 69e12af, both found by the pipeline rather than by reading.

client/Conversations.kt had an extra `}` on line 425. Writing out both
`sendGroupAttachment` (main) and `uploadAttachment` (the release branch) left
the closing brace of the function git had spliced away. The class body closed
at 425 instead of 3466, so the parser reported `companion object` at 3364 as a
syntax error 3000 lines from the cause.

.gitlab-ci.yml took the release branch's copy whole, which predates five
vector exports main had added: padding-v1, sync-merge-v1, handle-cert-v1,
links-v1 and bridge-offer-v1. BridgeLineVectorTest then failed on a missing
file, reading as a protocol bug. The two sides each added different vectors,
so the resolution is the union, in both the pinned job and vectors-head.
vectors-head runs :obfs4:test again for the same reason: it reads the
bridge-offer vectors from SPEC section 24.

All 15 vector files exist at the pinned e2e ref 4b4042f.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 10:03:34 -04:00
Paul HittandClaude Opus 5 69e12afb40 Merge release/android-0-32 into main: the 0.32 features meet the Spanish strings
Two 0.32 lines had diverged since 28 September. main carried the Spanish
localization (853 strings) and friend-to-friend bridge sharing;
release/android-0-32 carried 65 commits of stories, Today, the shared test
vectors, the shell peer and envelope wake parity. Neither was a superset, so
this is a real merge of 26 conflicting hunks across 15 files.

.e2e-ref now pins flamenet-e2e 4b4042f, the commit that unified the two spec
lines. Before that no single pin covered both sides. .gitlab-ci.yml takes the
release branch's version, as the owner chose.

Conflicts that were decisions, not text:

- Conversations.kt looked like two versions of uploadAttachment. It was not:
  that function exists only on the release branch, and git had paired its
  doc comment and parameter list with the body of main's sendGroupAttachment.
  Resolving side by side would have deleted one function. Both are written
  out whole, and the attachment padding of 23.7 and 23.9 is intact.
- MessengerService and Session: main suppressed alerts for this account's own
  device copies (14.5, 17.2); theirs added card, event and game notices with
  snooze handling. Session keeps the quiet set and the notice bookkeeping and
  returns touched + quiet, so quiet threads still count as unread, and the
  service subtracts them before anything is raised.
- MainActivity: contact links and hello links are different schemes, so both
  handlers stay. Hello runs first, because helloLinkIn matches only its own
  scheme and leaves the intent alone, while the contact handler consumes any
  VIEW data and would swallow every hello link.
- MessengerViewModel: fetchedBuddies is not in scope at that point, so the
  roster comes from the vault as theirs has it, with the status localized.

Nine string keys to match: seven new (the hello-link QR label, the card and
event forward notes, the Today tab and the three attach row labels) and two
reworded, because supervision now hides the Today tab. English and Spanish
both, so parity holds at 879 keys each side.

No Java on the machine this was merged on, so CI is the first compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 09:51:19 -04:00
Paul Hitt 842439b452 Merge branch 'i18n/es' into 'main'
Bridge sharing (friend to friend) and Spanish (draft, needs native review)

See merge request extant2000/flamenet-messenger-android!163
2026-10-10 15:08:37 +00:00
Paul HittandClaude Opus 5.5 0d62e81d1a Spanish (es) draft for the bridge screens
DRAFT, needs native review.

Rebased onto bridge/f2f-apps (ebdd2ac). NetworkSection.kt was taken from
the bridge branch whole and its text moved to resources again, so every
bridge behaviour stays as that branch wrote it: several lines with failover,
offers, sharing with a confirmation, scanning a line, and nothing said to go
around a saved bridge.

- 19 new resources: the bridge list, failover and sharing text, the sign-in
  "Connect through a bridge" toggle, the four "off while you use a bridge"
  notices (calls, wallet, link previews, and the no-bridge failure).
- statusLineText() mirrors the new statusLine() branches, including "none
  reached" and "N bridges saved"; the English function stays for its tests.
- describeFailure takes the no-bridge sentence from the view model;
  ChainRpc.Failure.OffWhileBridged maps to the wallet notice.
- ScanAddressDialog's default hint and refusal are resources.
- Obfs4Settings.AddResult and AroundTheBridge keep their English for the
  tests; the screens show resources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:24:47 -04:00
Paul HittandClaude Opus 5.5 5dbf626f46 Spanish screens checked on the emulator; four more lines localized
DRAFT, needs native review.

An emulator run with the app locale set to es-MX, signed up on a local
relay, showed English in four places and one cut-off label:
- The sign-in line about which accounts a server keeps
  (SiteConfig.identityDescription) and the Network status line
  (statusLine): both now have a resource twin that the screen uses. The
  English functions stay because their tests call them without a Context.
- Message and reply-reminder notifications: the NotificationSettings
  instance methods read resources; the companion functions the tests pin
  are unchanged.
- The presence word ("offline") in the chat header, contact rows and
  contact detail now goes through presenceLabel().
- "Todos excepto" was cut to "Todos exc..." on the story audience row. It
  is "Todos menos" now, and "Todos" for "All contacts". "7 días" is still
  cut, exactly as "7 days" is in English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:09:55 -04:00
Paul HittandClaude Opus 5.5 d735e96b0c Spanish (es) draft: UI text moved to resources, values-es added
DRAFT, needs native review. Nothing in values-es ships until the owner or a
Localization Lab volunteer has read it.

- 841 user-facing strings (823 strings, 18 plurals) moved from Compose and the
  view model into res/values/strings_{common,chat,core,main,settings,social}.xml.
  English is word for word what the screens said before.
- values-es mirrors every file key for key, neutral Latin American Spanish,
  "tú", terms from the /es/ site pages. docs/i18n/glossary-es.md lists them.
- UserFacingErrors.kt maps the client library's fixed-text exceptions to
  resources, so a relay refusal reads in the phone's language. Text that
  comes from the relay itself stays as sent.
- SpanishStringsTest: every key has Spanish, no orphans, identical
  placeholders, a draft marker in every file; short labels over 1.6x are
  listed, not failed.
- Source-reading tests read the English through EnglishStrings.withEnglish,
  which appends the text of every key a file references.

Left in Kotlin on purpose, because a unit test calls the function with no
Context and pins the English: describeFailure, linkOutcomeText,
NotificationSettings.messageText, SiteConfig.identityDescription,
NetworkSection.statusLine, registeredLine, draftLengthText, quotedPreview,
FileLevels, ReportReason labels, AppDiagnostics summaries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:09:51 -04:00
Paul HittandClaude Opus 5.5 ebdd2acb00 Vectors: pin flamenet-e2e 4b85000 (SPEC-only change on bridge/f2f-followups)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:07:43 -04:00
Paul HittandClaude Opus 5.5 0f2e8cc529 Fail over within one request when the server is an IP literal
OkHttp skips the resolver for an IP literal, so it had one route and gave
up after the first line; the next line waited for a later request. The
cross-platform run caught it: Android sign-up against a relay at an IP
address sat on a dead first line, because only one discovery probe is made.
An interceptor now retries on a connection-phase failure (refused, no
handshake, TLS failing in the tunnel), once per stored line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:01:14 -04:00
Paul HittandClaude Opus 5.5 ef197dd432 Vectors: pin flamenet-e2e 4e36d5a; hold Content.bridgeOffer to envelope_plain byte for byte
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:25:28 -04:00
Paul HittandClaude Opus 5.5 57a969d3f2 Nothing goes around the bridge: previews, calls and the wallet are off while one is saved
SPEC §24.6: a client with bridges turned on must not connect directly.

- Off while a line is saved, saying why in plain words: link previews (the
  fetch returns nothing and connects nowhere; the setting is greyed with a
  note), calls (refused at place, at answer and where the peer connection is
  built; an incoming call is answered busy), and the wallet (the chain client
  refuses before connecting; the screen says so).
- Through the bridge now: the update check, which had its own client.
- The anonymous client keeps its own pool, which now empties with the rest
  when the bridges change (§24.7).
- The probe the app makes at launch goes to a saved bridge, checked by a
  test that fails when the bridges are not loaded at startup. With no bridge
  it is unchanged.
- The sign-in UI test saves a dead line first, so its view model never
  probes the production server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:18:56 -04:00
Paul HittandClaude Opus 5.5 9f4cb84009 Bridges: a screen before sign-in, several lines with failover, §24 receive rules
- "Connect through a bridge" on the sign-in and sign-up screen opens the
  bridge panel before any account exists: paste or scan a line, the live
  status, remove a line.
- Up to 8 stored lines (migrated from the one-line store), tried one at a
  time with the one that last worked first. OkHttp's own route loop does the
  trying: while lines are stored the resolver answers every name with one
  placeholder per line, so no name is looked up on the local network, and a
  refused connection, no handshake within 15 s, or TLS failing inside the
  tunnel moves to the next line. accounts.flamenet.io and the relay keep
  their own names through the tunnel, so the edge behind the bridge routes
  each to its real service. Every line failing never connects directly, and
  the error says no bridge could be reached.
- Offers: a gid, a line over 1024 bytes, or a line that is not a string is
  dropped; an oversized or blank line is never built. Ports are digits only.
- Held to bridge-offer-v1.json in :engine and :obfs4; .e2e-ref moves to
  flamenet-e2e c75b861 and CI exports FLAMENET_BRIDGE_VECTORS.
- Citations of §6.19 for the bridge offer now say §24.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:56:30 -04:00
Paul Hitt 4c818e1e7e Merge branch 'release/0.32.0' into 'main'
FlameNet Messenger 0.32 (Android): padding, pairs 2-B, multi-device, sealed first contact, Unlisted and contact links

See merge request extant2000/flamenet-messenger-android!162
2026-10-09 19:40:01 +00:00
Paul HittandClaude Opus 5.5 6eb6b77329 Merge main (!161, the vault hotfix) into release/0.32.0
!161 carried this branch's two vault fixes to main, with the account-switch
test written against main's Vault (group names in place of the client
records 0.32 adds). Both added that test file; this keeps the 0.32 version,
which also checks the handle certificate record. Every other file merged
cleanly: the fixes are the same lines on both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:30:51 -04:00
Paul Hitt ba9a94a85f Merge branch 'fix/account-switch-and-vault-race' into 'main'
Another account on the same install starts empty; two vault writes no longer crash

See merge request extant2000/flamenet-messenger-android!161
2026-10-09 19:30:35 +00:00
Paul HittandClaude Opus 5.5 605bd98e71 Vectors: pin flamenet-e2e 50fcc99, the merged 0.32 spec with §7's PQ number
e2e!66 merged privacy/sweep-2026-10 (the 0.32 SPEC and vectors this branch
was written against, 5a5b7e7), the Swift interop build list and §7's
post-quantum safety number. PublishedVectorsTest reads `safetyNumberPQ`.

Full suites with the vectors at 50fcc99: engine 759, client 573 (30 live
tests skipped), obfs4 75, app debug 393, app release 352, 0 failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:59 -04:00
Paul HittandClaude Opus 5.5 0cc85eb411 Contact links from the shipped relays open the app (verified app links)
SPEC 8.12: https://<relay>/add#... used to match only an unverified
*.flamenet.io filter, so Android asked which app to use, or opened the
browser. A second filter with android:autoVerify claims relay.flamenet.io
and relay1.flamenet.io for /add; the relay serves the matching
/.well-known/assetlinks.json (io.flamenet.messenger, the Messenger release
certificate's SHA-256). The wildcard filter stays for every other relay.
The link handler already reads the https form (MainActivity.openLinkIfAsked
to handleLinkIntent).

AContactLinkOpensTheAppTest: red with the old manifest, green now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:58:56 -04:00
Paul HittandClaude Opus 5.5 51d496a698 Safety number: cover both post-quantum identities, as iOS and SPEC §7 do
Owner's decision, 2026-10-09: iOS's rule (audit H3) is the protocol. On
the iPhone-to-Android pair against a local relay the two screens showed
different numbers for the same accounts (iOS 09614...98887, Android
15508...63939): only iOS folded the ik_pq fingerprints in.

- Trust.safetyNumber folds SHA-256(ik_pq) of both sides in when this
  device holds both (SafetyNumber.compute with PQ, the engine already had
  it), the Ed25519 number otherwise. safetyNumberCoversPQ says which, and
  the screen says it in iOS's words.
- The side that never read the other's bundle (the one that answered a
  first message) had no pin for them, so its number could never match.
  Conversations.pinPeerPQIdentity reads the contact's bundles once, for
  devices whose IK_sig is already trusted, checks and pins their ik_pq
  (§20.5); the screen does it when the number is first shown.
- PublishedVectorsTest pins protocol-v1.json `safetyNumberPQ`
  (flamenet-e2e 841492d).

FirstContactTest "the safety number covers both post-quantum identities,
as on iOS": red (Ed25519 number), then green, including both screens
matching after the pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:36:03 -04:00
Paul HittandClaude Opus 5.5 8842479bb8 Another account on the same install starts with an empty vault
Found on the emulator against a local relay: after a plain sign-out of
luke1009 and a sign-up as mira1009 on the same install, mira saw luke's
conversation with lina1009, carried andy1009's handle certificate from two
accounts earlier, and sent her first message to lina on luke's ratchet
session. The iPhone could not open it ("no session for envelope 95 from
a26ac1d7") and mira's first contact never arrived. Main has the same sign-in
path: a different account got a new device identity (android#22) and kept
every other record.

AccountSwitch.forgetPreviousAccountIfSwitching empties the vault when a
different account (another user id or relay) signs in, before its device is
made. The same account signing back in keeps everything, as before. The
last account's device identity was already being replaced at that point,
so nothing forgotten was still usable by it.

AnotherAccountStartsWithNothingTest: 2 red, then 4/4 green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:26:53 -04:00
Paul HittandClaude Opus 5.5 69a7bd72c6 Two writes of one vault record no longer kill the app
Found on the emulator, typing into a new conversation: the app died with
"IllegalStateException: could not commit the vault record" from
Vault.saveDraft on a worker thread. The draft is saved from a coroutine per
keystroke on the IO pool; two writes of one record staged into the same
`<name>.tmp`, the first rename took it, and the second had nothing to
rename and threw, uncaught. Main has the same code.

Each write now stages under its own name, and draft saves run one at a
time and in order, so an older draft cannot land after a newer one or
after the send that cleared it.

TwoWritesOfOneRecordDoNotCrashTest (400 writes from 8 threads): red with
the shared staging name, green now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:26:31 -04:00
Paul HittandClaude Opus 5.5 d83cebfee2 Another account on the same install starts with an empty vault
Found on the emulator against a local relay: after a plain sign-out of
luke1009 and a sign-up as mira1009 on the same install, mira saw luke's
conversation with lina1009, carried andy1009's handle certificate from two
accounts earlier, and sent her first message to lina on luke's ratchet
session. The iPhone could not open it ("no session for envelope 95 from
a26ac1d7") and mira's first contact never arrived. Main has the same sign-in
path: a different account got a new device identity (android#22) and kept
every other record.

AccountSwitch.forgetPreviousAccountIfSwitching empties the vault when a
different account (another user id or relay) signs in, before its device is
made. The same account signing back in keeps everything, as before. The
last account's device identity was already being replaced at that point,
so nothing forgotten was still usable by it.

AnotherAccountStartsWithNothingTest: 2 red, then 4/4 green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:42:42 -04:00
Paul HittandClaude Opus 5.5 2ac1ede1fc Carry this account's handle certificate, never one kept from another
Found on the emulator against a local relay: an install that had been
signed in as andy1009, then iris1009, then luke1009 carried andy1009's
certificate (uid 1) as `hc` on luke's first message to an iPhone. The
iPhone refused it ("prelude carried a handle certificate that does not
verify") and showed luke as "Buddy 3". A plain sign-out keeps the vault,
and ownHandleCert reused any kept certificate that had not expired.

A kept certificate is now reused only when it verifies for this account;
otherwise a fresh one is fetched.

FirstContactTest "a certificate kept for another account is not carried as
mine": red, then green; 16/16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:31:39 -04:00
Paul HittandClaude Opus 5.5 394ad7626d Two writes of one vault record no longer kill the app
Found on the emulator, typing into a new conversation: the app died with
"IllegalStateException: could not commit the vault record" from
Vault.saveDraft on a worker thread. The draft is saved from a coroutine per
keystroke on the IO pool; two writes of one record staged into the same
`<name>.tmp`, the first rename took it, and the second had nothing to
rename and threw, uncaught. Main has the same code.

Each write now stages under its own name, and draft saves run one at a
time and in order, so an older draft cannot land after a newer one or
after the send that cleared it.

TwoWritesOfOneRecordDoNotCrashTest (400 writes from 8 threads): red with
the shared staging name, green now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:15:45 -04:00
Paul HittandClaude Opus 5.5 2827c39c73 Numeric linking: the offer shows at once, and the state follows the link
Found linking an emulator to an iPhone account by the numeric fallback
(17.3), against a local relay:

- The offer arrived and was held, but the banner that asks the person to
  compare the number reads held offers only on a refresh, and the poll
  refreshed only after a tick that touched a thread. A link offer touches
  none. The poll tick now refreshes when the held offers change.
- The existing device sends its snapshot right behind the offer. The new
  device, still waiting for the person, dropped it as traffic from a
  device it had not linked ("DROPPED sync_state from own device ... which
  is not linked"), so the confirmed link held no contacts, blocks or
  flags. acceptHeldLink now asks the offering device for its state
  (sync_state with want), which both apps answer with a snapshot.

History sent behind a numeric offer is still dropped; only the QR path
backfills history.

LinkingAndSyncTest "the numeric fallback still hands the new device the
account's state" and ALinkOfferShowsWithoutAMessageTest: red, then green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:34:11 -04:00
Paul HittandClaude Opus 5.5 0c1cf8b7ec A fresh install pins the relay key on first sight, so it can be introduced
Found on the emulator against a local relay (relay main plus the wave 0b
descriptor switches): a new Android account adding an iPhone account by
username failed with "The server's answer about that account could not be
verified, so nothing was sent." The handle certificate (14.5) is checked
against the pinned relay key, and Android pinned that key only when it
first verified a key-log head, which a fresh install had not done yet.
FakeRelay pinned every device by hand, so no test saw it.

Trust.relayKeyPinnedOnFirstSight pins the descriptor's key when nothing is
pinned, on the same RelayIdentityPin verdicts as the head check; a key
that changed still verifies nothing until the person accepts it. iOS pins
on first sight at start-up (checkRelayIdentity).

FirstContactTest: "a fresh install pins the relay key on first sight" red,
then green; "a changed relay key verifies no certificate" green both ways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:36:09 -04:00
Paul HittandClaude Opus 5.5 0258846b5d Tests: answer the relay descriptor outside the queue; follow 0.32's block
The full suites had not been run on this branch with --rerun-tasks. With
the vectors given (FLAMENET_*_VECTORS, flamenet-e2e 5a5b7e7):

- 20 client and 6 app tests queue exactly the answers their route needs on
  a MockWebServer. Since 0.32 a poll and a directory read first read the
  relay descriptor, which took the queued inbox and left the real read
  waiting for 10 seconds. NoDescriptor answers the descriptor with 404
  (every switch off, the behaviour these tests describe) outside the
  queue, and request counts skip it.
- SecondDeviceCallTest looked for `private fun sendPlaintext` and an
  `emptyList()` prelude; it is internal now and the preludes are
  `if (toSelf) null`.
- AcceptingKeysBringsTheMessagesBackTest counted forgetHeldEnvelopes in
  the view model; a block now drops them in Conversations.blockContact
  through Session.block.

engine 758/758, client 568 (30 live tests skipped, no relay), obfs4 75
(interop against lyrebird 3/3 via scripts/obfs4-interop.sh), app debug
386/386, app release 345/345.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:08:44 -04:00
Paul HittandClaude Opus 5.5 af64b466da Anonymous reads from the first send, not the first poll
The relay's switches were applied only in poll(), so a send made before
the session's first poll read the recipient's devices and bundles from
GET /e2e/devices/{id} and /e2e/keys/{id}, naming the target in a URL, on a
relay that offers anonymous reads (spec 8.11). FirstContactTest "reads
about other people name nobody" caught it: it sends without polling and
was red on this branch. iOS reads the switch at each call.

directoryFor and bundlesFor now apply the switches first (cached, so one
descriptor read per session), and the avatar push reads devices through
directoryFor rather than the relay client directly.

New in FirstContactTest: a reusable link reaches the relay as standard
base64 SHA-256 of its 32 bytes, the link carries it as 43 characters of
unpadded base64url, and a sixth is refused and not kept, as
RoutesIntro.setContactLinks requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:08:44 -04:00
Paul HittandClaude Opus 5.5 6badd3e999 Contact links: one-time links on every relay
Spec 8.12: a one-time link carries a delivery token (21.4) the recipient
minted, and the first message presents it. No introduction is involved.
The Finding you section was hidden unless the relay advertised
`introductions`, which hid one-time links with it. iOS offers them on any
relay that takes delivery tokens.

contactLinkKindsOffered: one-time always, reusable where the relay keeps
them (`contactLinks`). The section shows on every relay.

OneTimeLinksNeedNoIntroductionTest: red (an empty list without
introductions), then green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:08:44 -04:00
Paul HittandClaude Opus 5.5 2c7fda2dc1 Web search: rows tall enough for a title, a host and a snippet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:04:45 -04:00
Paul HittandClaude Opus 5.5 1c3ce29f84 App: no smart cast across modules; read the Listed setting off the main thread
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:04:45 -04:00
Paul HittandClaude Opus 5.5 b96a63e227 Descriptor switches use the names iOS reads: introductions, anonymousReads
The relay does not advertise either yet (privacy/wave0-relay offers
padAttachments, perDeviceDigests and, on privacy/web-search, webSearch), so
the two apps have to agree on the names first. iOS 14c2c7ed reads
features.introductions and features.anonymousReads; Android now reads the
same keys. contactLinks, unlisted and webSearch already matched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:04:45 -04:00
Paul HittandClaude Opus 5.5 bf5ad7074b App: Requests, stars and archives, linking, finding you, contact links, web search
The screens for part 2 (docs 60 and 61), on top of the client work:

- Requests (14.5, decision DC2): a stranger's conversation sits under
  Requests with Accept, Block and Delete, named by a verified certificate
  when there is one ("Name confirmed by the server") and otherwise by the
  relay's answer, marked unconfirmed. The app no longer adds strangers to
  the contact list on the person's behalf. Requests and copies from linked
  devices are quiet: they count as unread and never raise a notification.
- Pairs 2-B: acceptance and blocks seeded from the relay once, then held on
  the device; blocking is the local block, the batch revoked, the key
  rotated and pushed, and the relay's block, with an honest message when a
  part of it failed. Star and archive from a long press, starred first,
  archived under their own heading. Opening a chat moves its read mark,
  which the linked devices get; a thread read on another linked device stops
  showing unread here. An owned legacy group is sealed once a session.
- Sent-copies and history (17.2, 17.4) are filed into their threads as
  outgoing (or as the original sender's), once, never into an
  off-the-record thread.
- Your devices: which are linked, Unlink, Link this device (the QR code for
  ten minutes), Scan a device's code (confirmed before anything is sent),
  Link by number with the safety number on both screens, an optional
  history backfill, and why a linked device left the set (removed by you,
  retired by the server, or no longer listed). A held numeric offer shows
  on the new device until the person confirms the number there too.
- Finding you (8.12): Listed or Unlisted where the relay offers it, worded
  as "people can't find or add you by your username", never "secret"; the
  same choice at signup, Listed by default, with an honest message when the
  server cannot apply it. One-time and reusable contact links as QR codes,
  shared as the https form, removable.
- Contact links open from a scan, from another app (flamenet: and
  https://*.flamenet.io/add, not verified app links) or after sign-in if one
  arrived before it.
- Web search, its own screen behind features.webSearch.
- The export nudge (DA9): a single-device account with no export in 30 days.
- A send refused for having no sealed way to go says "Could not send.
  <name> needs to message you or send you a new link."

UI copy went through the avoid-ai-writing audit (two fixes). LinkingWordsTest
(2). Written while builds are paused for CI: the :app module has not been
compiled since 9322d1e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:04:45 -04:00
Paul HittandClaude Opus 5.5 020f974829 Client: a new contact device gets our presence token; web search; hourly log check
- Spec 8.9d: when a contact's device list gains a device (Trust records the
  addition), the next poll re-sends them our delivery-key prelude with the
  presence token we already issued, so a new phone of theirs restored
  without a backup can see our presence. Taken after the poll, never inside
  a send.
- Spec 17.3: the linked set is checked against the key log at most hourly
  from the poll.
- WebSearch and MessengerApi.webSearch for POST /api/flamenet/v1/search
  (relay branch privacy/web-search): the query in the body, never the URL;
  the relay's limits (1 to 200 characters, pages 1 to 3) checked before
  asking; an unavailable index kept apart from "nothing found"; a result
  that is not http or https dropped. Shown only with features.webSearch.

WebSearchTest (4). Written while builds are paused for CI; not yet run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 07dd805226 First contact without naming anybody: intros, certificates, links, anonymous reads
Client side of stages C2, C3, D2 and B3 (SPEC 14.5, 8.11, 8.12), each behind
the relay's descriptor switch for it, since none of the relay half is
deployed: features.intro, features.anonReads, features.contactLinks and
features.unlisted (a JSON true only). The relay branch privacy/wave0-relay
does not advertise these four yet; it must before any of this turns on.

- C2: HandleCert verification against the pinned relay key. This account's
  own certificate (GET /e2e/handle-cert, read bare or wrapped) rides as `hc`
  in the first message's prelude; a contact's is kept only if it verifies
  and names that sender. knownHandle says certified or a link's hint.
- C3: introduceByHandle (the intro challenge solved, then POST /e2e/intro)
  and introduceByLink (a one-time link's token presented as delivery_token,
  bundles read anonymously; a reusable link asks for an intro, which does
  not spend it; a link for another relay is refused). A send seals by token,
  then key, then intro. With features.intro (or sealed-only asked for),
  nothing goes out named: a refused token tries the key, a refused key tries
  ONE fresh intro by the link or handle the conversation started from (or
  the roster's handle), and otherwise NeedsIntroduction, sending nothing.
  Without the switch a first message still goes named, so 0.31 peers and
  today's relays keep working.
- D2: AnonymousRelay, a separate OkHttp client (own pool, no cookies, no
  Authorization, User-Agent "FlameNet", same obfs4 transport). With
  anonReads, directory and bundle reads about others go to /e2e/anon/* with
  the presence token they issued us as `cap`; our own devices come from
  GET /e2e/devices/me. presenceAnonymously asks in shuffled batches of at
  most eight, each after a random pause of up to 20 s.
- B3: createContactLink (one-time: a delivery token uploaded by hash among
  the others; reusable: POST /e2e/contact-links by hash, at most five),
  contactLinks, revokeContactLink; MessengerApi.discoverable and
  setDiscoverable for Listed and Unlisted.

FirstContactTest (12) against FakeRelay, which gained intros, certificates
signed by a relay key the devices pin, the intro challenge and contact
links. Written while builds are paused for CI; not yet run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 9cabe50b10 Backups carry the account state: acc, blocked, convFlags, read, dk, batches
Section 16.2.1's pending keys, written and read: who was accepted and who is
blocked, stars and archives, read marks (from the sync registers, their only
store), the account delivery key and the delivery-token batches issued.

A restore merges acc, blocked and convFlags as a union (it never unblocks
anybody), read as a maximum per thread, deliveryTokensIssued per contact with
this device's own entry winning, and adopts dk only onto a device with no
key of its own (16.1), unpublished so this device publishes it. What changes
the registers is queued for the linked devices.

BackupAccountStateTest (6) holds the round trip to the vector's `pending`
object exactly, and each merge rule. Written while builds are paused for
CI; not yet run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 ca2b63a553 Linking, sync registers and contact state on the device (17.3, 17.6, 8.9d)
Client side of stages A2 and A3 and the core of pairs 2-B:

- AccountSync keeps this account's registers (17.6). acc/, blocked/, conv/
  and read/ live only there: who was accepted, who is blocked, stars and
  archives and read marks are no longer the relay's to remember. Seeding
  from the relay happens once and is a union.
- SyncProjection mirrors the other families (roster, gname, gsecret, pin,
  audience, muted, pdk, tok, ptok/i, ptok/h, dk, link) onto the stores the
  app already had. A device compares what its stores say with the last look
  and turns each difference into an op; a change that arrived from a linked
  device is written and the look retaken, so it is never echoed, and a
  value that does not parse leaves the store alone, so it is never undone.
  Off-the-record pins never cross.
- flushSync at the end of every poll: up to 8 ops go as sync envelopes, a
  burst as one sync_state; read marks at most once per thread per 30 s. With
  nobody linked the queue is dropped. A week away sends a snapshot with
  want; 90 days away discards all but link/ first.
- Own-device traffic is sealed to the account under its delivery key.
  Everything but a link_offer counts only from a linked device; a link/ add
  is honoured only for a device the relay lists on this account under that
  key; a link/<self> rm clears this device's set.
- Linking: beginLink (the QR text, a 16-byte secret for ten minutes),
  linkDevice (relay must list the device under the shown key, the key log
  must not have retired it; then link_offer, a snapshot, optional history,
  and the op to the rest of the set), the numeric fallback held until the
  person confirms on the new device too, unlinkDevice (the device leaving
  is told), refreshLinkedSet (revoke / expire / missing, kept as notices).
  The new device adopts the linked set and the account delivery key.
- Sent-copies (17.2) for everything a person sends, one per group fan-out,
  never for off-the-record threads; history (17.4) arrives as Received.
- Token slices (21.4, A3): each of a contact's devices gets its own slice of
  the batch in its own prelude. The prelude can carry `hc` (14.5).
- Blocking is the local block, the batch revoked, the delivery key rotated
  and pushed to every other contact at once. A blocked person's messages are
  dropped on arrival. A stranger's message is marked a request; writing to
  somebody accepts them. A rekey_request drops sessions with that device and
  the batch issued to them.

FakeRelay: an in-memory relay with real mailboxes, sealed-submission
checks, contact links, intros and log entries. LinkingAndSyncTest (24), all
passing; client suite 551 with the two UrgentCallOfferTest source checks
updated for the new send signature (text may be null for a key push).
Not yet run: falsification of these tests, and the :app compile, both held
while builds are paused for CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 a5a9fef9e0 Engine: sync registers, link codes and handle certificates, held to the vectors
The pieces sections 17.6, 17.3, 8.12 and 14.5 must compute exactly as every
other engine does, before anything uses them:

- SyncRegisters: the register merge (later at wins, a block wins a tie, then
  the greater device id as text by UTF-16 unit; read marks only move
  forward; ops more than a day ahead dropped; malformed ops ignored whole;
  unknown keys kept as ordinary registers), tombstones forgotten after 90
  days, snapshot registers merged one by one, chunking at 16 KiB.
- LinkCodes: the device-link code (flamenet:link?d=&k=&s=), both forms of a
  contact link (https://<relay>/add#... and flamenet:<fnid>@<relay>?...),
  base64url without padding, encodeURIComponent as the reference writes it.
- HandleCert: fnhandle-v1 verification against the pinned relay key, for this
  relay, for the account it is about, before exp; `|`, a wrong version and a
  uid given as text are refused before any signature check.

MultiDeviceVectorTest (10) checks sync-merge-v1 in every order of every
case's ops and snapshot registers, each order also delivered twice;
handle-cert-v1 valid and invalid; links-v1 encode, decode and refuse.
Falsified: no tie rule for blocks, `|` allowed in a handle, a repeated key
accepted, a short secret accepted; each goes red.

.e2e-ref moves to flamenet-e2e 5a5b7e7 (privacy/sweep-2026-10), which carries
the three files; content-v1 and backup-payload-v1 changed only in their
pending sections, and the whole engine suite passes at the new pin (752).
CI exports the three new variables in unit-tests and vectors-head and gates
on them. That commit must be on the e2e remote before this branch's CI can
pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 935062cfce App: compile and test what part 1 changed without a compiler
The SDK is back, so :app builds again: 358 unit tests passed before this
commit with no change needed. The three app-side changes from part 1 had no
test of their own:

- the composer's countdown text (draftLengthText, pulled out of the
  composable);
- describeFailure for each Padding.Failure, in the words iOS uses;
- when roster tags are published, and under which device id
  (Session.planRosterTags, pulled out of presenceFromTokens).

PaddingReachesTheScreenTest (6). Falsified: the Padding branch removed, the
device id sent on every relay, the countdown starting at 2,000, and an empty
first set published; each goes red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 2ba6dba70a Composer: a "/" costs one character, as on iOS
Content wrote strings through org.json's JSONObject.quote, and the two
org.json builds disagree: the phone's escapes every "/" as "\/" and writes
U+2000 to U+20FF as UTF-8, the JVM's (used by the tests) does the opposite.
So a URL cost twice on a phone what iOS counts for it, the composer reached
its limit sooner on Android, and the tests measured a writer no phone runs.

Content and Padding.composerCost now both use the engine's own writer
(Padding.Json.quote), which escapes only what JSON requires and is the same
on both. A "/" is one character, as iOS counts it. Receivers parse JSON and
read either form; the iOS vectors still parse.

New test: what the composer counts is exactly what a text adds to the
envelope, for random runs of quotes, slashes, control characters, dashes,
U+2028 and emoji. Falsified: composerCost back on JSONObject.quote, and
Content back on JSONObject.quote, each go red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 49ef29aca7 A group message too long to send is refused whole, before any member has it
SPEC 23.7 says a refusal is surfaced and never swallowed, and comes before
anything is spent. Android checked each member on that member's own session,
so a text that fit one member's classic header and not another's post-quantum
first message reached the first member and was then reported as refused; the
sender's own copy was never written. iOS had the mirror of this (caught per
member, shown as delivered).

- Padding.refuseUnlessItFitsEveryHeader checks a body once against the
  largest header an envelope can carry, sealed, as iOS does. sendToGroup and
  sendGroupAttachment call it before any member (the caption before the
  upload too); the app layer's group fan-out (edits, forwards) through
  Conversations.refuseUnlessItFitsEveryMember.
- A Padding.Failure inside the per-device loop (and in the unsealed retry) is
  rethrown, not filed in Sent.failed: a group where every device failed that
  way read as "not delivered yet". Nothing for that recipient is posted.
- Edits and forwards that are refused for length now say so on screen; the
  edit puts the old wording back as before.

AGroupSendIsRefusedWholeTest (5): RED before the fix on three (the classic
member received the text; WrongLength came back as Sent.failed; the caption
case uploaded first). Falsified after: no preflight, rethrow removed, and the
pre-upload check removed each go red on their own test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 4974259e30 Per-device digests (SPEC 17.6, DA4a); a capped poll follows more (8.5)
When the relay descriptor says features.perDeviceDigests, this device
publishes its delivery-key digest once more, once per relay host, with a
vault flag set only when the relay accepts it, so a failure is tried again
on the next poll. Without it a device whose digest was overwritten under
the old one-per-account rule would never publish again, and contacts
holding its key would keep falling back to unsealed sending. No delivery
key yet means nothing to republish and no descriptor request. Roster tags
carry device_id on such a relay, so a set replaces this device's own and
not the account's; a change of relay behaviour alone republishes them.

A poll the relay cut short at its byte cap ("more": true, read strictly)
is followed at once from the new cursor without holding, up to 16 pages,
on the home relay and every extra mailbox. Padding makes the cap far
more likely to bite. Same bound as iOS.

Tests: PerDeviceDigestsAndPollMoreTest (9): republished once and only when
the relay says so; a failed republish retried; device_id only when asked
for; features read strictly; a page with more followed at once; a page
without more is the whole inbox; a relay that always says more cannot hold
the poll; only a JSON true counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 349bfb8b6d Own-device traffic to linked devices only (SPEC 17.3, A1)
Everything this device sent to its own account went to every device on
it, so a device signed in by anybody holding the password received story
copies, group names and keys, and the offer to take over a call.

The vault now keeps a linked-device set: device id to the base64 IK_sig it
was linked under. It is sealed like the rest of the vault and never
exported in a backup. It starts empty, and nothing in this release adds to
it: linking is stage A2. A send to this account goes only to devices in
the set, matched on id AND key, so a relay listing a linked id under
another key is not believed. The key is checked twice: against the
directory before anything is fetched, and again in guardIdentity on the
bundle the session is actually opened with, because the key route could
serve the same id under another key; that mismatch is refused. With no
linked device nothing is fetched, so no unlinked device's prekey is spent.

The one exception is by design and matches iOS: a call end saying
answered-elsewhere or declined-elsewhere still goes to every device of the
account, because the caller rang them all. Receiving, a HANDOFF or
HANDOFF_TAKEN from an own device that is not linked is dropped (SPEC 6.30).

Consequence until A2 ships, as on iOS: call handoff, story copies and group
names no longer reach a second device of the same account.

Tests: OwnDeviceTrafficGoesOnlyToLinkedDevicesTest (9): none linked
reaches nobody and fetches no bundle; the linked device and not the
password one; a linked id under another key in the directory, and on the
key route; the stop-ringing signal to every device; any other call signal
and group names linked-only; a handoff from an unlinked own device dropped,
from a linked one delivered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 c3cd820f79 Trust: a contact's added device is a notice, not a refusal (SPEC 17.3, DA2)
Trust.record refused unless every offered key was already pinned, so a
contact who signed in on a new laptop stopped every Android user writing to
them until each one tapped "Accept new keys", and their messages from the
new device were held. That is the live bug; iOS has always told the cases
apart.

Trust.classify is the iOS table. A new key and none missing is
DEVICE_ADDED: pinned at once, the verified mark cleared (the numbers
compared did not cover this device), shown as a notice in the identity
panel, never refused. A key missing and none new re-pins the smaller set
and keeps the verified mark. A new key AND a missing one is a substitution
and still CHANGED, refused until accepted, with the pinned keys kept.

The receive side used to call record with the one key on the envelope,
which reads as every other device removed. Trust.recordInbound takes a
pinned key as it is, and for an unpinned one asks the directory (no
prekey spent) and classifies the whole set it lists, exactly as a send
would. A key the directory does not list, or a directory that cannot be
read, is refused: unreachable is not agreement. One pinned device writing
no longer shrinks the pinned set.

Envelopes held while a sender was refused are replayed by the next poll
once that sender is no longer CHANGED or UNKNOWN, so a contact held under
the old rule for adding a device gets their messages back without a tap.
A failed replay is logged and never costs the poll's other messages.

The added flag is persisted in the vault's trust record (written only when
set, so records from 0.31 read unchanged) and answered by the next send
that finds the same keys, or by marking them verified.

Tests: AnAddedDeviceIsANoticeTest (15), and TrustTest's "extra unseen key"
case now expects DEVICE_ADDED, with the substitution case split out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 b22dbf9873 Client: every send padded, refused visibly above the top step (SPEC 23.7, 23.9)
Both wrap points pad now. Unsealed envelopes go out as
Envelope.paddedPayload (pad beside ct), sealed ones through
SealedSender.sealedPayload, and the federated seal to an address is padded
the same way. That covers text, control, group fan-out, delivery-key
preludes, call signalling, own-device copies and federated sends, each
envelope on its own. A payload that misses its ladder step throws and is
never sent; nothing falls back to unpadded.

Everything for one device (preludes and the message) is built and padded
before any of it is queued or the session saved, so a payload that fails
its length check cannot leave a prelude queued on a session that was then
not saved. The federated path now saves the session after the payload is
built, for the same reason.

A message too long for the top step is refused before anything is spent:
for a body over Padding.ALWAYS_FITS_PLAINTEXT_BYTES each device's envelope is
built once on an unsaved session and measured in the sealed form, before
any token is taken, batch minted or ratchet step saved. The person sees
"Too long to send as a message. Send it as a file." The composer shows
how much is left in the last thousand characters, counted as the wire
counts them (Padding.composerCost), and disables Send past the limit.
Same words and limit (20,000) as iOS.

Attachments, stories and avatars are sealed with clen and padded only when
the relay descriptor carries features.padAttachments as a JSON true.
Absent, false, a string, or a descriptor that cannot be read all mean off.
The switches are read at most once an hour, and a failed read is retried
after a minute rather than once per envelope.

interop: seal_padded, attachment_pad and attachment_open, so the reference
engine can check this side's padded output byte for byte.

Tests: EverySendIsPaddedTest (8): every payload of an unsealed first send
and of a sealed send is a ladder length; a long message takes the larger
step; over the top step is refused with no delivery token, batch or session
step spent; just under the limit is sent; the attachment switch off, on,
and read strictly. SendToAddressTest asserts the federated payload is a
ladder length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 aeb52e8876 Engine: pad payloads, attachments and rosters to a ladder (SPEC 23)
engine/Padding.kt holds section 23's arithmetic, the same as iOS
Padding.swift and the reference engine: the message ladder {16384, 65536},
targets derived from it, the plan, a pad drawn uniformly from [A-Za-z0-9]
by rejection sampling, and the length check on the bytes after the fact.
A payload that misses its step throws WrongLength and is never sent; one
that does not fit the top step throws TooLong, "Too long to send as a
message. Send it as a file."

SealedSender.sealedPayload pads the inner JSON and Blob.toPayload now
writes the canonical outer form by template. Envelope.wireJson and the
seal's inner JSON use one writer that never escapes `/` and keeps one key
order, so the vector's sealed payload comes out byte for byte (org.json on
a phone escapes every `/`; on the JVM it orders keys by hash).
Envelope.paddedPayload is the unsealed form.

Attachments: Ref carries clen (a clen that is not an integer is held as
-1 and refused before decrypting); open verifies the digest over the
whole blob, then opens the first clen bytes; seal(pad = true) adds a
random tail to the attachment ladder, for use only when the relay says
padAttachments. Over 10 MiB is refused either way. Sealed rosters pad to
8192 bytes or the next multiple. composerCost counts with
JSONObject.quote, the writer Content uses, because org.json writes
U+2000..U+20FF as six-byte escapes.

Tests: PaddingVectorTest asserts every case of padding-v1.json (pinned
flamenet-e2e 7036115, privacy/sweep-2026-10, NOT yet on origin) with the
sealed payload reproduced to the byte; PaddingPropertyTest walks plaintext
lengths 0 to 70,000 in four header shapes. CI: FLAMENET_PADDING_VECTORS
exported in unit-tests and vectors-head and added to the pre-flight file
gate; .e2e-ref bumped to 7036115 (vectors moved: padding-v1.json added,
backup-payload-v1.json gained `pending`, canonical unchanged). Ten
sabotages, each red, each restored and compared byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 69b81b8f6c Version 0.32, build 32
Start of the 0.32 release: padding, linked-device gating and the
added-device notice land on this branch. versionCode and versionName move
together, as the comment above them requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul Hitt 55801af2e8 Merge branch 'crossplat/android-cross-platform-fixes' into 'main'
Cross-platform fixes: reactions, edits, reply quotes, polls, blocking, lost sends

See merge request extant2000/flamenet-messenger-android!160
2026-10-09 12:14:23 +00:00
Paul HittandClaude Opus 5.5 cba942a64f Answer every request in the lost-send test, not a fixed four
The server-error case queued four 503 responses. MockWebServer's queue
blocks a request that arrives after the queue is empty, so a send making
one request more than expected waits on the read timeout instead of
failing. The unit-tests job on MR 160 sat in :app:testReleaseUnitTest
for 29 minutes and was killed at the 30-minute limit; this is the one
test on the branch with a fixed-length queue. A dispatcher now answers
503 to every request.

The CI command, run locally with --rerun-tasks: app 711, engine 723,
client 480 (30 live skipped), obfs4 75, 0 failures, 2m 3s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:48:45 -04:00
Paul HittandClaude Opus 5.5 5b3d194d65 Keep a message the relay never took, on Android
The composer clears its draft the moment send is tapped, and Session.send
recorded the line only after the relay answered. A send that threw on the
way, no connection or a server error, left nothing behind: no bubble, no
outbox entry, no draft.

Found on the iPhone-to-Android pair with the local relay paused for ten
seconds: the Android message appeared on neither phone, ever.

A send that waiting can fix is now recorded as PENDING ("Undelivered, will
send") and queued, so the outbox sends it when the relay is back, the same
as a message to a peer with no device. Any other failure is recorded as
FAILED, so it stays visible, and rethrown so the error is still shown.
ASendTheRelayNeverSawIsKeptTest: 2 red before, 2 green after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:08:27 -04:00
Paul HittandClaude Opus 5.5 88906aacb4 Keep a blocked contact out of the inbox on Android
The relay refuses a blocked sender only on an identified send. A sealed
send is authorised by the recipient's delivery key and does not say who
sent it, so a blocked contact who already holds that key keeps getting
through. Revoking delivery tokens does not close it: a refused token
falls back to the delivery key by design. The recipient learns the sender
when it unseals, so the recipient has to refuse.

Found on the iPhone-to-Android pair: after the iPhone blocked the
emulator, the emulator's next message went sealed, the relay accepted it
(blocks row 7 -> 6 present) and the iPhone showed it. This app had no
such check either.

The blocked list is kept in the vault, written on every block and unblock
and whenever the relay's list is fetched, and absorb() drops messages and
call signals from anyone on it. ABlockedSenderStaysOutTest: 2 red before,
4 green after; engine Vault tests 75 green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:02:26 -04:00
Paul HittandClaude Opus 5.5 325adb4208 Move the poll card when the poll moves, on Android
Two faults left an open group's poll card where it was until the group
was reopened:

- The UI state held the PollBook's own entries. A vote, a piece or a sum
  changes an entry in place, so the state before and after compared
  equal and the StateFlow emitted nothing. Android's own vote looked like
  it did nothing.
- The tick that re-reads the cards sat inside if (threads.isNotEmpty()).
  Poll traffic touches no thread, so a poll asked from the iPhone, and
  every share and sum after it, never reached an open group.

Found on the iPhone-to-Android pair: the iPhone's poll and the Android
vote both showed only after leaving and reopening the group.

pollCards() hands the UI copies, and the refresh runs on every tick.
PollCardsMoveTest: 3 red before, 3 green after; other poll tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:02:20 -04:00
Paul HittandClaude Opus 5.5 b10ad0a21a Draw the quote on a reply on Android
A reply arrives with its re reference and the quoted words (preview), and
Session files them on Line.about. Nothing in ChatScreens read about, so
on Android a reply drew only its own words and looked like an unrelated
message. That covers replies from an iPhone and Android's own.

Found on the iPhone-to-Android pair against a local relay: the iPhone
showed the quote on Android's reply, Android showed none on the iPhone's.

ReplyQuote draws the preview above the reply's words, inside the bubble,
the way the iPhone does. AReplyShowsWhatItQuotesTest: 1 red before the
bubble called it, 6 green after; ScreenshotTest 16 green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:02:20 -04:00
Paul HittandClaude Opus 5.5 8a9de92289 Apply inbound reactions, edits and pins on Android
absorb() kept only isRenderable envelopes before its loop. isRenderable is
false for a reaction, an edit and a pin, so the branches that apply them
never ran: each one decrypted ("opened by current") and was dropped.

Found on the iPhone-to-Android pair against a local relay: the iPhone's
reaction and its edit both reached the emulator and neither showed.
Android-to-Android is affected the same way. The existing tests call
applyEdit and applyReaction directly, which is why they stayed green.

InboundControlReachesTheThreadTest drives absorb() with the bytes the
iPhone writes. 3 red before, 3 green after; 41 related tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:02:20 -04:00
Paul HittandClaude Opus 5.5 df1f0b414e CI: pin flamenet-e2e at the 0.32 spec and hand the new loaders their vectors
.e2e-ref moves to d07ed14 (docs/spec-0-32). Between the old pin and
this one the only existing file that changed is backup-payload-v1.json,
which gains cards, events and games; the rest are new.

unit-tests and vectors-head export FLAMENET_CONTENT_KINDS_VECTORS,
FLAMENET_TODAY_VECTORS and FLAMENET_HELLO_LINK_VECTORS, and the
pre-flight check names them. vectors-head also runs the three loader
classes, which live in :client and :app, not :engine.

The pinned commit has to be on the remote before this pipeline runs, so
the spec branch is pushed first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:01:07 -04:00
Paul HittandClaude Opus 5.5 8281727b5b Today: a saved page says "From <date>" at the top, and why under it
TEST-B finding 5, DESIGN 4. The header showed the plain date whether the
page was the relay's answer or the copy kept on the phone; the "From"
went into the line under it. A kept copy shown because the relay could
not be reached, or sent nothing newer, now reads "From <date>" in the
header and one plain line saying why, as on the iPhone. A page the relay
is serving now keeps the plain date and no line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:54:10 -04:00
Paul HittandClaude Opus 5.5 d1defcf65e Merge branch 'test/peer-0-32' into release/android-0-32
TEST-B's interop peer harness extended to every 0.32 kind, with a bot
mode (1d6de8b). It touches only :interop, not the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:54:40 -04:00
Paul HittandClaude Opus 5.5 3ec88c91ca Merge branch 'test/vectors-0-32' into release/android-0-32
TEST-A's loaders for content-kinds-v1, today-v1 and hello-link-v1 (4aa0d0f),
test code only. Two lines changed to fit the branch they land on: the
Today test no longer names the unsigned reason, which DESIGN 48 folded
into malformed, and it expects the 34 cases today-v1 holds since the
spec added "envelope version written as a string".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:54:35 -04:00
Paul HittandClaude Opus 5.5 db59a61d52 Open a stored account from the phone, and let the relay catch up after
DESIGN 47. A start with stored credentials waited on the relay for the
contact list and the groups before leaving the spinner: up to its call
timeout twice with the network down, and then without any groups, since
those came only from the relay. The chats now open from what is on the
phone, the stored contacts and the groups as last listed, which the
phone now keeps, and the relay's lists replace them when it answers.
The saved Today page already opened from disk. Sign-in still shows only
with no stored credentials.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:53:22 -04:00
Paul HittandClaude Opus 5.5 5d01528bcc Hello links: whitespace around a link is not part of it
The shared hello-link vectors trim whitespace around a link, as a
scanned code already was here. Space inside the fragment is still
refused; a newline after the whole link is now whitespace around it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:51:38 -04:00
Paul HittandClaude Opus 5.5 251a9553d8 Today: a missing part is malformed, as DESIGN 48 names the reasons
DESIGN 48. An envelope without v, kid, body or sig, or with an empty kid
or sig, is malformed; Android called a missing signature unsigned and a
missing v an unknown version. A v that is there but not exactly 1 is
still an unknown version, and a body without v is a bad body. The
unsigned reason is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:51:38 -04:00
Paul HittandClaude Opus 5.5 1d6de8ba72 Shell peer: send and read the 0.32 kinds, and a bot mode
Test harness only. Cards, events and answers, games (replayed with the
games engine), photo stories with edits and take-downs, stickers and
directory shares, all built with the engine's own encoders. The bot polls,
logs every envelope it opens, and runs commands dropped in a folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:40:01 -04:00
Paul HittandClaude Opus 5.5 4aa0d0f237 Test: the 0.32 shared vectors, loaded by the app's own code
Loader tests for the three vector files flamenet-e2e docs/spec-0-32 adds,
found through FLAMENET_CONTENT_KINDS_VECTORS, FLAMENET_TODAY_VECTORS and
FLAMENET_HELLO_LINK_VECTORS, or in a flamenet-e2e checkout beside this one:

- content kinds (app module): every written envelope parsed and written
  back by the engine's encoders, byte for byte; every envelope through
  Content.parse and the session's own gates (control traffic, and a move
  or an ending through ChatGames.receive), checked field by field.
- Today (client module): TodayFile.verify on the sample, every case and
  the 512 KB edge; the sequence and If-None-Match through TodayFeed over
  a MockWebServer on this machine.
- hello links (app module): the link cases through HelloLink.fromLink, as
  openHelloLink calls it, and the scan cases through prefillFor.

Test files only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:30:57 -04:00
Paul HittandClaude Opus 5.5 067e6fcf51 PARITY: which envelopes go silently
A row for edits, reactions, pins and story reactions, which go with the
relay's silent flag and never notify on Android, with where iOS stands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:26:37 -04:00
Paul HittandClaude Opus 5.5 02c517aace The stories feed's secondary text reads in the dark theme
Owner-approved for 0.32. The feed's secondary lines, its tally, its
Mute and Report, its composer notes and its empty-state icon used the
palette's fixed grey, 1.7:1 on the dark surface. They now take the
theme's onSurfaceVariant, as cards, events and polls do; in the light
theme it is the same grey.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:26:24 -04:00
Paul HittandClaude Opus 5.5 bac69a752a Pin which envelopes wake the other phone and which go silently
Every kind Android sends, named with the relay's silent flag it goes
with. Edits, reactions, pins and story reactions already went silently
and never notified here, which the owner has now asked of both apps;
the test keeps it so, and makes a new kind a decision. The rule moved
to the companion so it can be tested for what it decides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:25:40 -04:00
Paul HittandClaude Opus 5.5 ff98518046 Queue a photo, voice note or file that reached nobody, so it leaves pending
Owner-approved for 0.32. A photo, voice note or file to one person, and a
file to a group, was recorded as pending when it reached nobody and never
queued, so it stayed pending for good. Each is now uploaded once and sent
as its reference: to one person the way a text is, queued when nobody
has a device, and to a group through the fan-out a group photo uses. The
bubble turns delivered once the queued copy goes. The group attachment
send that nothing calls any more is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:22:44 -04:00
Paul HittandClaude Opus 5.5 8570efb43c Merge branch 'feature/0-32-stories' into release/android-0-32
Stories (and-a, 6dff1f0): incoming edits, pins and reactions applied
from the whole batch; taking a story down and editing its words (§6.35);
a feed card that shows the whole picture.

Both sides kept where they meet: each session is wired with the sealing
policy, the leftover-device retirement, the Today clearing on a wipe and
the attachment cache, in the view model and in the background service.
The Today test that read the one-line wiring now reads each wiring
block. Tic-tac-toe still never turns, and the round 6 and 7 game and ctl
rules stand; the stories branch did not touch either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:19:14 -04:00
Paul HittandClaude Opus 5.5 6928df1405 Drop a Today answer that comes back after sign-out or a wipe
DESIGN 32. A fetch still on its way when the phone was cleared wrote the
file and its state back when it returned, and an open tab showed it. A
feed now belongs to the clear it was made in: one made before a clear
asks nothing and writes nothing, an answer that returns after one is
dropped, and the tab shows nothing from a request that outlived the
clear, as on the iPhone. The count moves before the files go, so a fetch
sees the clear first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:39:06 -04:00
Paul HittandClaude Opus 5.5 6dff1f0064 Show a story's whole picture, and take it down or edit it from the feed
The feed card cropped every picture to a 240dp band, which is how the
top of a story went missing. A card now takes the picture's own shape,
from two wide by one high to four wide by five high, never taller than
560dp (the iPhone's numbers), and fits the picture whole inside it with
plain bars in the theme's colour beside a tall one or above and below a
wide one. Videos and streams sit in the same frame. The composer draws
the picked picture through the same frame, so it looks exactly as it
will in friends' feeds before Post.

The upload never cropped: pictures are only turned upright, scaled down
whole and re-encoded. The strip on this phone draws initials, not
pictures, so it has no thumbnail to crop.

Under my own story, in one row: Edit caption (Edit words for a text
story) and Take down. Take down asks first, in DESIGN item 25's words.
The editor will not empty a story and offers Take down instead. A
story whose words changed says Edited beside its time left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:28:18 -04:00
Paul HittandClaude Opus 5.5 c0d88e85ec Take a story down for everyone, and change its words (§6.35)
DESIGN items 25, 26, 33 and 44, with the iPhone's bytes:

  {"fnc":1,"kind":"unpost","ctl":1,"st":1,"sid":"<story id>"}
  {"fnc":1,"kind":"unpost","ctl":1,"st":1,"set":"<set.id>"}
  {"fnc":1,"kind":"edit","st":1,"caption":"<new words>",
   "re":{"mid":"<story id>","author":0,"preview":""}}

The story id is the one §6.20 replies already use here: a picture's
attachment id, a text story's mid.

Taking a story down removes this phone's copy at once, from the strip
and the archive, with its reactions and any decrypted bytes, then tells
everyone it went to: the people recorded when it was posted, now kept
on each story, or for one posted before that or restored from a backup,
a group story's roster or today's story audience, and the change says
which. A set comes down whole by its id. This account's other phones
are told too. Both kinds are silent control traffic and never queued.

A receiver applies either only to a story it holds from the envelope's
sender. An unknown id, a take-down naming both a story and a set or
neither, an edit that would empty a story, and an older edit after a
newer one (by the relay's time) all change nothing. None is drawn and
none notifies. A story's edited time travels in backups; who it was
sent to stays on the phone.

The relay has no route to delete a blob, so a taken-down picture's blob
waits for the retention purge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:28:09 -04:00
Paul HittandClaude Opus 5.5 f9519a299e PARITY: what 0.32 adds
One section for stickers, cards, events, games, the Today tab, the
directory and hello links, with the DESIGN item and the iOS file behind
each row, and the sends that can still stay pending for good.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:19:45 -04:00
Paul HittandClaude Opus 5.5 06af4d3a92 Cards, events and games keep the chat's timer, not one of their own
DESIGN 46. A secs on a card or an event set that message's own timer.
It is ignored on cards, events and games in 0.32, so they follow the
chat's disappearing timer like the rest of the conversation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:19:45 -04:00
Paul HittandClaude Opus 5.5 f5ec45667a An event runs one to thirty-one days
DESIGN 45. days above 31 is read as 31 and below 1 as a single day, and
the event composer holds what it writes to the same range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:19:45 -04:00
Paul HittandClaude Opus 5.5 39bee6b021 A start or a move that carries r is still a start or a move
DESIGN 44. The game object refused an r that was not a known ending
whatever the kind, so a gmove carrying one was dropped and a start fell
through to the placeholder. Only an ending reads r now; on a start or a
move it is ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:19:45 -04:00
Paul HittandClaude Opus 5.5 d2e0b16914 Any ctl but 0, false or null makes an envelope control
DESIGN 43. ctl was read as a boolean or as a whole number, so a string
"0", a fraction or an object let a control envelope through as a bubble.
Carrying ctl now means any value but 0, false or null; "1" and true still
count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:19:45 -04:00
Paul HittandClaude Opus 5.5 3dcfca51c9 Today: figures, notes, country codes and zones keep to their bounds
DESIGN 42. A market's sym, last, chg and pct are cut at 24 characters,
and at most five notes are kept. cc is two ASCII letters or is ignored,
tz is ignored past 64 characters, and a zone this phone does not know
shows a city's days as the file gives them, where it used the phone's own
zone to drop days already gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:19:45 -04:00
Paul HittandClaude Opus 5.5 2b0d911bc5 Hello links: an ASCII host, no spaces, and a fragment of 400 at most
DESIGN 41. A host is checked as ASCII before it is lowercased: Java
lowercases a Kelvin sign to an ASCII k, so a bare or scanned address
holding one was folded into a different host. A fragment over 400
characters is not read, as the relay's /hi page caps it. A typed or
pasted address gets the same host rule. The app link with a slash after
hi, and spaces in a scanned address, were already refused; the test
holds both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:19:45 -04:00
Paul HittandClaude Opus 5.5 8ad68bc379 Today: v must be written as the integer 1
DESIGN 38. The envelope's and the body's v went through a number check
that took 1.0 and 1e0, and Android's own parser reads 01 as 1. Both are
now judged on the token as written, so only 1 passes, as on the iPhone
and the relay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:57:53 -04:00
Paul HittandClaude Opus 5.5 183e9522fb Today: a market needs a name and a price, not a symbol
DESIGN 38. A market entry without sym was dropped. It is now kept; the
row never showed the symbol, so it draws as before. A name and a
non-empty last are still required, as on the iPhone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:57:53 -04:00
Paul HittandClaude Opus 5.5 81c816e85d Drop a game envelope with no log, and judge a draw accept at the whole log
DESIGN 37. A gmove with no log or an empty one, and a gend with no log,
made a board out of nothing or ended one at no position; they are now
dropped. A draw-accept counts only when it carries this phone's whole log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:57:53 -04:00
Paul HittandClaude Opus 5.5 d638846fd3 An event takeover never notifies, and an id needs only to be there
DESIGN 36. A rev 1 from somebody other than the stand-in organizer took
over as a revision and notified "<old title> changed". It is now settled:
the card is redrawn and nothing is said. Event and game ids are taken
when they are not empty, a space included, on the wire and off a history
row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:57:53 -04:00
Paul HittandClaude Opus 5.5 a3f64ee651 Hello links: the path is exactly /hi or /es/hi
DESIGN 35. The part before the # was lowercased whole, so /HI was taken,
and the app link allowed a trailing slash. Scheme and host still match in
any case; the path must be /hi or /es/hi exactly, with no trailing slash
and no query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:57:53 -04:00
Paul HittandClaude Opus 5.5 68f813fa55 Cite the Today file as SPEC §8.12
DESIGN 34. Section 8.11 is push-token registration. Every 0.32 citation of
it for the Today file now reads §8.12, and a test keeps it that way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:57:52 -04:00
Paul HittandClaude Opus 5.5 bcd49b27e9 Card, event and poll secondary text reads in the dark theme
Owner-approved for 0.32. Their secondary lines used the palette's fixed
grey, 1.7:1 on the dark surface. They now take the theme's
onSurfaceVariant, as the game card does, which is the same grey in the
light theme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:57:52 -04:00
Paul HittandClaude Opus 5.5 c21158c508 Queue a group photo that reached nobody, so it leaves pending
Owner-approved for 0.32. A group photo was recorded as pending and never
queued, so it stayed pending for good. It now uploads once and goes
through the fan-out group stickers and cards use, which queues the
members nothing reached with the group thread; the bubble turns delivered
once the last of them has it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:57:52 -04:00
Paul HittandClaude Opus 5.5 f336a67c47 A snoozed chat holds every notification, not only a game's
Owner-approved for 0.32. Only a game's news waited for a snooze to end;
an ordinary message in a snoozed chat still rang. NotificationSettings.due
now leaves snoozed chats out of a poll's notification for every kind, and
what arrives still counts as unread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:57:51 -04:00
Paul HittandClaude Opus 5.5 38d1f0b444 A game invitation is said out loud and counts as unread; a move does not
DESIGN 29. A new game from the other side notifies "Sam invited you to
play chess" with a sound and marks the chat unread. Moves, draw offers
and endings redraw the board and are said through Session.takeGameNews
without marking anything unread, not even a move that had to rebuild a
lost board, as on the iPhone. An ending keeps its sound; a move and an
offer come without one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:57:51 -04:00
Paul HittandClaude Opus 5.5 d9f71e0f58 Sign-out and a wipe clear the Today file and the weather picks
DESIGN 32. TodayData.forget deletes the cached file and the city picks,
and an open Today tab lets go of what it held. Sign-out, the two other
ways out of an account and a lost-device wipe all call it; the wipe
reaches it through Session.onWiped, which the view model and the
background service both set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:57:51 -04:00
Paul HittandClaude Opus 5.5 0bbefb4de3 Tic-tac-toe keeps its cells in place for the second player
DESIGN 30. Chess, checkers and reversi turn the board round for whoever
plays second. Tic-tac-toe has no side to face, so its cells now keep their
places on both phones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:57:51 -04:00
Paul HittandClaude Opus 5.5 dccb67bab0 Parental controls hide the Today tab and the directory
DESIGN 31. With supervision on, the bar has no Today tab, and a Today
screen left open shows the signed-in home screen instead. The parental
controls copy says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:57:50 -04:00
Paul HittandClaude Opus 5.5 30c1f8fc13 Every scanner binds by fnid, and a card's ?u= is ignored
DESIGN 32. The Messages scanner filled the add screen with the handle an
older card named for this relay, which a card could claim for anybody.
Both scanners now fill in fnid@relay through prefillFor, as Home's did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:57:50 -04:00
Paul HittandClaude Opus 5.5 cf13ae085f Apply incoming edits, pins and reactions, which a poll dropped
Session.absorb handled edits (§6.15), pins (§6.19), reactions (§6.9)
and story reactions (§6.22) inside its loop over renderable messages,
and none of them is renderable, so every one that arrived was dropped:
no reaction, edit or pin from anybody else ever showed on Android. The
tests that existed called applyEdit and the like directly, which is why
they passed.

They are now applied from the whole batch before the renderable filter,
as answers and game moves already are. Each lands only on what it names,
in a group only from one of its members, and none is ever drawn as a
bubble. None is news either, as each of those sections requires: the
thread is redrawn, never marked unread, and nothing notifies. A pin
moves the open chat's banner when it is redrawn.

The test goes through absorb for each of the four, and every one of
them failed before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:55:57 -04:00
Paul HittandClaude Opus 5.5 cee5d294bd Version 0.32, build 32
Stickers in FlameNet's art, cards, events and answers, games in one-to-one
chats, the Today tab, the small-web directory and hello links.
versionCode and versionName move together, as the comment above them
requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:00:56 -04:00
Paul HittandClaude Opus 5.5 036d901b25 Merge branch 'feature/0-32-today-directory-hello' into release/android-0-32
The Today tab, the small-web directory with sends to a chat, hello links,
and Home's scanner filling in the address it read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:00:36 -04:00
Paul HittandClaude Opus 5.5 36ba1dc30d Merge branch 'feature/0-32-portal-kinds' into release/android-0-32
Stickers in FlameNet's art, cards, events and answers, games in one-to-one
chats, one-to-one backups keyed by user id, and link previews on incoming
messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:00:31 -04:00
Paul HittandClaude Opus 5.5 39c2a4a78d Test each half of the Today ETag rule on its own
The rule (DESIGN 19) has two checks: a relay's tag is kept only when it is
the SHA-256 of the file it came with, and If-None-Match goes out only when
the stored tag is that hash of the copy held. Breaking either one alone left
every test green, because the other still held the behaviour. Now a test
watches each: a foreign tag is not stored, and a foreign tag already on disk
is not sent. Each was seen to fail with its half removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:27:36 -04:00
Paul HittandClaude Opus 5.5 080b3f6e18 Show link previews on incoming messages
The sender's link preview (§6.12) arrived inside the message and was
never handed to the history store, so no incoming message has shown one
since dc1be35 added them. Directory shares (DESIGN item 18) are sent as
exactly such a preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:18:15 -04:00
Paul HittandClaude Opus 5.5 cb36e95dbc Hello links on the address card, in the scanners and from a link
The address card shows the hello link (SPEC 6.34) with Copy and
Share, says what it does, and its QR code holds it.
flamenetmessenger://hi#<address> opens the app and fills in Home's add
screen; nothing is looked up or sent until the person chooses to. Both
scanners read a hello link, an older card or a bare address, and say
so when a code holds none. Parsing follows DESIGN 21: the known link
forms only, the fragment percent-decoded once, fnid: and 52 letters of
the FNID alphabet, a lowercased host with a dot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:57:48 -04:00
Paul HittandClaude Opus 5.5 c38dbe528b Add the Today tab beside Messages
Headlines, Markets ("Prices are delayed. Not advice."), Weather for
the cities picked on the phone, Site of the day with the file's own
note, the blog when there is a post, and the way into the directory.
The tab asks the relay when it opens and when the app comes back to
it; offline it shows the cached copy with "From <date>", and a 404
shows only the directory and "Today's page is not offered by this
relay." Links open in the browser on a tap, with the host shown.

The dev key (kid a9a94e74) is compiled into debug builds only, from
src/debug; release has its own empty copy, and TodayKeys.PRODUCTION is
empty until the release step. Eight tabs fit a 360dp phone with the
labels one size smaller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:57:48 -04:00
Paul HittandClaude Opus 5.5 04ef16c423 Ship the small-web directory and send a site to a chat
directory-v1.json (DESIGN 5) ships as an asset: 2,964 sites in 90
categories, browsed by category and searched on the phone with no
network. A site sent to a chat is an ordinary text through the 1:1
send path, the URL as the words and an lnk built from the entry: the
title, and desc present and empty (DESIGN 18). Nothing is fetched to
make it, and with link previews off the URL goes alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:57:47 -04:00
Paul HittandClaude Opus 5.5 cb44ee6604 Read the Today file from the home relay, verified and cached
TodayFile checks a Today envelope (SPEC 8.11) before reading a field
of it: size, version, a pinned kid, the Ed25519 signature over the body
bytes, then a body with a real date and a made time. Lists are cut to
their bounds, strings are cut on character boundaries, and a link that
is not http or https drops its entry.

TodayFeed asks the home relay for /today.json through the default
client, so a bridge carries it: no Authorization, cookie, id or query,
and If-None-Match only when it is the hash of the copy held (DESIGN
19). At most once an hour from the relay's last answer, five minutes
after a failed connection. Redirects are not followed. The last good
envelope is kept on disk and re-verified when read; a 404 clears it,
and an older signed file never replaces a newer one.

The signed sample flamenet.io's builder made is a test fixture, and
LiveTodayTest checks a relay on this machine when FN_LIVE_TODAY_RELAY
names one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:57:47 -04:00
Paul HittandClaude Opus 5.5 511607062a Fill Home's add screen with the scanned address, not template text
Scanning a code on Home filled the add field with the literal text
${card.fnid}@${card.relay}: the line in 130edd7 was written with ${'$'}
escapes, so the template was never evaluated. Every scan on Home gave
the same unusable string.

homePrefill() turns a scanned code into the address, bound by the fnid
as the audit asked, and Home uses it. The test pins both the address
and the absence of the escaped template in MainActivity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:57:47 -04:00
Paul HittandClaude Opus 5.5 c62afdc152 Back up one-to-one threads under the user id, as the iPhone does
A one-to-one thread went into the shared backup under this app's own
name for it, "user:7", and came back under whatever key the file had.
The iPhone keys these threads "7" and skips any key that is not a
number, so every one-to-one conversation was lost when a backup moved
between the two phones, in either direction: the iPhone dropped
Android's threads, and Android filed the iPhone's under "7", where
nothing reads them.

The prefix now comes off on the way out and goes back on for a numeric
key on the way in. A file written before this, keyed "user:7", still
restores into the same thread. The test goes through the vault's own
export and import, where the payload-only checks passed all along; the
older tests that used a bare id as this app's thread name now use the
real one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:54:01 -04:00
Paul HittandClaude Opus 5.5 b94480cfc3 Game boards in the chat, started from the attach row
"Game" sits in the attach row of a one-to-one chat only: pick chess,
checkers, reversi or tic-tac-toe, and who goes first. The card shows the
board from the engine's own board, row 0 at the top for the first player
and turned round for the second, with whose move it is.

Tap a piece and its targets are marked; tap one to send the move. A
checkers jump is entered square by square, the path marked as it goes,
and sent as one move once complete; a chess promotion asks for the
piece; reversi marks its legal squares and offers a lone Pass when that
is the only move; tic-tac-toe takes the tapped cell. Whatever is sent is
exactly a move the engine offered.

Under the board: decline or cancel before play starts, resign after a
confirmation, offer a draw once it is the other person's move, accept or
decline theirs, in one row; the result in words once it is over; "Your
boards disagree" with "Start a new game" (same game, same sides) when
out of step; and the moves, folded away. Plain colours that read in both
themes, and every square labelled with its name and what stands on it.
A move or an ending that did not reach them says so and changes nothing.

The attach row now scrolls sideways instead of squeezing five buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:53:53 -04:00
Paul HittandClaude Opus 5.5 eb9b7647b3 Play games in one-to-one chats: the rules, sending, receiving and notices
A game lives on its start message's history row as the log alone, so it
follows that row through off the record (memory only), delete, the
disappearing timer and the backup. Everything else is replayed from the
log with the capped engine replay.

Receiving follows §6.33 rules 1 to 4: an unknown game is rebuilt from
the envelope; a stale or duplicate log changes nothing; a log that
disagrees, a peer adding a ply for this person's side, an illegal or
malformed move, or a log over the cap marks the game out of step, which
is sticky. Endings follow DESIGN items 16, 22 and 23: an ending is judged
at the log its gend carries, once that log is in step; a draw offer
counts only from the side that has just moved, at the receiver's whole
log, and the next move lapses it; decline and cancel count only until
the invitee's first move in that log; a decline for a game this phone
never saw rebuilds it as the game it started, declined.

Moves and endings go out announced, so the other phone wakes (item 15),
and are never queued: one that reaches nobody, a contact with no device
included, is taken back (item 24). The start is queued like a card.
Their move notifies without a sound, "Your move: chess with Sam"; an
ending says "<Game> with <name>: <result>"; a draw offer "<name> offers
a draw: <Game>"; a refused offer says nothing; a snoozed chat holds them
all. A game from another server, or from this account's own other
device, is never a board.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:53:43 -04:00
Paul HittandClaude Opus 5.5 582006285d Games on the wire and in backups (§6.33)
Content reads and writes `game`, `gmove` and `gend` exactly as §6.33
shows them: `mine` is the side the sender of that envelope plays, `log`
the whole game so far, `r` one of the six endings. A start is drawn as a
board in a one-to-one chat only; a game in a group, or one this build
cannot read, shows the unsupported placeholder. A move or an ending is
control traffic and never a bubble, with or without `ctl`.

A backup carries the game on its start message as
{"id","t","me","log","r","rby"} (DESIGN items 12 and 14). The out-of-step
flag and an open draw offer stay on the phone, each listed with its
reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:53:21 -04:00
Paul HittandClaude Opus 5.5 825903e0c4 Run the games engine's tests in CI
`:games:test` joins the unit job's task list, the results gate counts
the module with a floor of 13 of its 15 tests, and its build directory
is kept out of Spotlight like the other modules'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:53:07 -04:00
Paul HittandClaude Opus 5.5 5385cd2adc Merge the games engine, :games, from feature/0-32-games-engine
Brings in 597dd804: the chess, checkers, reversi and tic-tac-toe rules
engines the chat games are built on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 07:41:30 -04:00
Paul HittandClaude Opus 5.5 dea4669ced Follow the settled rules for answers and cancels, and cite the spec
DESIGN items 7 to 13, as both apps now do:
- An answer's n is the unix second it was given, or one past this
  device's last answer if that was later. Receivers keep the highest n,
  and on an equal n the answer that sorts higher wins (yes over no over
  maybe), so one person answering on two devices in one second leaves
  every phone with the same answer. Held answers use the same rule.
- A cancel notifies "<title> was cancelled" and the card reads
  "Cancelled: <title>".
- An event id that is only spaces is malformed, as on the iPhone.
Comments and test names cite §6.31 (cards), §6.32 (events and answers),
§17.2 and docs/STICKERS.md instead of DESIGN.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 07:41:24 -04:00
Paul HittandClaude Opus 5.5 597dd8042b Add :games, the rules engines for games in chats
A plain JVM module set up like :engine: chess, checkers, reversi and
tic-tac-toe as DESIGN.md section 2 describes them, with the same API as
the iOS FlamenetGames package. Moves are strings in the wire notation and
apply accepts exactly the strings legalMoves returns. Replay refuses a log
over the game's cap before playing it and names the index of the first
move it cannot play.

The tests run games-v1.json, byte for byte the file the iOS package runs:
the perft counts from DESIGN.md and 147 cases. Deeper published perft
counts run with FLAMENET_GAMES_DEEP=1.

Nothing in :app uses it yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:02:32 -04:00
Paul HittandClaude Opus 5.5 f368e69db9 Write emoji and invisible characters in the new code as escapes
Two tests carried a narrow no-break space, a no-break space and a line
separator as literal characters, which read as ordinary spaces. They and
the emoji in the new tests and the sticker fallback are now \u escapes,
as the existing tests write them. The strings are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:01:21 -04:00
Paul HittandClaude Opus 5.5 78ea0ae11b Cards and events in conversations, with answers
DESIGN 1b and 3 in the app, in one-to-one chats and groups.

Cards: the attach panel offers a card with one of the nine flamenet.io
fronts (shipped as assets/cards, titles drawn as text), to, message,
from, and an optional day it stays sealed until, at most 400 days ahead.
The receiver sees the front and "Opens on <date>" until then, and the
card opens by itself when the day comes; the sender's copy is open and
says whom it is sealed for. Copy, report and forward never hand over a
card still sealed here, and a forward goes out unsealed.

Events: title, a time or all day, an optional end, place and note. The
card shows when it is in the reader's zone, the organizer's time when
their clock reads differently, who answered what, Going / Maybe / Can't
go in one row with "Everyone in this chat sees your answer.", and Add to
calendar through the system's own insert sheet. Only the organizer is
offered Change event and Cancel event. Revisions go out under a fresh
mid and fold into the card the event first arrived on.

Arrivals: answers fold onto their event and only redraw it, never
marking a chat unread or notifying; one that comes before its event is
held in memory until the event lands. A card or event from this
account's own other device is kept only when it names a group. With
previews on, a card or an event that arrives alone is announced as
DESIGN words it, and never with a card's words.

Nothing new is written for an off-the-record chat: every change goes
through record and mutateHistory. Rows carry card, ev, org and rsvp, and
HistoryRowsCarryEveryFieldTest reads record() and Session.Line from
source and fails until a new field is carried by the backup, listed in
BackupPayload.NOT_CARRIED, and read back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:00:17 -04:00
Paul HittandClaude Opus 5.5 ec0c40bfd2 Delete for me works off the record and writes nothing
Session.deleteMessage went straight to the vault. For an off-the-record
thread the line is held in memory, so the delete found nothing, left the
line on screen, and rewrote the thread's history record, empty, to disk:
a record naming the person for a conversation §6.28 says is never
written down. It now edits the thread where it lives, through
mutateHistory, like edits and reactions. Cards and events go through the
same delete, which is how this turned up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:57:54 -04:00
Paul HittandClaude Opus 5.5 d69ddab363 Load every sticker pack and ship the FlameNet art
DESIGN 1a: every manifest under assets/stickers is a pack, the glyph set
first and then flamenet.retro, and the picker shows them as sections. A
sticker draws as its picture, then its glyph, then its pack's fallback,
and a pack this build lacks draws as the core fallback. A manifest's
image may only name a file in its own folder. Sending passes the pack
that was picked instead of always the core one.

The 24 retro PNGs and their manifest are the ART agent's output, byte
for byte the files in the iOS worktree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:57:32 -04:00
Paul HittandClaude Opus 5.5 bfb1f058ca Refuse an answer counted below 1, as the iPhone does
The iPhone reads an rsvp only when n is at least 1, so Android now does
too and both drop the same envelopes. The reason given for leaving rxp
out of the backup now says what the iPhone does with private reactions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:56:39 -04:00
Paul HittandClaude Opus 5.5 3f0e5e3634 Carry cards and events through the shared backup
A card travels as its wire object and an event as its latest accepted ev
with org and rsvp beside it, as agreed with the iOS build. Row keys left
out of the shared shape are now listed in BackupPayload.NOT_CARRIED with
a reason: the provisional organizer mark, and rxp, which was already
being dropped without one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:28:31 -04:00
Paul HittandClaude Opus 5.5 7e232a8055 Fold event revisions and answers by the rules in DESIGN 3
The highest rev from the organizer replaces the whole event and anything
else is ignored. The organizer is whoever sent rev 1; until rev 1 is
seen the first sender stands in, and a rev 1 from somebody else takes
over with its own content. Each member's answer with the highest count
wins. State maps onto a history row as ev, org and rsvp, the shape the
backup carries, plus a provisional mark that stays local.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:28:31 -04:00
Paul HittandClaude Opus 5.5 e001fa9d42 Read and write cards, events and RSVPs in the content codec
DESIGN 1b and 3 add three kinds: card and event are renderable and carry
mid like a text; rsvp is control traffic and never a bubble, with or
without its ctl. Each is built by hand with its keys in the order both
apps agreed, and strings go through a new WireJson.quote that writes
what Swift's JSONEncoder writes with slashes left alone. The framework
org.json escapes every slash and the JVM copy does not, so the existing
quote could not give one set of bytes.

Decoding follows the iPhone: a card object is read leniently, an event
needs an id, a rev of at least 1, a title and a time, and anything
unusable falls through to the unsupported placeholder. Flags are read as
numbers or booleans, whole numbers may carry a fraction, and render
bounds and the sealed-until rule (capped at 400 days from arrival) live
on the types so they can be tested here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:28:31 -04:00
Paul Hitt 446323bc36 Merge branch 'chore/move-to-extant2000-group' into 'main'
Point links at the extant2000 GitLab group

See merge request extant2000/flamenet-messenger-android!158
2026-09-28 19:08:31 +00:00
Paul HittandClaude Opus 5.5 41dbfeb19d Point links at the extant2000 GitLab group
The messenger repositories move from gitlab.com/paulhitt to
gitlab.com/extant2000. Old web and git URLs redirect; the relay's container
image path does not, so docker-compose.yml names the new registry path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:38:57 -04:00
Paul HittandClaude Opus 5.5 d53f6a81bb Merge branch 'fix/echo-peer-survives-errors' into 'main'
The echo peer outlives a failed call instead of exiting

See merge request paulhitt/flamenet-messenger-android!157

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:14:57 -04:00
Paul HittandClaude Opus 5.5 df56cf2e6e The echo peer outlives a failed call instead of exiting
Peer's autoreply loop had no catch, so one failed poll or send to the relay
ended the process. The on-device reply case then waited out its 90 seconds
for an echo from a peer that was gone, which fits the one missed echo seen
in five full runs on 2026-09-28 (its log was lost, so not proven).

Each poll and each echo is now caught, logged to stderr with a timestamp
("poll failed, still answering: ..."), and the loop carries on. The
closing JSON adds an `errors` count. instrumented.sh already prints the
echo peer's log when a run fails, so a recurrence will say why.

Checked against a relay address that refuses connections: the old peer
exits 1 on the first poll with no result; the new one runs its full 8 s,
logs 4 failed polls and prints {"echoed":0,"errors":4}. Against relay1: 0
errors. Full on-device suite with the new peer: 10 of 10.

Harness only: interop/ is not shipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:11:30 -04:00
Paul HittandClaude Opus 5.5 fdee288111 Merge branch 'fix/await-the-email-field' into 'main'
On-device suite green: sign-up tap, fresh peers, group id, and the chat scroll crash

See merge request paulhitt/flamenet-messenger-android!155

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 09:16:48 -04:00
Paul HittandClaude Opus 5.5 d33bb338de Scroll the chat by request, not by forcing a layout
The chat keeps its newest message in view when it opens and while the
keyboard slides, and both did it with `listState.scrollToItem`, which forces
a layout on the spot. This screen is composed inside the Scaffold's measure
pass, so when the effect ran straight away it asked for a layout in the
middle of one:

  IllegalArgumentException: performMeasureAndLayout called during measure layout
    at LayoutNode.forceRemeasure
    at LazyListState.snapToItemIndexInternal
    at ChatScreens.kt:1740 (the keyboard follow)
    ... LaunchedEffectImpl.onRemembered ... SubcomposeLayout ... Scaffold

It failed ChatFlowTest's send in every full on-device run since 2026-09-18.
The frames that ran the effect inline are the Compose test dispatcher's; the
shipped app posts effects to the next frame, so this has not been seen to
crash a real device. Forcing a synchronous layout from an effect is wrong
either way.

Both now use `requestScrollToItem` (foundation 1.7.6), which the next layout
applies, and the keyboard is what brings that layout. Still instant, still
not animated. The animated follow for new messages is unchanged: it waits
for animation frames before it moves.

TheKeyboardDoesNotHideTheMessagesTest read the source for
`listState.scrollToItem(`; it now requires `requestScrollToItem(` and refuses
`scrollToItem(` in the keyboard effect. Put the old line back and it fails.

Result on relay1: the full on-device suite passes, 10 of 10, and the crash
buffer has no performMeasureAndLayout. App unit tests: 358 debug, 323
release, all green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:13:25 -04:00
Paul HittandClaude Opus 5.5 a44624cfad Find the new group by its hex id, not by a number
aGroupIsCreatedAndListed waited for a row matching `group-\d+`. The relay
mints group ids with randomHexToken(), so the tag is `group-` plus 64 hex
characters, and the pattern only matched when the id happened to start
with a digit: ten times in sixteen. The rest of the time the test failed
with "the group never appeared in the list after Create" while the dump
showed the group in the list ("Test Crew, peer96035270, You", tag
group-eea4ceeb...).

Now `group-[0-9a-f]{16,}`, which matches that tag and still not
`group-name` or `group-member-N`. Checked both patterns against the tag
from the failing run: old no match, new match. On device: passes.

The keyboard was ruled out first: with it fully open (top at y=1517) the
member row and Create sat at y=1201-1377, above it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:52:51 -04:00
Paul HittandClaude Opus 5.5 cd5d1d8f44 A fresh peer for each on-device run, not peerjvmone
The suites added `peerjvmone`, one standing account on relay1 whose only
device came online when somebody ran the interop peer by hand. Nobody did.
On relay1 it had no one-time prekeys left, classical or PQ, and 114
messages it would never collect, and every run added more strangers to it.
Adding it and delivering to it failed in some runs and not others.

scripts/instrumented.sh now creates two accounts with the interop peer
before the run and passes them as `contactPeer` and `echoPeer`: one for the
add-and-send cases, one running `autoreply` for the reply case alone, since
an autoreply peer answers the first new account that writes to it. The
peer runs from `:interop:installDist` with plain java, not a second Gradle
build, and the exit trap stops it and waits for it.

The suites no longer fall back to peerjvmone; without the arguments they
say to use the script. The reply case now fails when no echo arrives
instead of logging a warning and passing. FeatureUseTest waited for the
confirm button, which is on screen but disabled until the lookup resolves,
so it tapped a disabled button; it now waits for the lookup result like
the other two suites.

On relay1: 8 of 10. The reply round trip passes. Still red: the group case
("the group never appeared in the list after Create", not yet diagnosed)
and ChatFlowTest's send, which crashes in the app with "performMeasureAndLayout
called during measure layout" at ChatScreens.kt:1740, the keyboard-follow
scroll recorded on 2026-09-18.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:36:15 -04:00
Paul Hitt e1d4b0ff41 Merge branch 'release/android-0-31' into 'main'
Version 0.31, build 31

See merge request paulhitt/flamenet-messenger-android!156
2026-09-28 11:24:00 +00:00
Paul HittandClaude Opus 5.5 332d3803e9 Version 0.31, build 31
One change since 0.30, android!154: the unsealed-send and changed-address
notices. versionCode and versionName move together, as the comment above
them requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:14:56 -04:00
Paul HittandClaude Opus 5.5 32a258bc1f Tap "Create an account" before the relay is typed, not after
The on-device sign-up helpers typed the relay and then tapped the
toggle-register button. Typing raises the keyboard, which slides in after
performScrollTo has measured the screen, so the tap landed on the keyboard
and the form stayed on sign-in. The failure read as a missing email field,
and was taken for a race with the relay probe.

Logged on the emulator: after the scroll the toggle sat at y=1878 while the
content ended at y=1517, the top of the keyboard. Every failure dump already
said "This server keeps its own accounts.", so the probe had answered.

Now the toggle is tapped first, while nothing is moving, and the email field
is waited for because the probe decides whether this relay asks for one.
Same change in FeatureUse, CrudSurface, ChatFlow, CrossPlatform and
LiveSignUpFleet. On relay1, every sign-up in the suite now gets through;
the remaining red is the shared peerjvmone fixture, not sign-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 20:03:54 -04:00
Paul Hitt fed6388de7 Merge branch 'fix/say-it-at-the-time' into 'main'
F2 and F5 on Android: say an unsealed send and a changed address at the time

See merge request paulhitt/flamenet-messenger-android!154
2026-09-26 18:45:35 +00:00
Paul HittandClaude Opus 5.5 d72a852899 F2 and F5 on Android: say an unsealed send and a changed address at the time
The iPhone app has said both since 2026-09-23 (flamenetmessenger
e030d63c). Android had the same facts and kept them to itself.

F2, SPEC section 14.5: "a user who has enabled sealed sending SHOULD be
told when a message could not be sent sealed." A send falls back to an
identified one when the recipient's delivery key has gone stale. It is
still encrypted per device; what changes is that the relay is told who
sent it. The sealing record in Privacy counted that, which answers "how
often" and never "did this one". The conversation now says so, in the
same words as iOS, keyed on the peer the send was to. onSendSealed
carries the peer id for that. In memory only, as on iOS, and a later
sealed send does not take it back, because the message that went
identified still did.

F5. The account key comes from the passphrase and a salt the account
service hands back at every sign-in and never stores on the phone, so
that service can serve a different salt, the phone derives a different
fnid, and the sign-in still succeeds. The new fnid replaced the old one
in the saved credential with nothing comparing them. It is compared
before the save now, for the same account on the same relay only (a
different account on a shared phone is a different person), and the
old address is shown above Your address in Settings.

What this does not fix is custody: the salt still lives on one service.

Eleven tests. The notices are rendered with Compose and read back, the
two pure pieces are tested including their controls (same address is
no change; a sealed send records nothing), and the call sites are
pinned from source because losing any of them changes nothing a user
would see: the message still sends and the sign-in still works.
Falsified by unhooking the chat screen and making the record ignore
whether the send was sealed; three tests fail.

358 app unit tests, no failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 14:39:31 -04:00
286 changed files with 42535 additions and 2278 deletions
+1 -1
View File
@@ -1 +1 @@
be8093f7c76c42cac5b826f40db95db372bdd34b
4b4042f72880758ee2dea7713f7a2cb63fa12f73
+2 -2
View File
@@ -1,6 +1,6 @@
# Why this repository pins flamenet-e2e
`.e2e-ref` is the commit of [flamenet-e2e](https://gitlab.com/paulhitt/flamenet-e2e)
`.e2e-ref` is the commit of [flamenet-e2e](https://git.extant2000.com/extant2000/flamenet-e2e)
whose protocol vectors the `unit-tests` job checks against. `AccountKdfTest`,
`PQVectorTest` and `PublishedVectorsTest` all read them.
@@ -17,7 +17,7 @@ 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`.
is `continue-on-error: true` in `.gitea/workflows/ci.yml`.
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.
+203
View File
@@ -0,0 +1,203 @@
# CI for the Android app on Gitea Actions. Translated from .gitlab-ci.yml on
# 2026-10-13; every gate it had is kept. Instrumented tests stay manual: they
# need an emulator and a local relay.
#
# Runs on mac1/mac2 (`ubuntu-latest`) inside the pinned toolchain image built
# from ci/android-ci.Dockerfile. Never on pauls-imac: Android unit tests hang
# there (2026-10-09).
name: ci
on:
push:
branches: [main]
pull_request:
env:
# Bump when ci/android-ci.Dockerfile changes.
IMAGE: git.extant2000.com/extant2000/flamenet-android-ci:jdk21.0.5-api35-r1
GITEA_BASE: https://git.extant2000.com/extant2000
# Inside a container job the runner picks `sh -e {0}`, which is dash on this
# image, and dash has no `pipefail`. Every `set -euo pipefail` step died on
# line 2 before it ran anything. Named here once rather than per step.
defaults:
run:
shell: bash
jobs:
# The repository is public, so this sweep is the last thing between a pasted
# key and the internet. It was red for nine days in 2026-09 because nobody
# ran it.
secret-sweep:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- run: bash scripts/secret-sweep.sh
# Builds and pushes the toolchain image when its tag is not in the registry
# yet. A no-op on every other run.
image:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Build and push the toolchain image if missing
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
JOB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ACTOR: ${{ gitea.actor }}
run: |
set -euo pipefail
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz \
| tar -xz -C /usr/local/bin --strip-components=1 docker/docker
if [ -n "${REGISTRY_TOKEN:-}" ]; then
echo "$REGISTRY_TOKEN" | docker login git.extant2000.com -u extant2000git --password-stdin
else
echo "$JOB_TOKEN" | docker login git.extant2000.com -u "$ACTOR" --password-stdin
fi
if docker manifest inspect "$IMAGE" >/dev/null 2>&1; then
echo "image $IMAGE exists"; exit 0
fi
DOCKER_BUILDKIT=0 docker build -t "$IMAGE" -f ci/android-ci.Dockerfile ci
docker push "$IMAGE"
unit-tests:
needs: image
runs-on: ubuntu-latest
timeout-minutes: 45
container:
image: git.extant2000.com/extant2000/flamenet-android-ci:jdk21.0.5-api35-r1
env:
# The interop tests FAIL rather than skip when unconfigured, on purpose.
# They need a real lyrebird from the flamenet-elligator2 checkout, so they
# are opted out HERE and nowhere else. Run scripts/obfs4-interop.sh
# locally before merging any obfs4 change.
OBFS4_INTEROP_OPTOUT: "1"
steps:
- uses: actions/checkout@v4
- name: Fetch the pinned protocol vectors
run: |
set -euo pipefail
# Pinned to .e2e-ref, not tip (flamenet-relay#56 is what tip cost).
# `vectors-head` below is the advisory half that watches HEAD.
FLAMENET_E2E_PIN=$(cat .e2e-ref)
rm -rf .ci-vectors
git clone -q "$GITEA_BASE/flamenet-e2e.git" .ci-vectors
git -C .ci-vectors checkout -q "$FLAMENET_E2E_PIN"
- name: Unit tests
run: |
set -euo pipefail
V="$GITHUB_WORKSPACE/.ci-vectors/test/vectors"
export FLAMENET_VECTORS="$V/protocol-v1.json"
export FLAMENET_PQ_VECTORS="$V/pqx3dh-v2.json"
export FLAMENET_ACCOUNT_VECTORS="$V/account-kdf-v1.json"
export FLAMENET_BACKUP_VECTORS="$V/backup-v1.json"
export FLAMENET_BACKUP_PAYLOAD_VECTORS="$V/backup-payload-v1.json"
export FLAMENET_CONTENT_VECTORS="$V/content-v1.json"
export FLAMENET_POLL_VECTORS="$V/poll-tally-v1.json"
export FLAMENET_PADDING_VECTORS="$V/padding-v1.json"
export FLAMENET_SYNC_VECTORS="$V/sync-merge-v1.json"
export FLAMENET_HANDLE_CERT_VECTORS="$V/handle-cert-v1.json"
export FLAMENET_LINK_VECTORS="$V/links-v1.json"
export FLAMENET_BRIDGE_VECTORS="$V/bridge-offer-v1.json"
export FLAMENET_CONTENT_KINDS_VECTORS="$V/content-kinds-v1.json"
export FLAMENET_TODAY_VECTORS="$V/today-v1.json"
export FLAMENET_HELLO_LINK_VECTORS="$V/hello-link-v1.json"
# A path that silently does not exist would read as a protocol bug.
for f in "$FLAMENET_VECTORS" "$FLAMENET_PQ_VECTORS" "$FLAMENET_ACCOUNT_VECTORS" \
"$FLAMENET_BACKUP_VECTORS" "$FLAMENET_BACKUP_PAYLOAD_VECTORS" \
"$FLAMENET_CONTENT_VECTORS" "$FLAMENET_POLL_VECTORS" \
"$FLAMENET_PADDING_VECTORS" "$FLAMENET_SYNC_VECTORS" \
"$FLAMENET_HANDLE_CERT_VECTORS" "$FLAMENET_LINK_VECTORS" \
"$FLAMENET_BRIDGE_VECTORS" "$FLAMENET_CONTENT_KINDS_VECTORS" \
"$FLAMENET_TODAY_VECTORS" "$FLAMENET_HELLO_LINK_VECTORS"; do
[ -f "$f" ] || { echo "GATE: vector file missing: $f"; exit 1; }
done
# Clear old results so the gate never counts ghosts.
rm -rf ./*/build/test-results
# --rerun-tasks: a FROM-CACHE pass prints the same BUILD SUCCESSFUL
# as a real one. Both app variants: release is the one that ships.
./gradlew --no-daemon --rerun-tasks :engine:test :client:test :obfs4:test \
:games:test :app:testDebugUnitTest :app:testReleaseUnitTest
- name: Gate on the test results
run: |
# Assert on the XML, never on Gradle's exit status: `:app` prints no
# test names, and an up-to-date task is also "BUILD SUCCESSFUL".
python3 - <<'PY'
import glob, re, sys
# Floors near 90% of the suite (663 / 453 / 75 / 572, games 15).
# `app` counts both variants.
MINIMUMS = {"engine": 595, "client": 405, "obfs4": 68, "games": 13, "app": 515}
totals = {}
for module in MINIMUMS:
tests = failures = skipped = 0
for path in glob.glob(f"{module}/build/test-results/*/TEST-*.xml"):
text = open(path).read()
m = re.search(r'tests="(\d+)" skipped="(\d+)" failures="(\d+)" errors="(\d+)"', text)
if not m:
continue
tests += int(m.group(1)); skipped += int(m.group(2))
failures += int(m.group(3)) + int(m.group(4))
totals[module] = (tests, failures, skipped)
bad = False
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")
bad = True
if failures:
bad = True
grand = sum(t for t, _, _ in totals.values())
print(f"GATE: {grand} tests in total")
if grand < 1580:
print(f"GATE: only {grand} tests in the whole build; something is not running")
bad = True
sys.exit(1 if bad else 0)
PY
# The pin's conscience: the vector suites against flamenet-e2e HEAD.
# Allowed to fail. A red run means an update is waiting; see .e2e-ref.md.
vectors-head:
needs: image
runs-on: ubuntu-latest
timeout-minutes: 20
continue-on-error: true
container:
image: git.extant2000.com/extant2000/flamenet-android-ci:jdk21.0.5-api35-r1
env:
OBFS4_INTEROP_OPTOUT: "1"
steps:
- uses: actions/checkout@v4
- name: Vector suites at flamenet-e2e HEAD
run: |
set -euo pipefail
rm -rf .ci-vectors-head
git clone --depth 1 "$GITEA_BASE/flamenet-e2e.git" .ci-vectors-head
echo "pinned: $(cat .e2e-ref)"
echo "at HEAD: $(git -C .ci-vectors-head rev-parse HEAD)"
V="$GITHUB_WORKSPACE/.ci-vectors-head/test/vectors"
export FLAMENET_VECTORS="$V/protocol-v1.json"
export FLAMENET_PQ_VECTORS="$V/pqx3dh-v2.json"
export FLAMENET_ACCOUNT_VECTORS="$V/account-kdf-v1.json"
export FLAMENET_BACKUP_VECTORS="$V/backup-v1.json"
export FLAMENET_BACKUP_PAYLOAD_VECTORS="$V/backup-payload-v1.json"
export FLAMENET_CONTENT_VECTORS="$V/content-v1.json"
export FLAMENET_POLL_VECTORS="$V/poll-tally-v1.json"
export FLAMENET_PADDING_VECTORS="$V/padding-v1.json"
export FLAMENET_SYNC_VECTORS="$V/sync-merge-v1.json"
export FLAMENET_HANDLE_CERT_VECTORS="$V/handle-cert-v1.json"
export FLAMENET_LINK_VECTORS="$V/links-v1.json"
export FLAMENET_BRIDGE_VECTORS="$V/bridge-offer-v1.json"
export FLAMENET_CONTENT_KINDS_VECTORS="$V/content-kinds-v1.json"
export FLAMENET_TODAY_VECTORS="$V/today-v1.json"
export FLAMENET_HELLO_LINK_VECTORS="$V/hello-link-v1.json"
# Only the tests that read vectors: the engine, :obfs4 (the
# bridge-offer vectors, SPEC section 24), and the three 0.32 loaders
# in :client and :app.
./gradlew --no-daemon --rerun-tasks :engine:test :obfs4:test \
:client:test --tests io.flamenet.client.SharedVectorsTodayTest \
:app:testDebugUnitTest --tests io.flamenet.messenger.app.SharedVectorsContentKindsTest \
--tests io.flamenet.messenger.app.SharedVectorsHelloLinkTest
-213
View File
@@ -1,213 +0,0 @@
# T4: the Mac is in the merge path. Engine, client, obfs4 and app unit suites on
# the project's own runner (tag mac-local). Instrumented tests stay manual:
# they need an emulator and a local relay.
stages: [test]
# The repository is public, so this sweep is the last thing between a pasted
# key and the internet. Until 2026-09-17 it only ran when somebody remembered
# to, and it had been red for nine days without anyone noticing, because a
# formatter carried an allow marker off the line it covered. A check nobody
# runs protects nothing; here it runs on every merge request and every push
# to main, and it finishes in seconds.
secret-sweep:
stage: test
tags: [mac-local]
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
variables:
GIT_DEPTH: "1"
script:
- bash scripts/secret-sweep.sh
timeout: 5m
unit-tests:
stage: test
tags: [mac-local]
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
variables:
GIT_DEPTH: "1"
# 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.
# Run `scripts/obfs4-interop.sh` locally before merging any obfs4 change:
# every other test in that module compares against recorded vectors or
# against itself, and two identically-wrong ends agree perfectly.
OBFS4_INTEROP_OPTOUT: "1"
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.
# `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
# 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
# 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
# merge-when-pipeline-succeeds, and was misdiagnosed as runner starvation.
# It fired twice in one evening. The person who merged was working in a
# different project and had no reason to look.
#
# A pin makes the same divergence a deliberate, reviewable bump here instead
# 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
# 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
# "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
# while being broken stays a hard stop.
#
# `flamenet-e2e` is public, so no credentials are needed.
- FLAMENET_E2E_PIN=$(cat "$CI_PROJECT_DIR/.e2e-ref")
- rm -rf "$CI_PROJECT_DIR/.ci-vectors"
- git clone -q https://gitlab.com/paulhitt/flamenet-e2e.git "$CI_PROJECT_DIR/.ci-vectors"
- git -C "$CI_PROJECT_DIR/.ci-vectors" checkout -q "$FLAMENET_E2E_PIN"
- 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"
- export FLAMENET_BACKUP_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/backup-v1.json"
- export FLAMENET_BACKUP_PAYLOAD_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/backup-payload-v1.json"
- export FLAMENET_CONTENT_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/content-v1.json"
- export FLAMENET_POLL_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/poll-tally-v1.json"
# 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" \
"$FLAMENET_BACKUP_VECTORS" "$FLAMENET_BACKUP_PAYLOAD_VECTORS" \
"$FLAMENET_CONTENT_VECTORS" "$FLAMENET_POLL_VECTORS"; do
[ -f "$f" ] || { echo "GATE: vector file missing: $f"; exit 1; }
done
# 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
# counted, by the very check meant to notice that it had stopped.
- rm -rf ./*/build/test-results
# Keep the build output out of Spotlight.
#
# The module build directories are rewritten every job on a host that is
# also the mac-local runner for both app repos. Indexing them drives the
# machine to a load average in the hundreds, with `mds_stores` and
# `mdworker_shared` at the top of CPU while no build process is near it.
# Measured on the iPhone repo: the same from-scratch build peaks at load
# ~12 with its output excluded and well over a hundred without.
#
# Written here rather than committed, because the directories these belong
# in are created by the build.
- |
for m in app engine client obfs4; do
mkdir -p "$m/build" && touch "$m/build/.metadata_never_index"
done
# `: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.
# 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".
# Both app variants, not just debug. The release variant was never run
# here, so four tests that die on it -- the merged manifest has no
# ComponentActivity, which is a debug-only dependency -- were red from the
# day they landed and CI never said a word. A variant nobody tests is a
# variant nobody knows about, and release is the one that ships.
- ./gradlew --no-daemon --rerun-tasks :engine:test :client:test :obfs4:test :app:testDebugUnitTest :app:testReleaseUnitTest
- |
# 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
# 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
# (663 / 453 / 75 / 572) 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.
#
# `app` counts both variants, because the glob takes every
# `test-results/*` directory and both now run.
MINIMUMS = {"engine": 595, "client": 405, "obfs4": 68, "app": 515}
totals = {}
for module in MINIMUMS:
tests = failures = skipped = 0
for path in glob.glob(f"{module}/build/test-results/*/TEST-*.xml"):
text = open(path).read()
m = re.search(r'tests="(\d+)" skipped="(\d+)" failures="(\d+)" errors="(\d+)"', text)
if not m:
continue
tests += int(m.group(1)); skipped += int(m.group(2))
failures += int(m.group(3)) + int(m.group(4))
totals[module] = (tests, failures, skipped)
bad = False
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")
bad = True
if failures:
bad = True
# 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")
if grand < 1580:
print(f"GATE: only {grand} tests in the whole build; something is not running")
bad = True
sys.exit(1 if bad else 0)
PY
timeout: 30m
# 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
# 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`.
vectors-head:
stage: test
tags: [mac-local]
allow_failure: true
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
variables:
GIT_DEPTH: "1"
OBFS4_INTEROP_OPTOUT: "1"
script:
- export JAVA_HOME=/opt/homebrew/opt/openjdk@21
- export ANDROID_HOME=$HOME/Library/Android/sdk
- rm -rf "$CI_PROJECT_DIR/.ci-vectors-head"
- git clone --depth 1 https://gitlab.com/paulhitt/flamenet-e2e.git "$CI_PROJECT_DIR/.ci-vectors-head"
- |
echo "pinned: $(cat "$CI_PROJECT_DIR/.e2e-ref")"
echo "at HEAD: $(git -C "$CI_PROJECT_DIR/.ci-vectors-head" rev-parse 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"
- export FLAMENET_BACKUP_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/backup-v1.json"
- export FLAMENET_BACKUP_PAYLOAD_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/backup-payload-v1.json"
- export FLAMENET_CONTENT_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/content-v1.json"
- export FLAMENET_POLL_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/poll-tally-v1.json"
# 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
timeout: 20m
+47
View File
@@ -1206,3 +1206,50 @@ Android injects the policy and the counter into `Conversations`/`Session` as
lambdas, those modules have no Android dependency, and wires them in ONE
helper, because two sign-in paths building a session and only one applying the
policy is how a setting silently stops working on half the app.
## 0.32: stickers, cards, events, games, Today and hello links
Everything 0.32 adds, with the DESIGN.md item that decided it where there is
one. Each row names the iOS file to re-check it against, and the Android test
that holds it. iOS was read on its `release/0-32-0` branch at `1230277f`
on 2026-10-04.
| Feature | iOS | Android | Note |
|---|:--:|:--:|---|
| Sticker packs in FlameNet's art | yes | yes | iOS `StickerPack.swift` reads the same manifests; Android `StickerPack.kt`, every pack under `assets/stickers` |
| A sticker or a card to a group, queued for the members nothing reached | yes | yes | iOS `E2EInbox.sendGroupSticker`, `sendGroupCard`; Android `Session.sendGroupSticker`, `sendGroupCard` |
| Greeting cards (§6.31), sealed until a day | yes | yes | iOS `ChatCard.swift`, `CardViews.swift`; Android `CardUi.kt`, `CardArt.kt`, `CardsAndEventsInASessionTest` |
| Events and answers (§6.32) | yes | yes | iOS `ChatEvent.swift`, `EventViews.swift`; Android `ChatEvents.kt` (engine), `EventUi.kt`, `ChatEventsTest` |
| A rev 1 that takes over from a stand-in organizer is redrawn and never notifies (DESIGN 36) | yes | yes | iOS `ChatEvent.fold` returns `.settled`; Android `ChatEvents.fold` returns `SETTLED` |
| Event and game ids: any non-empty string, a space included (DESIGN 36) | yes | yes | iOS `ChatEvent.isValid`, `ChatGame.swift`; Android `Content.Event`, `Content.Game`, `ChatGames.fromRow` |
| An event runs 1 to 31 days, read and written (DESIGN 45) | yes | yes | iOS `ChatEvent.dayCount`; Android `Content.Event.clampDays`, the event composer |
| A card, an event or a game follows the chat's timer; its own `secs` is ignored (DESIGN 46) | yes | yes | iOS `E2EAttachment.swift`; Android `Content.lifetimeSeconds`, `CardEventEnvelopeTest` |
| Any `ctl` but 0, false or null makes an envelope control (DESIGN 43) | yes | yes | iOS `E2EAttachment.swift`; Android `Content.isControl`, `ControlFlagInteropTest` |
| Games in a one-to-one chat (§6.33): chess, checkers, reversi, tic-tac-toe | yes | yes | iOS `ChatGame.swift`, `GameViews.swift`, `FlamenetGames`; Android `:games`, `ChatGames.kt`, `GameUi.kt`; both engines pinned to `games-v1.json` |
| A `gmove` with no log or an empty one, and a `gend` with no log, are dropped (DESIGN 37) | yes | yes | iOS `GamePart.init`; Android `ChatGames.receive`, `ChatGamesTest` |
| A draw accept counts only at the whole known log (DESIGN 37) | yes | yes | iOS `GameState.ending`; Android `ChatGames.applyEnding` |
| A start or a move that carries `r` is still one; a field a kind does not use never drops it (DESIGN 44) | yes | yes | iOS `GameWire` reads `r` leniently; Android `Content.Game.fromJson`, `GameEnvelopeTest` |
| "Sam invited you to play chess", with a sound, and the chat is unread (DESIGN 29) | yes | yes | iOS `GameWords.invited`, `E2EInbox.receiveGame`; Android `Session.Notice.GameInvite`, `GamesInASessionTest` |
| A move, a draw offer or an ending is said but never unread (DESIGN 29) | yes | yes | iOS `GameWords.isQuiet`; Android `Session.takeGameNews`: an ending with a sound, a move or an offer without |
| Boards turn round for the second player, tic-tac-toe never (DESIGN 30) | yes | yes | iOS `GameViews.swift`; Android `ChatGames.turnedRound`, `GameScreensTest` |
| Games and events in the shared backup (§18) | yes | yes | iOS `E2EBackupPayload.swift`; Android `BackupPayload.kt` |
| One-to-one threads backed up under the user id | yes | yes | iOS `E2EBackupPayload.swift` keys `threads` by user id; Android `BackupPayload.kt`, `BackupThreadKeyTest` |
| Link previews on incoming messages | yes | yes | iOS `LinkPreview.swift`, `Message.swift`; Android `Session.kt`, `IncomingLinkPreviewTest` |
| The Today tab beside Messages (§8.12) | yes | yes | iOS `TodayFile.swift`, `TodayStore.swift`, `TodayView.swift`; Android `TodayFile.kt`, `TodayFeed.kt` (client), `TodayModel.kt`, `TodayScreen.kt` |
| Today fetch: once an hour, five minutes after a failure, an ETag only when it is the hash of the cached file (DESIGN 19) | yes | yes | iOS `TodayStore.swift`; Android `TodayFeedTest` |
| Today strictness: no redirects, a market needs a `name` and a non-empty `last` and nothing else, `v` is the integer `1` as written (DESIGN 38) | yes | yes | iOS `TodayFile.swift` (`TodayJSON.exactInteger`); Android `TodayFile.verify`, `TodayFileTest` |
| The Today file cited as §8.12, not §8.11 (DESIGN 34) | yes | yes | Android `TodayIsSection812Test` |
| Today bounds: a figure 24 characters, five notes of 200, `cc` two letters, `tz` 64, and an unknown zone shows the days as given (DESIGN 42) | yes | yes | iOS `TodayFile.swift`, `TodayFormat.swift`; Android `TodayFile.kt`, `TodayText.ahead`, `TodayFileTest`, `TodayTextTest` |
| The small-web directory and "Send to a chat" (§6.12) | yes | yes | iOS `SmallWebDirectory.swift`, `DirectoryView.swift`; Android `Directory.kt`, `DirectoryScreen.kt`, `DirectoryShare.kt` |
| Hello links (§6.34): the address card, the app link, the scanners | yes | yes | iOS `HelloLink.swift`, `MyAddressSection.swift`, `CardScanner.swift`; Android `HelloLink.kt`, `AddressCard.kt`, `ScanAddress.kt` |
| A hello link's path is exactly `/hi` or `/es/hi`: no trailing slash, no query (DESIGN 35) | yes | yes | iOS `HelloLink.swift`; Android `HelloLink.isHead`, `HelloLinkTest` |
| A hello link's host is ASCII, an address has no spaces, a fragment is 400 characters at most (DESIGN 41) | yes | yes | iOS `HelloLink.canonicalAddress`, `ContactCard.swift`; Android `HelloLink.canonical`, `ContactCard.decode` (typed or pasted), `HelloLinkTest`, `ContactCardTest` |
| Every scanner binds by fnid; an older card's `?u=` is ignored (DESIGN 32) | yes | yes | iOS `CardScanner.swift`; Android `prefillFor`, `HomeScanFillsInTheAddressTest` |
| Parental controls hide the Today tab and the directory (DESIGN 31) | yes | yes | iOS `ParentalControls.swift`, `MainTabView.swift`; Android `ParentalControls.todayHidden`, `NavDestinationsTest` |
| Sign-out and a lost-device wipe clear the Today file and the weather picks, and a fetch still on its way is dropped when it returns (DESIGN 32) | yes | yes | iOS `TodayStore.forgetOnThisPhone`, `TodayStore.generation`; Android `TodayData.forget`, the `TodayFeed` epoch, `TodayDataIsClearedTest`, `TodayFeedTest` |
| Edits, reactions, pins and story reactions go with the relay's silent flag and never notify (owner, 0.32) | partly | yes | iOS `E2EInbox.warrantsAlert` at `d7d0c83e` wakes for any envelope with text, an edit among them, and the owner has approved making all four silent there; Android `Conversations.warrantsAlert`, every kind pinned in `SilentKindsTest` |
| A stored account opens to its chats and the saved Today page with the relay out of reach, and connects after (DESIGN 47) | partly | yes | iOS is being changed to do so; Android `Session.storedRoster`, `Session.storedGroups`, `catchUpAfterStart`, `StoredStartStateTest` |
| A snoozed chat holds every notification, not only a game's | no | yes | Owner, 0.32. iOS `NotificationManager.postIncomingMessage` checks mute and never snooze, though `AttentionSummary` says a snoozed chat "stops asking"; Android `NotificationSettings.due`, `ASnoozedChatHoldsItsNotificationsTest` |
| A group photo that reached nobody is queued, and leaves pending when it goes | yes | yes | iOS `E2EInbox.sendGroupImage`; Android `Session.sendGroupImage`, `OutboxTest` |
| A photo, voice note or file to one person, and a file to a group, that reached nobody is queued and leaves pending when it goes (owner, 0.32) | no | yes | iOS `E2EInbox.sendAttachment` and `sendFile` stop with an error when the person has no device, and nothing waits; iOS has no group file send; Android `Session.sendUploaded`, `Session.sendGroupFile`, `OutboxTest` |
| Event, card, poll and stories feed secondary text reads in the dark theme | yes | yes | iOS `Theme.secondaryText` has a dark value; Android uses the theme's `onSurfaceVariant`, as the game card does; `CardsReadInTheDarkTest` |
+1 -1
View File
@@ -47,7 +47,7 @@ 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://git.extant2000.com/extant2000/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
+8 -2
View File
@@ -43,14 +43,18 @@ android {
// is a label. Code 24 with name 0.24 closes it by skipping the name
// 0.23, which costs nothing because a name is a label and may skip.
// From here they move together and match: 25 with 0.25, 26 with 0.26.
versionCode = 30
versionName = "0.30"
versionCode = 32
versionName = "0.32"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
sourceSets {
getByName("main") { java.srcDir("src/main/kotlin") }
getByName("androidTest") { java.srcDir("src/androidTest/kotlin") }
// Code that differs between the two builds. The Today dev key is
// compiled into debug builds and is absent from release.
getByName("debug") { java.srcDir("src/debug/kotlin") }
getByName("release") { java.srcDir("src/release/kotlin") }
}
buildFeatures { compose = true }
@@ -198,6 +202,8 @@ android {
dependencies {
implementation(project(":engine"))
implementation(project(":client"))
// The rules of the games played in chats (§6.33). Plain JVM, no Android.
implementation(project(":games"))
// 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 uses these types; borrowing them through another
@@ -0,0 +1,369 @@
package io.flamenet.messenger.app
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot
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.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import io.flamenet.obfs4.Obfs4Stats
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* The app's own message, carried by an obfs4 bridge.
*
* 🔴 The claim this exists to back. The answers sent on 2026-09-28 said bridge
* users can reach the relay from anywhere. They could not: the bridge
* forwarded to plain HTTP while the app speaks TLS through the tunnel, so the
* handshake completed and every app session behind it died. The upstream was
* fixed on 2026-10-07 and checked with one GET, and flamenet-e2e's
* `test/bridge-message.mjs` later carried a real message through it from Node.
* None of that exercises THIS code: `Obfs4SocketFactory` under the app's own
* OkHttp, the sign-up, the device registration and the send.
*
* So the bridge goes into force before anything touches the network, and the
* handshake counter is asserted at zero first. A test that only checks the
* message arrived would pass with the bridge quietly unused, which is the
* exact failure the 09-28 answer made in prose.
*
* Needs a live bridge, a reachable relay and a peer account that already has a
* device, so it is opt-in:
*
* ./gradlew :app:connectedDebugAndroidTest \
* -Pandroid.testInstrumentationRunnerArguments.class=io.flamenet.messenger.app.BridgeMessageTest \
* -Pandroid.testInstrumentationRunnerArguments.bridge="obfs4 HOST:PORT FINGERPRINT cert=CERT iat-mode=0" \
* -Pandroid.testInstrumentationRunnerArguments.peer=<handle>
*/
@RunWith(AndroidJUnit4::class)
class BridgeMessageTest {
/**
* 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,
)
@get:Rule(order = 1)
val compose = createAndroidComposeRule<MainActivity>()
private val args get() = InstrumentationRegistry.getArguments()
private val bridgeLine: String? get() = args.getString("bridge")
private val relay: String get() = args.getString("relay") ?: "relay1.flamenet.io"
/**
* Somebody with a published device, so a deposit has a destination.
*/
private val peerHandle: String? get() = args.getString("peer")
private lateinit var handle: String
/**
* The control. `-e direct true` runs the same path with no bridge, which
* is the only way to tell a flaky bridge from a flaky sign-up.
*/
private val direct: Boolean get() = args.getString("direct") == "true"
@Before
fun putTheBridgeInForceFirst() {
assumeTrue(
"pass -e bridge \"<obfs4 line>\" to run this; it needs a live bridge",
direct || !bridgeLine.isNullOrBlank(),
)
assumeTrue(
"pass -e peer <handle> to run this; the peer needs a published device",
!peerHandle.isNullOrBlank(),
)
handle = "andb" + (System.currentTimeMillis() % 100_000_000)
// The same call the Network screen's own button makes, so this is the
// app's setting path and not a test-only hook.
val settings = Obfs4Settings(InstrumentationRegistry.getInstrumentation().targetContext)
settings.clear()
if (!direct) {
val added = settings.add(bridgeLine!!)
if (added !is Obfs4Settings.AddResult.Added) {
throw AssertionError("the bridge line was refused: ${added.message}")
}
}
signOutIfSignedIn()
}
@Test
fun theAppSendsAMessageThroughTheBridge() {
assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable())
// Zeroed here and not in @Before, because signing out of whatever the
// last run left behind is itself a request, and with the bridge
// already in force that request is a handshake. The first run of this
// test failed on "the counter started at 1" for exactly that reason.
Obfs4Stats.reset()
assert(Obfs4Stats.completedHandshakes() == 0) {
"the counter would not zero: ${Obfs4Stats.completedHandshakes()}"
}
signUp()
val afterSignUp = Obfs4Stats.completedHandshakes()
if (direct) {
assert(afterSignUp == 0) { "the control run went through a bridge anyway" }
} else {
assert(afterSignUp > 0) {
"the sign-up and device registration completed without one obfs4 handshake, " +
"so the bridge was saved and bypassed"
}
}
compose.onNodeWithTag("add-contact").performClick()
compose.onNodeWithTag("new-contact").performTextInput(peerHandle!!)
// Adding is gated on a lookup, so a typo cannot become a contact. The
// lookup is itself a request through the bridge.
compose.onNodeWithTag("lookup-contact").performClick()
awaitTag("lookup-result", 30_000)
compose.onNodeWithTag("confirm-contact").performClick()
awaitNode(hasText(peerHandle!!, substring = true), 60_000)
compose.onNode(hasText(peerHandle!!, substring = true)).performClick()
awaitTag("draft", 20_000)
val text = "through a bridge from the app at ${System.currentTimeMillis() % 100000}"
compose.onNodeWithTag("draft").performTextInput(text)
compose.onNodeWithTag("send").performClick()
// The line appearing is NOT delivery. The app renders an outgoing
// message as soon as it is composed: a run that asserted only this
// reported OK while the relay never saw a POST /e2e/messages at all.
awaitNode(hasText(text, substring = true), 90_000)
compose.onNode(hasText(text, substring = true)).assertIsDisplayed()
// Delivery is the absence of both markers. The row carries `pending`
// while the message is still queued and `undelivered` when it failed,
// so a row with neither is one the relay accepted.
val deadline = System.currentTimeMillis() + 150_000
var queued = true
while (System.currentTimeMillis() < deadline) {
settle(1_000)
if (nodesWithTag("undelivered").isNotEmpty()) {
throw AssertionError("the message is marked undelivered. On screen: ${visibleText()}")
}
if (nodesWithTag("pending").isEmpty()) {
queued = false
break
}
}
assert(!queued) { "the message was still pending after 150 seconds" }
// The counter is the whole evidence, so say what it was at each
// stage rather than only that it moved.
val afterSend = Obfs4Stats.completedHandshakes()
assert(afterSend >= afterSignUp) {
"the handshake count went backwards, $afterSignUp then $afterSend"
}
android.util.Log.i(
"fnbridge",
"sent from $handle to ${peerHandle!!} through the bridge: $text " +
"($afterSend obfs4 handshakes, $afterSignUp of them before the send)",
)
}
/**
* One whole sign-up, driven the way a person drives it.
*/
private fun signUp() {
compose.onNodeWithTag("relay").performTextClearance()
compose.onNodeWithTag("relay").performTextInput(relay)
// The toggle is a toggle, so clicking it blindly is a coin flip, and
// one blind click put this test into sign-in mode: it filled a handle
// and a passphrase, pressed a button labelled "Sign in", and read the
// relay's refusal as "Your session expired". Drive it by what the
// screen says instead, and scroll first, because the keyboard opened
// by the relay field can leave the toggle outside the window where a
// tap lands nowhere and nothing reports it.
var attempts = 0
while (nodesWithText("Create an account").isNotEmpty() && attempts < 4) {
attempts++
compose.onNodeWithTag("toggle-register").performScrollTo()
compose.onNodeWithTag("toggle-register").performClick()
settle(2_000)
}
if (nodesWithText("Create an account").isNotEmpty()) {
throw AssertionError(
"the app stayed in sign-in mode after $attempts taps on the toggle. " +
"On screen: ${visibleText()}",
)
}
// Wait for discovery BEFORE touching the form. The relay says whether
// it keeps its own accounts, and the email field appears or disappears
// on that answer. Filling the form first put text into a field that
// then vanished, left a required field empty, and the submit button
// stayed disabled; a disabled submit swallows the tap in silence, so
// the first run of this test sat on the sign-up screen for its whole
// 180 seconds with nothing to show for it.
awaitTag("identity-mode", 60_000)
// Which fields exist is the relay's decision, so fill what is there.
// relay1 asks for neither an email nor a birth year, and typing into a
// field that is not on screen is an immediate "Failed to perform text
// input" that reads like a broken test.
typeIfPresent("email", "$handle@example.invalid")
compose.onNodeWithTag("handle").performTextInput(handle)
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
typeIfPresent("birth-year", "1990")
// Scroll first: the app sits inside imePadding(), so an open keyboard
// shortens the viewport and the submit button is laid out below it.
compose.onNodeWithTag("submit").performScrollTo()
compose.onNodeWithTag("submit").performClick()
// Wait for the signed-in screen for the whole window, and read the
// error node only if it never comes.
//
// 🔴 An earlier version stopped at the first "error" node and reported
// a refusal. The relay's own log showed the opposite: signup 200,
// token 200, devices 200, identity 200. The app's first attempt
// through a cold obfs4 tunnel passed the 25-second callTimeout, put
// "The server did not answer" on screen, retried, and finished 97
// seconds later. Treating that message as final called a working
// sign-up a failure.
try {
awaitTag("nav-contacts", 240_000)
} catch (e: AssertionError) {
val shown =
runCatching {
compose.onAllNodes(hasTestTag("error")).fetchSemanticsNodes().size
}.getOrDefault(0)
throw AssertionError(
"sign-up never reached the signed-in screen ($shown error nodes on screen). " +
"On screen: ${visibleText()}",
e,
)
}
}
/** Let recomposition happen, with the clock driven by hand. */
private fun settle(millis: Long) {
compose.mainClock.autoAdvance = false
try {
val deadline = System.currentTimeMillis() + millis
while (System.currentTimeMillis() < deadline) {
compose.mainClock.advanceTimeBy(100)
Thread.sleep(50)
}
} finally {
compose.mainClock.autoAdvance = true
}
}
/** Fill a field the relay may or may not be asking for. */
private fun typeIfPresent(
tag: String,
text: String,
) {
if (nodesWithTag(tag).isNotEmpty()) {
compose.onNodeWithTag(tag).performTextInput(text)
}
}
/**
* Get to a signed-out app.
*/
private fun signOutIfSignedIn() {
awaitAny(listOf("relay", "nav-contacts", "nav-account", "back"), 30_000)
if (nodesWithTag("back").isNotEmpty()) {
compose.onNodeWithTag("back").performClick()
awaitTag("nav-contacts", 20_000)
}
if (nodesWithTag("nav-account").isEmpty()) return
compose.onNodeWithTag("nav-account").performClick()
awaitTag("sign-out", 20_000)
compose.onNodeWithTag("sign-out").performScrollTo()
compose.onNodeWithTag("sign-out").performClick()
awaitTag("relay", 30_000)
}
private fun awaitAny(
tags: List<String>,
timeoutMillis: Long,
) {
compose.mainClock.autoAdvance = false
try {
val deadline = System.currentTimeMillis() + timeoutMillis
while (System.currentTimeMillis() < deadline) {
compose.mainClock.advanceTimeBy(100)
if (tags.any { nodesWithTag(it).isNotEmpty() }) return
Thread.sleep(100)
}
} finally {
compose.mainClock.autoAdvance = true
}
}
private fun nodesWithText(text: String): List<Any> =
runCatching {
compose.onAllNodes(hasText(text, substring = true)).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 awaitNode(
matcher: androidx.compose.ui.test.SemanticsMatcher,
timeoutMillis: Long,
) {
compose.mainClock.autoAdvance = false
try {
val deadline = System.currentTimeMillis() + timeoutMillis
while (System.currentTimeMillis() < deadline) {
compose.mainClock.advanceTimeBy(100)
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()}")
} finally {
compose.mainClock.autoAdvance = true
}
}
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("https://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection)
.apply {
connectTimeout = 8000
readTimeout = 8000
}.responseCode in 200..299
}.getOrDefault(false)
}
@@ -0,0 +1,124 @@
package io.flamenet.messenger.app
import io.flamenet.obfs4.BridgeLine
import io.flamenet.obfs4.BridgeLineResult
import io.flamenet.obfs4.Obfs4SocketFactory
import io.flamenet.obfs4.Obfs4Stats
import org.junit.Assume.assumeTrue
import org.junit.Test
import javax.net.ssl.SSLSocketFactory
/**
* Just the transport, so a failure says which layer failed.
*
* The whole-app test goes through the sign-up screen, and a refusal there
* reads as "The server did not answer", which covers a bridge that would not
* handshake and a relay that is down with the same sentence. This opens the
* tunnel by hand and speaks one HTTP request through it.
*/
class BridgeTransportProbeTest {
private val args get() = androidx.test.platform.app.InstrumentationRegistry.getArguments()
@Test
fun theDeviceCanReachTheRelayThroughTheBridge() {
val line = args.getString("bridge")
assumeTrue("pass -e bridge \"<obfs4 line>\" to run this", !line.isNullOrBlank())
val host = args.getString("relay") ?: "relay1.flamenet.io"
val parsed = BridgeLine.parse(line!!)
if (parsed !is BridgeLineResult.Ok) throw AssertionError("the line was refused: $parsed")
Obfs4Stats.reset()
val plain = Obfs4SocketFactory(parsed.bridge).createSocket()
plain.soTimeout = 30_000
plain.connect(java.net.InetSocketAddress(host, 443), 30_000)
assert(Obfs4Stats.completedHandshakes() == 1) {
"the socket connected without an obfs4 handshake"
}
// The app speaks TLS inside the tunnel, so this does too.
val tls =
(SSLSocketFactory.getDefault() as SSLSocketFactory)
.createSocket(plain, host, 443, true) as javax.net.ssl.SSLSocket
tls.soTimeout = 30_000
tls.startHandshake()
val cert = tls.session.peerCertificates.first() as java.security.cert.X509Certificate
assert(cert.subjectX500Principal.name.contains(host.substringAfter('.'))) {
"the certificate inside the tunnel was for ${cert.subjectX500Principal.name}"
}
tls.outputStream.write(
"GET /health HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n".toByteArray(),
)
tls.outputStream.flush()
val status = tls.inputStream.bufferedReader().readLine() ?: ""
tls.close()
assert(status.contains("200")) { "the relay answered: $status" }
android.util.Log.i(
"fnbridge",
"probe: $status through ${parsed.bridge.host}:${parsed.bridge.port}, " +
"cert ${cert.subjectX500Principal.name}",
)
}
/**
* Several tunnels at once, because the app opens several.
*
* 🔴 Why this case exists. One hand-made socket through the bridge is
* reliable: 1.3 seconds, every time. The app's sign-up through the same
* bridge is not. One run carried the whole chain and the relay logged
* signup, token, devices, identity, buddies and groups all 200. The next
* run put nothing on the relay and showed "The server did not answer".
* The app's OkHttp client opens more than one connection and each one
* needs its own obfs4 handshake, so this is the difference worth testing.
*/
@Test
fun fourTunnelsAtOnceAllReachTheRelay() {
val line = args.getString("bridge")
assumeTrue("pass -e bridge \"<obfs4 line>\" to run this", !line.isNullOrBlank())
val host = args.getString("relay") ?: "relay1.flamenet.io"
val parsed = BridgeLine.parse(line!!)
if (parsed !is BridgeLineResult.Ok) throw AssertionError("the line was refused: $parsed")
Obfs4Stats.reset()
val factory = Obfs4SocketFactory(parsed.bridge)
val results = java.util.Collections.synchronizedList(mutableListOf<String>())
val threads =
(1..4).map { n ->
Thread {
results +=
runCatching {
val plain = factory.createSocket()
plain.soTimeout = 40_000
plain.connect(java.net.InetSocketAddress(host, 443), 40_000)
val tls =
(SSLSocketFactory.getDefault() as SSLSocketFactory)
.createSocket(plain, host, 443, true) as javax.net.ssl.SSLSocket
tls.soTimeout = 40_000
tls.startHandshake()
tls.outputStream.write(
"GET /health HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n"
.toByteArray(),
)
tls.outputStream.flush()
val status = tls.inputStream.bufferedReader().readLine() ?: "(no line)"
tls.close()
"$n: $status"
}.getOrElse { "$n: FAILED ${it.javaClass.simpleName}: ${it.message}" }
}
}
threads.forEach { it.start() }
threads.forEach { it.join(90_000) }
android.util.Log.i("fnbridge", "four at once: ${results.joinToString(" | ")}")
val bad = results.filter { !it.contains("200") }
assert(bad.isEmpty()) {
"${bad.size} of 4 concurrent tunnels failed: ${bad.joinToString(" | ")}. " +
"${Obfs4Stats.completedHandshakes()} handshakes completed."
}
assert(Obfs4Stats.completedHandshakes() == 4) {
"4 sockets but ${Obfs4Stats.completedHandshakes()} handshakes"
}
}
}
@@ -54,14 +54,21 @@ class ChatFlowTest {
} else {
"https"
}
// The contact on the other side. `peerjvmone` is a standing account on
// relay1; the reply case needs somebody answering AS it, which is the
// interop Peer in `autoreply` (see PROVEN below). Override with
// -Pandroid.testInstrumentationRunnerArguments.peer=<handle>, and run the
// reply case ALONE: one autoreply peer pins the first fresh account it
// sees and drops a second one, so two ChatFlow methods cannot share it.
// The contacts on the other side, both created for this run by
// scripts/instrumented.sh. They used to be `peerjvmone`, one standing
// account on relay1 that ran out of prekeys and filled with messages nobody
// collected, so adding it and delivering to it failed in some runs and not
// others. The reply case gets its own peer, running `autoreply`, because
// one autoreply peer answers the first new account that writes to it and
// drops the rest.
private val peerHandle: String
get() = InstrumentationRegistry.getArguments().getString("peer") ?: "peerjvmone"
get() =
InstrumentationRegistry.getArguments().getString("contactPeer")
?: error("no contactPeer: run scripts/instrumented.sh, which creates a fresh one")
private val echoPeer: String
get() =
InstrumentationRegistry.getArguments().getString("echoPeer")
?: error("no echoPeer: run scripts/instrumented.sh, which creates one and runs autoreply")
private lateinit var handle: String
@Before
@@ -117,14 +124,14 @@ class ChatFlowTest {
signUp()
compose.onNodeWithTag("add-contact").performClick()
compose.onNodeWithTag("new-contact").performTextInput(peerHandle)
compose.onNodeWithTag("new-contact").performTextInput(echoPeer)
// Adding is gated on a lookup: the button stays disabled until the
// handle resolves, so a typo cannot become a contact.
compose.onNodeWithTag("lookup-contact").performClick()
awaitTag("lookup-result", 30_000)
compose.onNodeWithTag("confirm-contact").performClick()
awaitNode(hasText(peerHandle, substring = true), 60_000)
compose.onNode(hasText(peerHandle, substring = true)).performClick()
awaitNode(hasText(echoPeer, substring = true), 60_000)
compose.onNode(hasText(echoPeer, substring = true)).performClick()
awaitTag("draft", 20_000)
val text = "ping ${System.currentTimeMillis() % 100000}"
@@ -134,15 +141,10 @@ class ChatFlowTest {
// 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)
if (!arrived) {
android.util.Log.w("fnchat", "no echo for '$text', is the peer running autoreply?")
return
}
// A missing echo is a failure. This used to log a warning and return,
// which passed the test while proving nothing; with the script running
// the echo peer, silence means the round trip is broken.
awaitNode(hasText(echo, substring = true), 90_000)
compose.onNode(hasText(echo, substring = true)).assertIsDisplayed()
android.util.Log.i("fnchat", "round trip complete: $echo")
}
@@ -150,9 +152,14 @@ class ChatFlowTest {
// -- Helpers -------------------------------------------------------------.
private fun signUp() {
// Switched to sign-up BEFORE the relay is typed. Typing raises the keyboard,
// which slides in after performScrollTo has measured, so the tap landed on
// the keyboard and the form stayed on sign-in. Nothing is moving yet here.
compose.onNodeWithTag("toggle-register").performScrollTo().performClick()
compose.onNodeWithTag("relay").performTextClearance()
compose.onNodeWithTag("relay").performTextInput(relay)
compose.onNodeWithTag("toggle-register").performClick()
// Waited for, not asserted: the probe decides whether this relay asks for one.
awaitTag("email", 30_000)
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
compose.onNodeWithTag("handle").performTextInput(handle)
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
@@ -44,9 +44,21 @@ class CrossPlatformTest {
settle()
if (nodesWithTag("nav-contacts").isNotEmpty()) return // already signed in
// Switched to sign-up BEFORE the relay is typed. Typing raises the keyboard,
// which slides in after performScrollTo has measured, so the tap landed on
// the keyboard and the form stayed on sign-in. Nothing is moving yet here.
compose.onNodeWithTag("toggle-register").performScrollTo().performClick()
compose.onNodeWithTag("relay").performTextClearance()
compose.onNodeWithTag("relay").performTextInput(relay)
compose.onNodeWithTag("toggle-register").performClick()
// Waited for, not asserted: the probe decides whether this relay asks for one.
awaitTag("email", 30_000)
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
compose.onNodeWithTag("handle").performTextInput(handle)
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
@@ -56,7 +56,11 @@ class CrudSurfaceTest {
} else {
"https"
}
private val peerHandle = "peerjvmone"
/** Created for this run by scripts/instrumented.sh; see FeatureUseTest. */
private val peerHandle: String
get() =
InstrumentationRegistry.getArguments().getString("contactPeer")
?: error("no contactPeer: run scripts/instrumented.sh, which creates a fresh one")
private lateinit var handle: String
@Before
@@ -83,9 +87,14 @@ class CrudSurfaceTest {
compose.onNodeWithTag("toggle-reset").performClick()
// Registering a local account asks for an email; signing in does not.
// Switched to sign-up BEFORE the relay is typed. Typing raises the keyboard,
// which slides in after performScrollTo has measured, so the tap landed on
// the keyboard and the form stayed on sign-in. Nothing is moving yet here.
compose.onNodeWithTag("toggle-register").performScrollTo().performClick()
compose.onNodeWithTag("relay").performTextClearance()
compose.onNodeWithTag("relay").performTextInput(relay)
compose.onNodeWithTag("toggle-register").performClick()
// Waited for, not asserted: the probe decides whether this relay asks for one.
awaitTag("email", 30_000)
compose.onNodeWithTag("email").assertExists()
}
@@ -218,9 +227,14 @@ class CrudSurfaceTest {
}
private fun signUp() {
// Switched to sign-up BEFORE the relay is typed. Typing raises the keyboard,
// which slides in after performScrollTo has measured, so the tap landed on
// the keyboard and the form stayed on sign-in. Nothing is moving yet here.
compose.onNodeWithTag("toggle-register").performScrollTo().performClick()
compose.onNodeWithTag("relay").performTextClearance()
compose.onNodeWithTag("relay").performTextInput(relay)
compose.onNodeWithTag("toggle-register").performClick()
// Waited for, not asserted: the probe decides whether this relay asks for one.
awaitTag("email", 30_000)
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
compose.onNodeWithTag("handle").performTextInput(handle)
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
@@ -48,6 +48,16 @@ class FeatureUseTest {
InstrumentationRegistry.getArguments().getString("relay")
?: "relay1.flamenet.io"
/**
* The contact, an account scripts/instrumented.sh creates for this run.
* Not a standing account: the one these suites used to share had run out of
* prekeys and filled with messages nobody collected.
*/
private val peer: String
get() =
InstrumentationRegistry.getArguments().getString("contactPeer")
?: error("no contactPeer: run scripts/instrumented.sh, which creates a fresh one")
/** A local relay is plain HTTP; anything else is not. */
private val relayScheme: String get() =
if (relay.startsWith("10.0.2.2") ||
@@ -120,8 +130,13 @@ class FeatureUseTest {
compose.onNodeWithTag("create-group").performClick()
// The group list carries a `group-<id>` row once the relay answers.
// The id is the relay's randomHexToken(), not a number. Matching
// `group-\d+` only worked when the hex happened to start with a digit,
// ten times in sixteen, so the test failed a third of the time on a
// group that was plainly there. Sixteen or more hex characters keeps
// `group-name` and `group-member-N` from matching instead.
awaitNodeMatching(
Regex("group-\\d+"),
Regex("group-[0-9a-f]{16,}"),
150_000,
"the group never appeared in the list after Create",
)
@@ -191,18 +206,22 @@ class FeatureUseTest {
compose.onNodeWithTag("add-contact").performScrollTo()
compose.onNodeWithTag("add-contact").performClick()
awaitTag("new-contact", 20_000)
compose.onNodeWithTag("new-contact").performTextInput(PEER)
compose.onNodeWithTag("new-contact").performTextInput(peer)
// Look up, THEN add.
compose.onNodeWithTag("lookup-contact").performScrollTo()
compose.onNodeWithTag("lookup-contact").performClick()
awaitTag("confirm-contact", 30_000)
// Wait for the RESULT, not the button. `confirm-contact` is on screen,
// disabled, until the handle resolves, so waiting for it returned at
// once and the tap landed on a disabled button. ChatFlowTest and
// CrudSurfaceTest already wait for this.
awaitTag("lookup-result", 30_000)
compose.onNodeWithTag("confirm-contact").performScrollTo()
compose.onNodeWithTag("confirm-contact").performClick()
// `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?",
"'$peer' never became a contact, does that account exist on $relay?",
)
}
@@ -216,9 +235,14 @@ class FeatureUseTest {
}
private fun signUp() {
// Switched to sign-up BEFORE the relay is typed. Typing raises the keyboard,
// which slides in after performScrollTo has measured, so the tap landed on
// the keyboard and the form stayed on sign-in. Nothing is moving yet here.
compose.onNodeWithTag("toggle-register").performScrollTo().performClick()
compose.onNodeWithTag("relay").performTextClearance()
compose.onNodeWithTag("relay").performTextInput(relay)
compose.onNodeWithTag("toggle-register").performClick()
// Waited for, not asserted: the probe decides whether this relay asks for one.
awaitTag("email", 30_000)
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
compose.onNodeWithTag("handle").performTextInput(handle)
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
@@ -329,9 +353,4 @@ class FeatureUseTest {
readTimeout = 8000
}.responseCode in 200..299
}.getOrDefault(false)
private companion object {
/** A standing account on relay1 that other on-device tests also use. */
const val PEER = "peerjvmone"
}
}
@@ -85,9 +85,11 @@ class LiveSignUpFleetTest {
if (!reachable(relay)) return
val name = "$handle$tag"
// Switched to sign-up BEFORE the relay is typed: the keyboard slides in after
// performScrollTo has measured, and a tap under it misses without an error.
compose.onNodeWithTag("toggle-register").performScrollTo().performClick()
compose.onNodeWithTag("relay").performTextClearance()
compose.onNodeWithTag("relay").performTextInput(relay)
compose.onNodeWithTag("toggle-register").performClick()
// Wait for discovery BEFORE deciding anything about the form.
awaitTag("identity-mode", 30_000)
@@ -0,0 +1,10 @@
package io.flamenet.messenger.app
/**
* DEBUG BUILDS ONLY. The release source set has its own copy of this object
* with no keys in it, so a release APK never trusts a file signed for testing.
*/
internal object TodayDevKey {
/** The 0.32 dev key, kid a9a94e74. Public, and for local testing only. */
val KEYS: List<String> = listOf("o6fwSoRjw0zldgUOpSClF9/Z2ySQcf0bTDtmtoDgFoA=")
}
+43
View File
@@ -103,6 +103,49 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!--
Two kinds of link open this activity.
Contact links (spec 8.12): the QR form,
flamenet:<fnid>@<relay>?u=..., and the web form,
https://<relay>/add#..., whose payload is in the fragment, which
no server ever receives. Not verified app links: any relay can
issue one, so Android asks which app to open it with.
Hello links (SPEC 6.34): flamenetmessenger://hi#<address>, which the
relay's /hi page links to. Any page or app can open one, so all it
does is fill in the add screen; nothing is sent until the person
chooses to write. The whole scheme is claimed so that a malformed
link reaches the app and gets a plain answer.
-->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="flamenet" />
<data android:scheme="flamenetmessenger" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="*.flamenet.io" android:pathPrefix="/add" />
</intent-filter>
<!--
The relays the app ships pointing at, verified: each serves
/.well-known/assetlinks.json naming this package and the release
certificate, so their links open here without asking. Separate
from the wildcard filter above, which cannot be verified.
-->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="relay.flamenet.io" />
<data android:host="relay1.flamenet.io" />
<data android:pathPrefix="/add" />
</intent-filter>
</activity>
<!--
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
{
"pack": "flamenet.retro",
"name": "FlameNet",
"fallback": "🙂",
"stickers": [
{ "id": "flame", "glyph": "🔥", "name": "Flame", "image": "flame.png" },
{ "id": "heart", "glyph": "❤️", "name": "Heart", "image": "heart.png" },
{ "id": "thumbs-up", "glyph": "👍", "name": "Thumbs up", "image": "thumbs-up.png" },
{ "id": "lol", "glyph": "😂", "name": "Laughing", "image": "lol.png" },
{ "id": "brb", "glyph": "🔙", "name": "Back soon", "image": "brb.png" },
{ "id": "new", "glyph": "🆕", "name": "New", "image": "new.png" },
{ "id": "star", "glyph": "⭐", "name": "Star", "image": "star.png" },
{ "id": "trophy", "glyph": "🏆", "name": "Trophy", "image": "trophy.png" },
{ "id": "cake", "glyph": "🎂", "name": "Birthday cake", "image": "cake.png" },
{ "id": "balloon", "glyph": "🎈", "name": "Balloon", "image": "balloon.png" },
{ "id": "gift", "glyph": "🎁", "name": "Gift", "image": "gift.png" },
{ "id": "flowers", "glyph": "💐", "name": "Flowers", "image": "flowers.png" },
{ "id": "love-letter", "glyph": "💌", "name": "Love letter", "image": "love-letter.png" },
{ "id": "music", "glyph": "🎶", "name": "Music", "image": "music.png" },
{ "id": "sun", "glyph": "☀️", "name": "Sun", "image": "sun.png" },
{ "id": "moon", "glyph": "🌙", "name": "Moon", "image": "moon.png" },
{ "id": "coffee", "glyph": "☕", "name": "Coffee", "image": "coffee.png" },
{ "id": "pizza", "glyph": "🍕", "name": "Pizza", "image": "pizza.png" },
{ "id": "candle", "glyph": "🕯️", "name": "Candle", "image": "candle.png" },
{ "id": "hourglass", "glyph": "⏳", "name": "Hourglass", "image": "hourglass.png" },
{ "id": "computer", "glyph": "🖥️", "name": "Computer", "image": "computer.png" },
{ "id": "floppy", "glyph": "💾", "name": "Floppy disk", "image": "floppy.png" },
{ "id": "construction", "glyph": "🚧", "name": "Under construction", "image": "construction.png" },
{ "id": "paper-plane", "glyph": "✈️", "name": "Paper plane", "image": "paper-plane.png" }
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

@@ -0,0 +1,28 @@
package io.flamenet.messenger.app
import io.flamenet.e2e.Vault
/** What happens to the vault when somebody signs in. */
object AccountSwitch {
/**
* Before a different account takes over this install, forget the last
* one's records: its conversations, sessions, delivery keys, drafts and
* handle certificate. A plain sign-out keeps the vault so the SAME person
* can sign back in; another account on the same install got a new device
* but inherited the rest, saw the last account's conversations and sent
* on its ratchet sessions, which the far end could not open. The last
* account's device was already replaced at this point, so what is
* forgotten could not have been used by it again.
*
* True when something was forgotten.
*/
fun forgetPreviousAccountIfSwitching(
vault: Vault,
previous: Vault.Credential?,
sameAccount: Boolean,
): Boolean {
if (previous == null || sameAccount) return false
vault.forgetEverything()
return true
}
}
@@ -3,6 +3,7 @@ package io.flamenet.messenger.app
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
@@ -24,33 +25,34 @@ 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.res.stringResource
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 io.flamenet.messenger.R
import android.graphics.Color as AndroidColor
/**
* Your address, as a code somebody can scan and a string they can copy.
* Your address, as a code somebody can scan and a string they can copy, and
* the hello link (§6.34) that carries it.
*/
@Composable
fun AddressCard(
fnid: String?,
relay: String,
handle: String? = null,
) {
val context = LocalContext.current
Column(Modifier.fillMaxWidth()) {
Text("Your address", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.social_your_address), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(Space.sm))
if (fnid == null) {
// 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.",
stringResource(R.string.social_address_not_derived, relay),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag("address-missing"),
)
@@ -58,13 +60,11 @@ fun AddressCard(
}
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.
val encoded =
"flamenet:$address" +
(handle?.takeIf { it.isNotBlank() }?.let { "?u=$it" } ?: "")
val bitmap = remember(encoded) { qrBitmap(encoded) }
// The code holds the hello link, so a phone's own camera opens the
// relay's page for it and this app's scanner reads the address out of
// it. Null only for an address that is not one, which nothing derives.
val hello = HelloLink.forAddress(address)
val bitmap = remember(hello, address) { qrBitmap(hello ?: address) }
Column(
modifier = Modifier.fillMaxWidth(),
@@ -73,7 +73,12 @@ fun AddressCard(
if (bitmap != null) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "Your address as a QR code",
contentDescription =
if (hello != null) {
stringResource(R.string.social_address_qr_hello_desc)
} else {
stringResource(R.string.social_address_qr_desc)
},
// None, or the modules blur when scaled up and the code
// stops scanning, the default filtering is smooth.
filterQuality = FilterQuality.None,
@@ -104,20 +109,75 @@ fun AddressCard(
OutlinedButton(
onClick = { copy(context, "Flamenet address", address) },
modifier = Modifier.testTag("copy-address"),
) { Text("Copy address") }
) { Text(stringResource(R.string.social_copy_address)) }
}
}
Spacer(Modifier.height(Space.sm))
Text(
"Derived from your passphrase, not issued by a server. Sign in anywhere " +
"with the same passphrase and this stays the same, which is what " +
"lets it outlive any one relay.",
stringResource(R.string.social_address_explainer),
style = MaterialTheme.typography.bodySmall,
)
if (hello != null) {
Spacer(Modifier.height(Space.lg))
HelloLinkSection(hello)
}
}
}
/**
* The hello link: your address as a link anyone can open, to put on a FlameNet
* profile or anywhere else.
*/
@Composable
private fun HelloLinkSection(link: String) {
val context = LocalContext.current
Text("Hello link", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(Space.xs))
Text(
link,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier.testTag("hello-link"),
)
Spacer(Modifier.height(Space.sm))
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
OutlinedButton(
onClick = { copy(context, "Flamenet hello link", link) },
modifier = Modifier.testTag("copy-hello-link"),
) { Text("Copy link") }
OutlinedButton(
onClick = { share(context, link) },
modifier = Modifier.testTag("share-hello-link"),
) { Text("Share link") }
}
Spacer(Modifier.height(Space.sm))
Text(
HELLO_LINK_EXPLAINED,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag("hello-link-explained"),
)
}
/** What a hello link does, and what it does not. */
internal const val HELLO_LINK_EXPLAINED =
"Put it on your FlameNet profile, or anywhere you like. Whoever opens it can " +
"ask to message you. FlameNet doesn't learn anything from the messenger, and " +
"the messenger doesn't learn your FlameNet name."
/** The system's share sheet. Nothing is sent by this app. */
private fun share(
context: Context,
link: String,
) {
val send =
Intent(Intent.ACTION_SEND)
.setType("text/plain")
.putExtra(Intent.EXTRA_TEXT, link)
runCatching { context.startActivity(Intent.createChooser(send, "Share your hello link")) }
}
private fun copy(
context: Context,
label: String,
@@ -130,7 +190,7 @@ private fun copy(
/**
* One module per pixel plus a quiet zone; Compose scales it up.
*/
private fun qrBitmap(
internal fun qrBitmap(
text: String,
quiet: Int = 4,
): Bitmap? {
@@ -15,10 +15,12 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import io.flamenet.messenger.R
/**
* A fingerprint, a face, or the device PIN in front of the app.
@@ -160,9 +162,9 @@ class AppLock(
if (seconds <= 0) return null
return if (seconds >= 60) {
val minutes = (seconds + 59) / 60
"Too many attempts. Try again in $minutes ${if (minutes == 1) "minute" else "minutes"}."
context.resources.getQuantityString(R.plurals.set_lock_wait_minutes, minutes, minutes)
} else {
"Too many attempts. Try again in $seconds ${if (seconds == 1) "second" else "seconds"}."
context.resources.getQuantityString(R.plurals.set_lock_wait_seconds, seconds, seconds)
}
}
@@ -218,8 +220,8 @@ class AppLock(
prompt.authenticate(
BiometricPrompt.PromptInfo
.Builder()
.setTitle("Unlock Flamenet")
.setSubtitle("Your messages are locked on this device.")
.setTitle(activity.getString(R.string.set_lock_prompt_title))
.setSubtitle(activity.getString(R.string.set_lock_prompt_subtitle))
.setAllowedAuthenticators(AUTHENTICATORS)
.build(),
)
@@ -243,14 +245,14 @@ fun LockScreen(onUnlock: () -> Unit) {
modifier = Modifier.size(Space.xxl),
)
Spacer(Modifier.height(Space.lg))
Text("Flamenet is locked", fontSize = 20.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.set_lock_locked_title), fontSize = 20.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.height(Space.sm))
Text(
"Unlock this device to read your messages.",
stringResource(R.string.set_lock_locked_body),
fontSize = 14.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.xl))
KitPrimaryButton(text = "Unlock", onClick = onUnlock, tag = "unlock")
KitPrimaryButton(text = stringResource(R.string.common_unlock), onClick = onUnlock, tag = "unlock")
}
}
@@ -16,9 +16,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import io.flamenet.client.UpdateCheck
import io.flamenet.messenger.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -195,19 +197,11 @@ fun UpdateAvailableDialog(baseUrl: String?) {
AlertDialog(
onDismissRequest = { showing = false },
title = { Text("Version ${release.versionName} is out") },
title = { Text(stringResource(R.string.set_update_version_out, release.versionName)) },
text = {
val newerBuild = stringResource(R.string.set_update_newer_build)
Text(
buildString {
append(
release.notes.ifBlank {
"A newer build of Flamenet Messenger is published."
},
)
append("\n\nThis app installs by hand rather than through a store, ")
append("so it cannot update itself. Downloading opens your browser; ")
append("installing over the top keeps your messages and your account.")
},
stringResource(R.string.set_update_body, release.notes.ifBlank { newerBuild }),
fontSize = 14.sp,
)
},
@@ -218,7 +212,7 @@ fun UpdateAvailableDialog(baseUrl: String?) {
showing = false
},
modifier = Modifier.testTag("update-download"),
) { Text("Download", fontWeight = FontWeight.Medium) }
) { Text(stringResource(R.string.set_update_download), fontWeight = FontWeight.Medium) }
},
dismissButton = {
TextButton(
@@ -228,7 +222,7 @@ fun UpdateAvailableDialog(baseUrl: String?) {
showing = false
},
modifier = Modifier.testTag("update-later"),
) { Text("Not now", color = MaterialTheme.colorScheme.onSurfaceVariant) }
) { Text(stringResource(R.string.common_not_now), color = MaterialTheme.colorScheme.onSurfaceVariant) }
},
)
}
@@ -0,0 +1,28 @@
package io.flamenet.messenger.app
import android.content.Context
import android.graphics.BitmapFactory
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
/**
* Pictures shipped in the app's assets (sticker art, card fronts), decoded
* once and kept. A path that will not load is remembered as missing, so the
* caller draws its fallback without trying again on every frame.
*/
object AssetImages {
private val cache = java.util.concurrent.ConcurrentHashMap<String, java.util.Optional<ImageBitmap>>()
fun load(
context: Context,
path: String,
): ImageBitmap? =
cache
.getOrPut(path) {
java.util.Optional.ofNullable(
runCatching {
context.assets.open(path).use { BitmapFactory.decodeStream(it)?.asImageBitmap() }
}.getOrNull(),
)
}.orElse(null)
}
@@ -357,6 +357,8 @@ class CallManager(
}
}
// The one place every call path builds its connection (§24.6).
if (CallPolicy.bridgeRefusal() != null) return null
val pc = ensureFactory().createPeerConnection(config, observer) ?: return null
val source = ensureFactory().createAudioSource(MediaConstraints())
val track = ensureFactory().createAudioTrack("fnaudio", source)
@@ -413,6 +415,7 @@ class CallManager(
// Supervision: enforced where the call would start, not where the
// button is drawn, every entry point hits the same wall.
if (ParentalControls.callsDisabled(context)) return
if (CallPolicy.bridgeRefusal() != null) return
if (state != State.IDLE && state != State.ENDED) return
val callId = UUID.randomUUID().toString()
// `current` BEFORE the state change.
@@ -494,7 +497,7 @@ class CallManager(
Content.CallSignal.OFFER -> {
// Supervision: a device with calls off does not ring, the
// caller is answered busy, like any other unavailable state.
if (ParentalControls.callsDisabled(context)) {
if (ParentalControls.callsDisabled(context) || CallPolicy.bridgeRefusal() != null) {
runCatching {
signal(
fromUser,
@@ -967,6 +970,7 @@ class CallManager(
}
fun accept(turn: JSONObject?) {
if (CallPolicy.bridgeRefusal() != null) return
val (fromUser, offer) = pendingOffer ?: return
pendingOffer = null
tellOwnDevices?.invoke(
@@ -0,0 +1,13 @@
package io.flamenet.messenger.app
import io.flamenet.client.AroundTheBridge
/**
* Whether a call may connect now. A call's media goes to the other person or
* a TURN server, never through a bridge, so while one is saved calls are off
* (SPEC §24.6).
*/
object CallPolicy {
/** Why a call cannot connect now, or null. */
fun bridgeRefusal(): String? = if (AroundTheBridge.active) AroundTheBridge.CALLS_OFF else null
}
@@ -30,12 +30,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.flamenet.messenger.R
import kotlinx.coroutines.delay
/**
@@ -98,7 +100,7 @@ fun CallScreenBody(
Text(status, fontSize = 14.sp, color = Palette.SecondaryText)
// Said plainly and always: it is the reason to use this at all.
Text(
"End-to-end encrypted",
stringResource(R.string.set_call_e2e),
fontSize = 10.sp,
color = Palette.SecondaryText,
modifier = Modifier.padding(top = 2.dp),
@@ -215,23 +217,23 @@ fun LiveCallScreen(
val status =
when (call.state) {
CallManager.State.RINGING -> {
if (call.incoming) "Incoming call" else "Ringing…"
if (call.incoming) stringResource(R.string.set_call_incoming) else stringResource(R.string.set_call_ringing)
}
CallManager.State.DIALLING -> {
"Calling…"
stringResource(R.string.set_call_calling)
}
CallManager.State.CONNECTING -> {
"Connecting…"
stringResource(R.string.common_connecting)
}
CallManager.State.ACTIVE -> {
"On a call"
stringResource(R.string.set_call_on_a_call)
}
else -> {
"Call ended"
stringResource(R.string.set_call_ended)
}
}
@@ -242,7 +244,7 @@ fun LiveCallScreen(
CallAction(
"decline",
KitIcon.CallEnd,
"Decline",
stringResource(R.string.common_decline),
tint = MaterialTheme.colorScheme.error,
iconColor = Color.White,
run = model::declineCall,
@@ -250,7 +252,7 @@ fun LiveCallScreen(
CallAction(
"accept",
KitIcon.Call,
"Answer",
stringResource(R.string.common_answer),
isPrimary = true,
tint = Palette.Teal,
iconColor = Color.White,
@@ -264,7 +266,7 @@ fun LiveCallScreen(
CallAction(
"mute",
if (call.muted) KitIcon.MicOff else KitIcon.Mic,
if (call.muted) "Unmute" else "Mute",
if (call.muted) stringResource(R.string.common_unmute) else stringResource(R.string.common_mute),
tint = if (call.muted) MaterialTheme.colorScheme.primary else null,
iconColor = if (call.muted) Color.White else null,
run = { model.setCallMuted(!call.muted) },
@@ -272,7 +274,7 @@ fun LiveCallScreen(
CallAction(
"end",
KitIcon.CallEnd,
"End",
stringResource(R.string.set_call_end),
isPrimary = true,
tint = MaterialTheme.colorScheme.error,
iconColor = Color.White,
@@ -287,7 +289,7 @@ fun LiveCallScreen(
CallAction(
"handoff",
KitIcon.Handoff,
"Move",
stringResource(R.string.set_call_move),
run = model::offerHandoff,
),
)
@@ -301,7 +303,7 @@ fun LiveCallScreen(
CallAction(
"end",
KitIcon.CallEnd,
"Cancel",
stringResource(R.string.common_cancel),
isPrimary = true,
tint = MaterialTheme.colorScheme.error,
iconColor = Color.White,
@@ -346,8 +348,7 @@ fun LiveCallScreen(
floating = floating,
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."
stringResource(R.string.set_call_no_relay_warning)
} else {
null
},
@@ -381,7 +382,7 @@ private fun VideoCallBody(
}
} else {
Text(
"Waiting for their camera…",
stringResource(R.string.set_call_waiting_camera),
color = Color.White,
modifier = Modifier.align(Alignment.Center),
)
@@ -558,9 +559,9 @@ fun ColumnScope.HandoffBanner(
horizontalArrangement = Arrangement.spacedBy(Space.sm),
) {
Column(modifier = Modifier.weight(1f)) {
Text("Take the call?", fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
Text(stringResource(R.string.set_call_take_title), fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
Text(
"On your other device, with ${phase.call.peerName}",
stringResource(R.string.set_call_take_other_device, phase.call.peerName),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
@@ -568,7 +569,7 @@ fun ColumnScope.HandoffBanner(
TextButton(
onClick = { model.takeHandoff() },
modifier = Modifier.testTag("handoff-take"),
) { Text("Take") }
TextButton(onClick = { model.dismissHandoff() }) { Text("Not now") }
) { Text(stringResource(R.string.set_call_take)) }
TextButton(onClick = { model.dismissHandoff() }) { Text(stringResource(R.string.common_not_now)) }
}
}
@@ -22,10 +22,12 @@ 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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.flamenet.messenger.R
/**
* Calls placed and received, the kit's `08 Calls-Listing`.
@@ -41,11 +43,11 @@ fun ColumnScope.CallsScreen(
androidx.compose.runtime.mutableStateOf(false)
}
PortalHeader(title = "Calls") {
PortalHeader(title = stringResource(R.string.set_calls_title)) {
// The way IN.
PortalHeaderIcon(
icon = KitIcon.Call,
contentDescription = "New call",
contentDescription = stringResource(R.string.set_calls_new_call),
modifier = Modifier.testTag("new-call"),
onClick = { showNewCall.value = true },
)
@@ -54,10 +56,10 @@ fun ColumnScope.CallsScreen(
if (showNewCall.value) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showNewCall.value = false },
title = { Text("New call") },
title = { Text(stringResource(R.string.set_calls_new_call)) },
text = {
if (phase.buddies.isEmpty()) {
Text("Add a contact first, calls go to people on your list.")
Text(stringResource(R.string.set_calls_add_contact_first))
} else {
Column {
phase.buddies.forEach { buddy ->
@@ -87,7 +89,7 @@ fun ColumnScope.CallsScreen(
Spacer(Modifier.width(Space.md))
Icon(
KitIcon.Video,
contentDescription = "Video call",
contentDescription = stringResource(R.string.set_calls_video_call),
tint = Palette.Accent,
modifier =
Modifier
@@ -105,7 +107,7 @@ fun ColumnScope.CallsScreen(
confirmButton = {
androidx.compose.material3.TextButton(
onClick = { showNewCall.value = false },
) { Text("Cancel") }
) { Text(stringResource(R.string.common_cancel)) }
},
)
}
@@ -123,12 +125,9 @@ fun ColumnScope.CallsScreen(
tint = Palette.SecondaryText,
modifier = Modifier.size(Space.xxl),
)
Text("No calls yet", fontSize = 16.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.set_calls_empty_title), 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.",
stringResource(R.string.set_calls_empty_body),
fontSize = 14.sp,
color = Palette.SecondaryText,
)
@@ -142,7 +141,7 @@ fun ColumnScope.CallsScreen(
phase.buddies
.firstOrNull { it.userId == entry.peerId }
?.let { it.displayName.ifBlank { it.handle } }
?: "user ${entry.peerId}"
?: stringResource(R.string.set_calls_user_fallback, entry.peerId)
PortalRow(
modifier =
Modifier
@@ -176,12 +175,15 @@ fun ColumnScope.CallsScreen(
)
Spacer(Modifier.width(6.dp))
Text(
buildString {
append(if (entry.incoming) "Incoming" else "Outgoing")
if (!entry.connected) append(" · missed")
append(" · ")
append(relativeTime(entry.at))
},
stringResource(
when {
entry.incoming && entry.connected -> R.string.set_calls_line_incoming
entry.incoming -> R.string.set_calls_line_incoming_missed
entry.connected -> R.string.set_calls_line_outgoing
else -> R.string.set_calls_line_outgoing_missed
},
relativeTime(entry.at),
),
fontSize = 12.sp,
color = if (entry.connected) Palette.SecondaryText else Palette.Pink,
maxLines = 1,
@@ -201,7 +203,7 @@ fun ColumnScope.CallsScreen(
) {
Icon(
KitIcon.Video,
contentDescription = "Video call $name",
contentDescription = stringResource(R.string.set_calls_video_call_name, name),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(Kit.icon),
)
@@ -219,7 +221,7 @@ fun ColumnScope.CallsScreen(
) {
Icon(
KitIcon.Call,
contentDescription = "Call $name",
contentDescription = stringResource(R.string.set_calls_call_name, name),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(Kit.icon),
)
@@ -0,0 +1,91 @@
package io.flamenet.messenger.app
import io.flamenet.e2e.Content
import java.text.DateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* The nine card fronts (§6.31): the flamenet.io drawings, shipped as
* `assets/cards/<design>.png`, with the title drawn by the app as text, the
* same titles as the site.
*/
object CardArt {
private val TITLES =
mapOf(
"birthday" to "Happy Birthday",
"thanks" to "Thank You",
"thinking" to "Thinking of You",
"congrats" to "Congratulations",
"getwell" to "Get Well Soon",
"love" to "With Love",
"season" to "Season's Greetings",
"hello" to "Hello!",
"sympathy" to "With Sympathy",
)
/** What each drawing shows, for TalkBack. */
private val DRAWINGS =
mapOf(
"birthday" to "three balloons and confetti",
"thanks" to "three flowers",
"thinking" to "a crescent moon and stars",
"congrats" to "a bright star burst",
"getwell" to "the sun and a cloud",
"love" to "a big heart and small hearts",
"season" to "a tree in the snow",
"hello" to "a paper plane",
"sympathy" to "a lit candle",
)
/** The front's title. A design this build does not know is titled as `hello`. */
fun title(design: String): String = TITLES[design] ?: TITLES.getValue(Content.Card.FALLBACK_DESIGN)
fun drawing(design: String): String = DRAWINGS[design] ?: DRAWINGS.getValue(Content.Card.FALLBACK_DESIGN)
/** The front's picture in the app's assets. */
fun asset(design: String): String = "cards/${if (design in TITLES) design else Content.Card.FALLBACK_DESIGN}.png"
/**
* What a quote, a pin or the conversation list says about a card: what is
* on the front, never the words inside, which may still be sealed.
*/
fun summary(card: Content.Card): String = "Card: " + title(card.shownDesign)
/** A date as the reader's calendar writes it: "January 1, 2026". */
fun day(
millis: Long,
zone: TimeZone = TimeZone.getDefault(),
locale: Locale = Locale.getDefault(),
): String =
DateFormat
.getDateInstance(DateFormat.LONG, locale)
.apply { timeZone = zone }
.format(Date(millis))
/**
* The words above a card that is still closed. The receiver is told when
* it opens; the sender is told that their copy is open and the other one
* is not.
*/
fun sealedLine(
mine: Boolean,
opensOn: String,
forName: String,
): String =
if (mine) {
"Sealed until $opensOn for $forName"
} else {
"Opens on $opensOn"
}
/**
* What sealing is and is not, said where the date is chosen and again on
* a closed card. The words are already on the other phone; the app keeps
* the card closed, the way a story's app hides it after a day.
*/
const val SEALED_HONESTY =
"The words are already on their phone when it arrives. Their app keeps " +
"the card closed until that day. It is a courtesy between apps, not a lock."
}
@@ -0,0 +1,379 @@
package io.flamenet.messenger.app
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SelectableDates
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.flamenet.e2e.Content
import kotlinx.coroutines.delay
import java.time.LocalDate
import java.time.ZoneId
/** The longest a closed card waits before looking at the clock again, in millis. */
private const val SEALED_RECHECK_MS = 60_000L
/**
* A card in a conversation (§6.31): its front and title, and while it is
* sealed, when it opens. It opens by itself when the day comes; nothing has to
* arrive for that.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CardBubble(
line: Session.Line,
card: Content.Card,
/** Whom the sender's copy names: the card's own `to`, else the chat. */
forName: String,
onOpen: () -> Unit,
onSealed: (opensOn: String) -> Unit,
onLongPress: () -> Unit,
) {
val context = LocalContext.current
val opensAt = remember(card, line.at) { card.opensAtMillis(line.at) }
var now by remember(line.at) { mutableLongStateOf(System.currentTimeMillis()) }
// Looked at again at least once a minute, so a phone that slept through
// the moment still opens the card within a minute of being looked at.
val waitingFor = if (line.mine) card.open?.let { it * 1000 } else opensAt
if (waitingFor != null && now < waitingFor) {
LaunchedEffect(waitingFor) {
while (now < waitingFor) {
delay(minOf(waitingFor - now, SEALED_RECHECK_MS).coerceAtLeast(1))
now = System.currentTimeMillis()
}
}
}
val sealed = card.isSealed(line.mine, line.at, now)
val title = CardArt.title(card.shownDesign)
val status =
when {
sealed && opensAt != null -> CardArt.sealedLine(mine = false, opensOn = CardArt.day(opensAt), forName = forName)
line.mine && waitingFor != null && now < waitingFor ->
CardArt.sealedLine(mine = true, opensOn = CardArt.day(waitingFor), forName = forName)
else -> null
}
val front = remember(card.shownDesign) { AssetImages.load(context, CardArt.asset(card.shownDesign)) }
val shape = RoundedCornerShape(Kit.bubbleRadius)
Column(
modifier =
Modifier
.width(240.dp)
.clip(shape)
.background(MaterialTheme.colorScheme.surface)
.border(Kit.hairline, Portal.rule(), shape)
.combinedClickable(
onClick = { if (sealed && opensAt != null) onSealed(CardArt.day(opensAt)) else onOpen() },
onLongClick = onLongPress,
).semantics {
contentDescription =
"Card, $title, a drawing of ${CardArt.drawing(card.shownDesign)}. " +
(status ?: "Double tap to open.")
}.testTag("card-${line.id ?: line.at}"),
) {
if (front != null) {
Image(
bitmap = front,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().aspectRatio(4f / 3f),
)
} else {
Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).background(Palette.SurfaceAlt))
}
Column(modifier = Modifier.padding(Kit.bubblePadding)) {
Text(title, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
Text(
status ?: "Tap to open",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag(if (sealed) "card-sealed" else "card-status"),
)
}
}
}
/** The inside of an open card, the way the site lays it out. */
@Composable
fun CardInsideDialog(
card: Content.Card,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(CardArt.title(card.shownDesign)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()).testTag("card-inside"),
verticalArrangement = Arrangement.spacedBy(Space.sm),
) {
card.shownTo?.let { Text("Dear $it,", fontSize = 15.sp, modifier = Modifier.testTag("card-to")) }
if (card.shownMsg.isNotBlank()) {
Text(card.shownMsg, fontSize = 16.sp, modifier = Modifier.testTag("card-msg"))
}
card.shownFrom?.let {
Text(
"From $it",
fontSize = 15.sp,
fontStyle = FontStyle.Italic,
textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth().testTag("card-from"),
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss, modifier = Modifier.testTag("card-close")) { Text("Close") }
},
)
}
/** What a closed card says when somebody taps it. */
@Composable
fun SealedCardDialog(
opensOn: String,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Opens on $opensOn") },
text = {
Text(
"Its words are already on this phone. The app keeps the card closed until " +
"that day, as a courtesy to whoever sent it. It is not a lock.",
fontSize = 14.sp,
modifier = Modifier.testTag("card-sealed-note"),
)
},
confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } },
)
}
/**
* Make a card: a design, the words, and optionally a day before which the
* other app keeps it closed (§6.31, at most 400 days ahead).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewCardDialog(
defaultTo: String,
defaultFrom: String,
onDismiss: () -> Unit,
onSend: (Content.Card) -> Unit,
) {
val context = LocalContext.current
var design by remember { mutableStateOf(Content.Card.DESIGNS.first()) }
var to by remember { mutableStateOf(defaultTo.take(Content.Card.MAX_TO)) }
var msg by remember { mutableStateOf("") }
var from by remember { mutableStateOf(defaultFrom.take(Content.Card.MAX_FROM)) }
var sealed by remember { mutableStateOf(false) }
var sealDay by remember { mutableStateOf<LocalDate?>(null) }
var choosingDay by remember { mutableStateOf(false) }
val today = LocalDate.now()
val firstDay = today.plusDays(1)
val lastDay = today.plusDays(Content.Card.MAX_SEAL_DAYS)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Send a card") },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()).testTag("new-card"),
verticalArrangement = Arrangement.spacedBy(Space.sm),
) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
items(Content.Card.DESIGNS, key = { it }) { d ->
val picture = remember(d) { AssetImages.load(context, CardArt.asset(d)) }
val chosen = d == design
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier =
Modifier
.width(96.dp)
.clickable { design = d }
.semantics {
contentDescription = "${CardArt.title(d)}, ${CardArt.drawing(d)}"
selected = chosen
}.testTag("card-design-$d"),
) {
Box(
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(4f / 3f)
.border(
if (chosen) 3.dp else Kit.hairline,
if (chosen) Palette.Accent else Portal.rule(),
),
) {
if (picture != null) {
Image(picture, null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxWidth())
}
}
Text(
CardArt.title(d),
fontSize = 11.sp,
maxLines = 1,
fontWeight = if (chosen) FontWeight.Bold else FontWeight.Normal,
)
}
}
}
OutlinedTextField(
value = to,
onValueChange = { to = it.take(Content.Card.MAX_TO) },
label = { Text("To") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("card-to-field"),
)
OutlinedTextField(
value = msg,
onValueChange = { msg = it.take(Content.Card.MAX_MSG) },
label = { Text("Message") },
minLines = 3,
modifier = Modifier.fillMaxWidth().testTag("card-msg-field"),
)
OutlinedTextField(
value = from,
onValueChange = { from = it.take(Content.Card.MAX_FROM) },
label = { Text("From") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("card-from-field"),
)
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(
checked = sealed,
onCheckedChange = {
sealed = it
if (it && sealDay == null) choosingDay = true
},
modifier = Modifier.testTag("card-seal-switch"),
)
Spacer(Modifier.width(Space.sm))
Text("Keep it sealed until", fontSize = 14.sp, modifier = Modifier.weight(1f))
}
if (sealed) {
TextButton(
onClick = { choosingDay = true },
modifier = Modifier.testTag("card-seal-day"),
) {
Text(sealDay?.let { CardArt.day(it.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()) } ?: "Choose a day")
}
Text(CardArt.SEALED_HONESTY, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
},
confirmButton = {
TextButton(
enabled = !sealed || sealDay != null,
onClick = {
val now = System.currentTimeMillis()
val open =
if (sealed) {
sealDay?.let { Content.Card.openValue(it.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(), now) }
} else {
null
}
onSend(
Content.Card(
design = design,
msg = msg.trim(),
to = to.trim().takeIf { it.isNotEmpty() },
from = from.trim().takeIf { it.isNotEmpty() },
open = open,
),
)
},
modifier = Modifier.testTag("card-send"),
) { Text("Send") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
if (choosingDay) {
val state =
rememberDatePickerState(
initialSelectedDateMillis = (sealDay ?: firstDay).toEpochDay() * DAY_MILLIS,
selectableDates =
object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean =
(utcTimeMillis / DAY_MILLIS) in firstDay.toEpochDay()..lastDay.toEpochDay()
override fun isSelectableYear(year: Int): Boolean = year in firstDay.year..lastDay.year
},
)
DatePickerDialog(
onDismissRequest = {
choosingDay = false
if (sealDay == null) sealed = false
},
confirmButton = {
TextButton(
onClick = {
state.selectedDateMillis?.let { sealDay = LocalDate.ofEpochDay(it / DAY_MILLIS) }
choosingDay = false
if (sealDay == null) sealed = false
},
modifier = Modifier.testTag("card-seal-day-ok"),
) { Text("OK") }
},
dismissButton = {
TextButton(onClick = {
choosingDay = false
if (sealDay == null) sealed = false
}) { Text("Cancel") }
},
) {
DatePicker(state = state)
}
}
}
private const val DAY_MILLIS = 86_400_000L
@@ -0,0 +1,444 @@
package io.flamenet.messenger.app
import io.flamenet.e2e.Content
import io.flamenet.games.DrawReason
import io.flamenet.games.GamePosition
import io.flamenet.games.GameType
import io.flamenet.games.Games
import io.flamenet.games.MoveResult
import io.flamenet.games.Outcome
import io.flamenet.games.ReplayResult
import io.flamenet.games.Side
import io.flamenet.games.WinReason
import org.json.JSONArray
import org.json.JSONObject
/**
* Games in a one-to-one chat (§6.33), as rules a conversation applies.
*
* Only the log travels. Each side replays it with the capped engine replay
* and checks that whatever a peer added is legal and is theirs to play, so a
* game can be rebuilt from any one envelope and a peer cannot move for you.
*/
object ChatGames {
const val RESIGN = "resign"
const val DRAW_OFFER = "draw-offer"
const val DRAW_ACCEPT = "draw-accept"
const val DRAW_DECLINE = "draw-decline"
const val DECLINE = "decline"
const val CANCEL = "cancel"
/** The endings that finish a game. An offer and its refusal do not. */
val FINISHING = setOf(RESIGN, DRAW_ACCEPT, DECLINE, CANCEL)
/** One game, as this person's phone holds it. Everything else is replayed from [log]. */
data class State(
val id: String,
val type: GameType,
/** The side this person plays. */
val me: Side,
val log: List<String> = emptyList(),
/** Whether this person started the game. */
val startedByMe: Boolean,
/** The ending that finished it, one of [FINISHING]. */
val ending: String? = null,
/** Who sent [ending]. Null when not known, as from a backup that did not say. */
val endingBy: Side? = null,
/** A draw offered and not yet answered, by this side. */
val offerBy: Side? = null,
/** The two boards stopped agreeing. Nothing more is taken for this game. */
val outOfStep: Boolean = false,
) {
val them: Side get() = me.opponent
/** The position the log reaches, or null when it does not replay. */
val position: GamePosition? by lazy { (Games.replay(type, log) as? ReplayResult.Replayed)?.position }
val outcome: Outcome get() = position?.let(Games::outcome) ?: Outcome.Ongoing
val finished: Boolean get() = ending in FINISHING || outcome.isOver
/** The person who did not start it. Their first move accepts the game. */
val invitee: Side get() = if (startedByMe) them else me
val accepted: Boolean get() = hasMoved(invitee)
/** Whether the game waits on this person now. */
val myMove: Boolean get() = !finished && !outOfStep && position?.sideToMove == me
/** The moves this person may make now, empty when it is not their move. */
val myMoves: List<String> get() = if (myMove) position?.let(Games::legalMoves).orEmpty() else emptyList()
fun hasMoved(side: Side): Boolean = log.indices.any { type.sideOfPly(it) == side }
}
/** What one move or ending from the other side did. */
sealed interface Received {
/** Their move; the game goes on and it is this person's turn. */
data class Moved(
val state: State,
) : Received
/** This envelope finished the game. */
data class Ended(
val state: State,
) : Received
/** The other side offers a draw. Shown without a sound (§6.33). */
data class Offered(
val state: State,
) : Received
/** One of ours turned down. Redrawn, not news. */
data class Quiet(
val state: State,
) : Received
/** The boards no longer agree. */
data class OutOfStep(
val state: State,
) : Received
/** A duplicate, a stale log, or nothing this side may send now. */
data object Ignored : Received
}
/**
* Apply a `gmove` or `gend` from the other side, whose side is the
* envelope's `mine` (§6.33, receiving, rules 1 to 4).
*
* An ending is judged at the log it carries, once that log is in step
* (DESIGN items 22 and 23). Moves it carries are taken even when the
* ending itself does not count.
*
* @param current what this chat holds for the game id, or null when it
* holds nothing, in which case the game is rebuilt from the log.
*/
fun receive(
current: State?,
kind: String,
game: Content.Game,
): Received {
val type = GameType.fromWire(game.type) ?: return Received.Ignored
val sender = Side.fromWire(game.mine) ?: return Received.Ignored
// DESIGN 37: a move carries the log it reaches, never none or an
// empty one; an ending carries the log it was sent at, which may be
// empty, but never none.
val log = game.log ?: return Received.Ignored
if (kind == Content.Kind.GAME_MOVE && log.isEmpty()) return Received.Ignored
val ending = if (kind == Content.Kind.GAME_END) game.ending ?: return Received.Ignored else null
// Rule 1: an unknown game is rebuilt from this envelope. Only the
// invitee can decline, so a decline makes this person the starter.
if (current == null) {
val fresh = State(game.id, type, me = sender.opponent, startedByMe = ending == DECLINE)
if (Games.replay(type, log) !is ReplayResult.Replayed) return Received.OutOfStep(fresh.copy(outOfStep = true))
return withEnding(fresh.copy(log = log), sender, ending, log)
}
if (current.outOfStep || current.finished) return Received.Ignored
// The other side must play the other side, in the same game.
if (type != current.type || sender != current.them) return Received.OutOfStep(current.copy(outOfStep = true))
val known = current.log
// Rule 2: no longer than what is known.
if (log.size <= known.size) {
if (log != known.subList(0, log.size)) return Received.OutOfStep(current.copy(outOfStep = true))
return if (ending == null) Received.Ignored else applyEnding(current, sender, ending, log)
}
// Rule 3: what is known has to be where the new log starts.
if (log.subList(0, known.size) != known) return Received.OutOfStep(current.copy(outOfStep = true))
// Rule 4: every added ply is theirs, and the whole log replays within the cap.
for (index in known.size until log.size) {
if (type.sideOfPly(index) != sender) return Received.OutOfStep(current.copy(outOfStep = true))
}
if (Games.replay(type, log) !is ReplayResult.Replayed) return Received.OutOfStep(current.copy(outOfStep = true))
// Their move lapses any draw that was offered.
return withEnding(current.copy(log = log, offerBy = null), sender, ending, log)
}
/** [moved], with [ending] applied when it counts. */
private fun withEnding(
moved: State,
sender: Side,
ending: String?,
carried: List<String>,
): Received {
if (ending != null) {
val ended = applyEnding(moved, sender, ending, carried)
if (ended != Received.Ignored) return ended
}
return if (moved.finished) Received.Ended(moved) else Received.Moved(moved)
}
/**
* One ending from [sender], judged at [carried], the log its `gend` held,
* which is in step with [state] and a prefix of its log.
*/
private fun applyEnding(
state: State,
sender: Side,
ending: String,
carried: List<String>,
): Received {
if (state.finished) return Received.Ignored
val atWholeLog = carried == state.log
fun movedBy(side: Side) = carried.indices.any { state.type.sideOfPly(it) == side }
return when (ending) {
RESIGN -> {
Received.Ended(state.copy(ending = RESIGN, endingBy = sender, offerBy = null))
}
// DESIGN item 22: only from the side that has just moved, at this
// phone's whole log, so no answer can race a move.
DRAW_OFFER -> {
val justMoved = state.position?.sideToMove == sender.opponent
if (atWholeLog && justMoved && state.accepted && state.offerBy == null) {
Received.Offered(state.copy(offerBy = sender))
} else {
Received.Ignored
}
}
// DESIGN 37: only at this phone's whole log, where the offer was made.
DRAW_ACCEPT -> {
if (atWholeLog && state.offerBy == state.me) {
Received.Ended(state.copy(ending = DRAW_ACCEPT, endingBy = sender, offerBy = null))
} else {
Received.Ignored
}
}
DRAW_DECLINE -> {
if (state.offerBy == state.me) Received.Quiet(state.copy(offerBy = null)) else Received.Ignored
}
// DESIGN item 23: the invitee, before their first move in the log
// the decline was sent at.
DECLINE -> {
if (sender == state.invitee && !movedBy(sender)) {
Received.Ended(state.copy(ending = DECLINE, endingBy = sender, offerBy = null))
} else {
Received.Ignored
}
}
// The starter, before the invitee's first move in that log.
CANCEL -> {
if (sender != state.invitee && !movedBy(state.invitee)) {
Received.Ended(state.copy(ending = CANCEL, endingBy = sender, offerBy = null))
} else {
Received.Ignored
}
}
else -> {
Received.Ignored
}
}
}
// -- This person's own moves and endings -------------------------------------.
/** The game after this person plays [move], or null when it is not theirs to play. */
fun play(
state: State,
move: String,
): State? {
if (move !in state.myMoves || state.log.size >= state.type.maxPlies) return null
val position = state.position ?: return null
if (Games.apply(position, move) !is MoveResult.Applied) return null
return state.copy(log = state.log + move, offerBy = null)
}
/** Whether this person may send [ending] now. */
fun canEnd(
state: State,
ending: String,
): Boolean {
if (state.finished || state.outOfStep) return false
return when (ending) {
RESIGN -> state.accepted
// DESIGN item 22: only once this person has moved and it is theirs.
DRAW_OFFER -> state.accepted && state.offerBy == null && state.position?.sideToMove == state.them
DRAW_ACCEPT, DRAW_DECLINE -> state.offerBy == state.them
DECLINE -> state.invitee == state.me && !state.hasMoved(state.me)
CANCEL -> state.startedByMe && !state.accepted
else -> false
}
}
/** The game after this person sends [ending]. Only call when [canEnd]. */
fun ended(
state: State,
ending: String,
): State =
when (ending) {
DRAW_OFFER -> state.copy(offerBy = state.me)
DRAW_DECLINE -> state.copy(offerBy = null)
else -> state.copy(ending = ending, endingBy = state.me, offerBy = null)
}
/** The envelope body for this person's side of [state]. */
fun wire(
state: State,
ending: String? = null,
): Content.Game = Content.Game(state.id, state.type.wire, state.me.wire, state.log, ending)
/**
* Whether this person's board is drawn turned round. Chess, checkers and
* reversi turn for the second player; tic-tac-toe never does, so its cells
* keep their places on both phones (DESIGN 30).
*/
fun turnedRound(state: State): Boolean = state.me == Side.SECOND && state.type != GameType.TICTACTOE
// -- Words -------------------------------------------------------------------.
fun name(type: GameType): String =
when (type) {
GameType.CHESS -> "Chess"
GameType.CHECKERS -> "Checkers"
GameType.REVERSI -> "Reversi"
GameType.TICTACTOE -> "Tic-tac-toe"
}
/** The name in running text, as §6.33's notification writes it: "chess". */
fun lowerName(type: GameType): String = name(type).lowercase()
/** What each side is called in each game. */
fun sideName(
type: GameType,
side: Side,
): String =
when (type) {
GameType.CHESS -> if (side == Side.FIRST) "white" else "black"
GameType.CHECKERS -> if (side == Side.FIRST) "dark" else "light"
GameType.REVERSI -> if (side == Side.FIRST) "black" else "white"
GameType.TICTACTOE -> if (side == Side.FIRST) "X" else "O"
}
/** How a finished game ended, from this person's side. Null while it goes on. */
fun result(state: State): String? {
val by = state.endingBy
when (state.ending) {
RESIGN -> return when (by) {
state.me -> "You resigned. They won"
state.them -> "They resigned. You won"
else -> "Resigned"
}
DRAW_ACCEPT -> return "Draw by agreement"
DECLINE -> return when (by) {
state.me -> "You declined"
state.them -> "They declined"
else -> "Declined"
}
CANCEL -> return when (by) {
state.me -> "You cancelled"
state.them -> "They cancelled"
else -> "Cancelled"
}
}
return outcomeWords(state.outcome, state.me)
}
/** A natural end in words, from [me]'s side. Null while the game goes on. */
internal fun outcomeWords(
outcome: Outcome,
me: Side,
): String? =
when (outcome) {
is Outcome.Win -> {
val mine = outcome.side == me
when (outcome.reason) {
WinReason.CHECKMATE -> if (mine) "Checkmate. You won" else "Checkmate. They won"
WinReason.NO_MOVES -> if (mine) "You won. They have no moves left" else "They won. You have no moves left"
WinReason.MORE_DISCS -> if (mine) "You won with more discs" else "They won with more discs"
WinReason.THREE_IN_A_ROW -> if (mine) "You won with three in a row" else "They won with three in a row"
}
}
is Outcome.Draw -> {
"Draw by " +
when (outcome.reason) {
DrawReason.STALEMATE -> "stalemate"
DrawReason.INSUFFICIENT_MATERIAL -> "insufficient material"
DrawReason.FIFTY_MOVES -> "fifty moves"
DrawReason.FORTY_MOVES -> "forty moves each without a capture"
DrawReason.REPETITION -> "repetition"
DrawReason.EQUAL_DISCS -> "equal discs"
DrawReason.FULL_BOARD -> "full board"
}
}
Outcome.Ongoing -> {
null
}
}
/** The line under the board. */
fun status(state: State): String =
when {
state.outOfStep -> "Your boards disagree"
state.finished -> result(state).orEmpty()
state.offerBy == state.them -> "They offered a draw"
state.offerBy == state.me -> "You offered a draw"
state.myMove && !state.accepted && state.invitee == state.me -> "Your move. Playing it accepts the game"
state.myMove -> "Your move"
!state.accepted && state.invitee == state.them -> "Waiting for them to play or decline"
else -> "Their move"
}
// -- On a history row --------------------------------------------------------.
/** `{"id","t","me","log","r","rby"}`: §6.33's backup shape, `rby` the side that sent `r`. */
const val ROW_GAME = "game"
/** Present, and true, while the boards disagree. Local to this phone. */
const val ROW_OUT_OF_STEP = "gameOutOfStep"
/** The side with a draw offer open. Local to this phone. */
const val ROW_OFFER = "gameOffer"
fun toRow(
state: State,
row: JSONObject,
): JSONObject {
val game =
JSONObject()
.put("id", state.id)
.put("t", state.type.wire)
.put("me", state.me.wire)
.put("log", JSONArray().also { array -> state.log.forEach { array.put(it) } })
state.ending?.let { game.put("r", it) }
state.endingBy?.let { if (state.ending != null) game.put("rby", it.wire) }
row.put(ROW_GAME, game)
if (state.outOfStep) row.put(ROW_OUT_OF_STEP, true) else row.remove(ROW_OUT_OF_STEP)
state.offerBy?.let { row.put(ROW_OFFER, it.wire) } ?: row.remove(ROW_OFFER)
return row
}
/** A game back off a history row, or null when the row holds none it can read. */
fun fromRow(row: JSONObject): State? {
val game = row.optJSONObject(ROW_GAME) ?: return null
// As on the wire: any id that is there (DESIGN 36).
val id = game.optString("id").takeIf { it.isNotEmpty() } ?: return null
val type = GameType.fromWire(game.optString("t")) ?: return null
val me = Side.fromWire(game.optString("me")) ?: return null
val array = game.optJSONArray("log") ?: JSONArray()
val log = (0 until array.length()).map { array.opt(it) as? String ?: return null }
return State(
id = id,
type = type,
me = me,
log = log,
startedByMe = row.optBoolean("mine", false),
ending = game.optString("r").takeIf { it in FINISHING },
endingBy = Side.fromWire(game.optString("rby")),
offerBy = Side.fromWire(row.optString(ROW_OFFER)),
outOfStep = row.optBoolean(ROW_OUT_OF_STEP, false),
)
}
}
File diff suppressed because it is too large Load Diff
@@ -27,12 +27,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.flamenet.client.MessengerApi
import io.flamenet.messenger.R
/**
* One contact, as the kit draws it: a cover, a name plate, and a rail of
@@ -47,7 +49,9 @@ 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 unknownName = stringResource(R.string.social_user_fallback, buddy.userId)
val backLabel = stringResource(R.string.common_back)
val name = buddy.displayName.ifBlank { buddy.handle }.ifBlank { unknownName }
val initials =
remember(name) {
name
@@ -71,7 +75,7 @@ fun ColumnScope.ContactDetail(
.size(Kit.icon + Space.md)
.clip(RoundedCornerShape(Radius.small))
.clickable(onClick = onClose)
.semantics { contentDescription = "Back" }
.semantics { contentDescription = backLabel }
.testTag("contact-back"),
contentAlignment = Alignment.Center,
) {
@@ -137,7 +141,7 @@ fun ColumnScope.ContactDetail(
buddy.handle
.takeIf { it.isNotBlank() && !it.equals(name, true) }
?.let { "@$it" },
buddy.presence.takeIf { it.isNotBlank() },
presenceLabel(buddy.presence).takeIf { it.isNotBlank() },
).joinToString(" · "),
fontSize = 12.sp,
color = Palette.SecondaryText,
@@ -163,18 +167,18 @@ fun ColumnScope.ContactDetail(
if (isBlocked) Palette.SecondaryText else MaterialTheme.colorScheme.primary,
),
) {
RailButton(KitIcon.Chats, "Message", "contact-message", enabled = !isBlocked) {
RailButton(KitIcon.Chats, stringResource(R.string.social_contact_message), "contact-message", enabled = !isBlocked) {
model.openChat(buddy.userId, buddy.displayName)
onClose()
}
RailDivider()
RailButton(KitIcon.Call, "Call", "contact-call", enabled = !isBlocked) {
RailButton(KitIcon.Call, stringResource(R.string.common_call), "contact-call", enabled = !isBlocked) {
model.requestCall(buddy.userId)
}
RailDivider()
RailButton(
if (isBlocked) KitIcon.PersonAdd else KitIcon.Block,
if (isBlocked) "Unblock" else "Block",
if (isBlocked) stringResource(R.string.social_unblock) else stringResource(R.string.common_block),
"contact-block",
) { model.setBlocked(buddy.userId, !isBlocked) }
}
@@ -182,18 +186,19 @@ fun ColumnScope.ContactDetail(
Spacer(Modifier.height(Space.xl))
PortalBand("Their details")
PortalBand(stringResource(R.string.social_their_details))
Column(Modifier.padding(vertical = Space.sm)) {
DetailRow("Username", buddy.handle.ifBlank { ", " })
DetailRow("User ID", buddy.userId.toString())
DetailRow("Status", buddy.presence.ifBlank { "unknown" })
DetailRow(stringResource(R.string.social_detail_username), buddy.handle.ifBlank { ", " })
DetailRow(stringResource(R.string.social_detail_user_id), buddy.userId.toString())
val unknownStatus = stringResource(R.string.social_status_unknown)
DetailRow(stringResource(R.string.social_detail_status), presenceLabel(buddy.presence).ifBlank { unknownStatus })
}
PortalBand("Managing this contact")
PortalBand(stringResource(R.string.social_managing_contact))
Column(Modifier.padding(vertical = Space.sm)) {
DetailRow(
"Clear this conversation",
"Removes it from this phone only. Their copy is theirs.",
stringResource(R.string.social_clear_conversation),
stringResource(R.string.social_clear_conversation_note),
tag = "contact-clear",
) {
model.clearHistory(
@@ -204,8 +209,8 @@ fun ColumnScope.ContactDetail(
// Confirms in place rather than in a dialog.
DetailRow(
if (confirmingRemove) "Really remove, this cannot be undone" else "Remove contact",
"Takes them off your list. It does not block them.",
if (confirmingRemove) stringResource(R.string.social_really_remove) else stringResource(R.string.social_remove_contact),
stringResource(R.string.social_remove_contact_note),
tag = "contact-remove",
danger = true,
) {
@@ -29,11 +29,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.flamenet.messenger.R
/**
* Crashes and freezes this phone recorded about this app.
@@ -62,12 +64,9 @@ fun ColumnScope.DiagnosticsSection() {
internal fun ColumnScope.DiagnosticsSection(reports: List<AppDiagnostics.Report>) {
var open by remember { mutableStateOf<AppDiagnostics.Report?>(null) }
Text("App diagnostics", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.set_diag_title), style = MaterialTheme.typography.titleMedium)
Text(
"Nothing here has left your phone. Android records a report when the app " +
"crashes or freezes, and this screen shows what it kept, so there is " +
"something to send if you ever want to. Sending one is something you do, " +
"not something that happens.",
stringResource(R.string.set_diag_intro),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
@@ -75,7 +74,7 @@ internal fun ColumnScope.DiagnosticsSection(reports: List<AppDiagnostics.Report>
if (reports.isEmpty()) {
Text(
"No crashes or freezes recorded.",
stringResource(R.string.set_diag_empty),
fontSize = 13.sp,
color = Palette.SecondaryText,
modifier = Modifier.testTag("diagnostics-empty"),
@@ -92,7 +91,7 @@ internal fun ColumnScope.DiagnosticsSection(reports: List<AppDiagnostics.Report>
.padding(vertical = Space.xs)
.testTag("diagnostic-${report.id}"),
) {
Text(report.kind.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
Text(kindTitle(report.kind), fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
Text(
report.summary,
fontSize = 13.sp,
@@ -100,7 +99,7 @@ internal fun ColumnScope.DiagnosticsSection(reports: List<AppDiagnostics.Report>
maxLines = 2,
)
Text(
"${AppDiagnostics.stamp(report.at)} · build ${report.appVersion}",
stringResource(R.string.set_diag_stamp_build, AppDiagnostics.stamp(report.at), report.appVersion),
fontSize = 11.sp,
color = Palette.SecondaryText,
)
@@ -130,6 +129,7 @@ private fun ReportDialog(
val clipboard = LocalClipboardManager.current
var saved by remember { mutableStateOf<String?>(null) }
var copied by remember { mutableStateOf(false) }
val savedMessage = stringResource(R.string.set_diag_saved)
val saveLauncher =
rememberLauncherForActivityResult(
@@ -142,14 +142,14 @@ private fun ReportDialog(
it.write(report.detail.toByteArray(Charsets.UTF_8))
} ?: error("nothing to write to")
}.fold(
onSuccess = { "Saved. Attach it to a message to the address above." },
onFailure = { e -> "Couldn't write the file: ${e.message}" },
onSuccess = { savedMessage },
onFailure = { e -> context.getString(R.string.set_diag_write_failed, e.message) },
)
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(report.kind.title) },
title = { Text(kindTitle(report.kind)) },
// Everything except the trace is fixed, and only the trace scrolls.
// With the whole dialog in one scrolling column the address and the
// save button sat below the explanation and off the bottom of a small
@@ -158,7 +158,7 @@ private fun ReportDialog(
text = {
Column {
Text(
"${AppDiagnostics.stamp(report.at)} · build ${report.appVersion}",
stringResource(R.string.set_diag_stamp_build, AppDiagnostics.stamp(report.at), report.appVersion),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
@@ -168,9 +168,7 @@ private fun ReportDialog(
// which is the difference between an informed share and a
// hopeful one.
Text(
"This report names the parts of Flamenet that were running and the kind " +
"of phone you have. It does not contain your messages, your contacts " +
"or your keys.",
stringResource(R.string.set_diag_report_contents),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
@@ -182,12 +180,12 @@ private fun ReportDialog(
copied = true
},
modifier = Modifier.testTag("diagnostics-address"),
) { Text(if (copied) "Address copied" else AppUpdate.SUPPORT_EMAIL) }
) { Text(if (copied) stringResource(R.string.set_diag_address_copied) else AppUpdate.SUPPORT_EMAIL) }
Spacer(Modifier.width(Space.sm))
OutlinedButton(
onClick = { saveLauncher.launch(fileName(report)) },
modifier = Modifier.testTag("diagnostics-save"),
) { Text("Save…") }
) { Text(stringResource(R.string.set_diag_save)) }
}
saved?.let {
Spacer(Modifier.height(Space.xs))
@@ -212,10 +210,27 @@ private fun ReportDialog(
)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } },
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) } },
)
}
/**
* The kind's name on screen. [AppDiagnostics.Kind.title] stays English
* because it also heads the saved report that goes to support.
*/
@Composable
private fun kindTitle(kind: AppDiagnostics.Kind): String =
stringResource(
when (kind) {
AppDiagnostics.Kind.Crash -> R.string.set_diag_kind_crash
AppDiagnostics.Kind.NativeCrash -> R.string.set_diag_kind_native_crash
AppDiagnostics.Kind.Frozen -> R.string.set_diag_kind_frozen
AppDiagnostics.Kind.OutOfMemory -> R.string.set_diag_kind_out_of_memory
AppDiagnostics.Kind.Resources -> R.string.set_diag_kind_resources
AppDiagnostics.Kind.StartupFailure -> R.string.set_diag_kind_startup_failure
},
)
/** A name somebody can find again in their downloads. */
internal fun fileName(report: AppDiagnostics.Report): String =
"flamenet-${report.kind.name.lowercase()}-${report.at}.txt"
@@ -0,0 +1,194 @@
package io.flamenet.messenger.app
import android.content.Context
import org.json.JSONObject
import java.text.Normalizer
import java.util.Locale
/**
* The small-web directory, shipped inside the app (DESIGN 5).
*
* FlameNet's public, approved listings as one file in the APK. Browsing and
* searching it happen on the phone and need no network, and nothing about
* what is looked at leaves the phone.
*/
class Directory private constructor(
/** The day the file was exported, `yyyy-MM-dd`. */
val made: String,
/** Where the listings come from, for the page's footer. */
val from: String,
/**
* The file's own label for the descriptions: who wrote them and how.
* Shown once in the directory, never on a site and never in a share.
*/
val note: String?,
val categories: List<Category>,
val sites: List<Site>,
) {
data class Category(
val id: String,
val name: String,
/** The parent's id, empty at the top. */
val parent: String,
)
data class Site(
val title: String,
val url: String,
/** One line about the site, written for FlameNet, not by the site. Optional. */
val description: String?,
/** Empty for a site that is found by search only, as on flamenet.io. */
val categories: List<String>,
) {
/** The host, shown beside every link so a title cannot pass for a destination. */
val host: String
get() = runCatching { java.net.URI(url).host }.getOrNull()?.lowercase(Locale.ROOT) ?: url
}
private val byId = categories.associateBy { it.id }
private val childrenOf = categories.groupBy { it.parent }
private val sitesOf: Map<String, List<Site>> =
buildMap<String, MutableList<Site>> {
for (site in sites) for (c in site.categories) getOrPut(c) { mutableListOf() } += site
}.mapValues { (_, list) -> list.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.title }) }
/** Lower case, accents off, per site: what a search is compared against. */
private val folded: List<Triple<String, String, String>> =
sites.map { Triple(fold(it.title), fold(it.host), fold(it.description.orEmpty())) }
/** The top of the tree, in the file's order. */
val top: List<Category> get() = childrenOf[""].orEmpty()
fun category(id: String): Category? = byId[id]
fun children(id: String): List<Category> = childrenOf[id].orEmpty()
/** The sites filed directly under [id], by title. */
fun sitesIn(id: String): List<Site> = sitesOf[id].orEmpty()
/** Sites under [id] and everything beneath it, each counted once. */
fun count(id: String): Int {
val seen = HashSet<Site>()
fun walk(c: String) {
seen += sitesIn(c)
children(c).forEach { walk(it.id) }
}
walk(id)
return seen.size
}
/**
* Sites matching every word of [query] in the title, the host or the
* description, ignoring case and accents. Title matches come first, then
* host matches, then the rest, each by title.
*/
fun search(
query: String,
limit: Int = SEARCH_LIMIT,
): List<Site> {
val words = fold(query).split(' ').filter { it.isNotEmpty() }
if (words.isEmpty()) return emptyList()
return sites.indices
.mapNotNull { i ->
val (title, host, desc) = folded[i]
if (!words.all { w -> w in title || w in host || w in desc }) return@mapNotNull null
val rank =
when {
title.startsWith(words.first()) -> 0
words.all { it in title } -> 1
words.all { it in host } -> 2
else -> 3
}
rank to sites[i]
}.sortedWith(compareBy<Pair<Int, Site>> { it.first }.thenBy(String.CASE_INSENSITIVE_ORDER) { it.second.title })
.take(limit)
.map { it.second }
}
companion object {
const val ASSET = "directory/directory-v1.json"
/** Results shown at once. Typing more narrows them. */
const val SEARCH_LIMIT = 100
const val MAX_TITLE = 120
const val MAX_DESC = 300
@Volatile private var shipped: Directory? = null
/** The copy shipped in the APK, read once. Blocking; call it off the main thread. */
fun load(context: Context): Directory =
shipped ?: synchronized(this) {
shipped ?: parse(context.assets.open(ASSET).use { String(it.readBytes(), Charsets.UTF_8) })
.also { shipped = it }
}
/**
* Read a directory file. A site without a title or an http(s) address
* is left out, a category it names that does not exist is ignored, and
* text is cut to the file's own bounds.
*/
fun parse(text: String): Directory {
val o = JSONObject(text)
require(o.optInt("v") == 1) { "directory file version ${o.opt("v")}, this build reads 1" }
val cats =
o.optJSONArray("cats").let { a ->
(0 until (a?.length() ?: 0)).mapNotNull { i ->
val c = a!!.optJSONObject(i) ?: return@mapNotNull null
val id = c.optString("id").takeIf { it.isNotEmpty() } ?: return@mapNotNull null
val name = c.optString("name").trim().takeIf { it.isNotEmpty() } ?: return@mapNotNull null
Category(id, name, c.optString("parent"))
}
}
val ids = cats.map { it.id }.toSet()
val sites =
o.optJSONArray("sites").let { a ->
(0 until (a?.length() ?: 0)).mapNotNull { i ->
val s = a!!.optJSONObject(i) ?: return@mapNotNull null
val title = (s.opt("t") as? String)?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
val url = (s.opt("u") as? String)?.trim()?.takeIf(::isWebAddress) ?: return@mapNotNull null
val cs = s.optJSONArray("c")
Site(
title = io.flamenet.client.TodayFile.bounded(title, MAX_TITLE),
url = url,
description =
(s.opt("d") as? String)
?.trim()
?.takeIf { it.isNotEmpty() }
?.let { io.flamenet.client.TodayFile.bounded(it, MAX_DESC) },
categories =
(0 until (cs?.length() ?: 0))
.mapNotNull { cs!!.opt(it) as? String }
.filter { it in ids }
.distinct(),
)
}
}
return Directory(
made = o.optString("made"),
from = o.optString("from"),
note = (o.opt("note") as? String)?.trim()?.takeIf { it.isNotEmpty() },
categories = cats,
sites = sites,
)
}
/** An http or https address with a host: the only kind a tap may open. */
fun isWebAddress(url: String): Boolean {
val uri = runCatching { java.net.URI(url) }.getOrNull() ?: return false
val scheme = uri.scheme?.lowercase(Locale.ROOT)
return (scheme == "https" || scheme == "http") && !uri.host.isNullOrBlank()
}
/** Lower case with the accents taken off, so "cafe" finds "Café". */
fun fold(s: String): String =
Normalizer
.normalize(s.lowercase(Locale.ROOT), Normalizer.Form.NFD)
.replace(MARKS, "")
.replace(SPACES, " ")
.trim()
private val MARKS = Regex("\\p{Mn}+")
private val SPACES = Regex("\\s+")
}
}
@@ -0,0 +1,316 @@
package io.flamenet.messenger.app
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.flamenet.client.MessengerApi
import java.text.NumberFormat
/**
* The small-web directory (DESIGN 5), reached from the Today tab. Everything
* here comes from the file shipped in the app; nothing is fetched to browse,
* search or share it.
*/
@Composable
internal fun ColumnScope.DirectoryScreen(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
today: TodayModel,
ui: TodayModel.Ui,
) {
val context = LocalContext.current
var sending by remember { mutableStateOf<Directory.Site?>(null) }
// What happened to the send, said in the dialog the person is looking at.
var outcome by remember { mutableStateOf<String?>(null) }
var said by remember { mutableStateOf<String?>(null) }
BackHandler { today.back() }
DirectoryPage(
directory = ui.directory,
category = ui.category,
query = ui.query,
said = said,
onQuery = {
said = null
today.search(it)
},
onBrowse = {
said = null
today.browse(it)
},
onOpen = { url -> said = if (openLink(context, url)) null else NO_BROWSER },
onSend = {
outcome = null
sending = it
},
onClose = { today.closeDirectory() },
)
sending?.let { site ->
SendSiteDialog(
site = site,
people = phase.buddies,
outcome = outcome,
onSend = { buddy ->
outcome = "Sending…"
model.shareSite(site, buddy.userId, buddy.displayName.ifBlank { buddy.handle }) { outcome = it }
},
onDismiss = {
sending = null
outcome = null
},
)
}
}
/** The directory from plain values, so it can be drawn without a session. */
@Composable
internal fun ColumnScope.DirectoryPage(
directory: Directory?,
category: String?,
query: String,
said: String? = null,
onQuery: (String) -> Unit,
onBrowse: (String?) -> Unit,
onOpen: (String) -> Unit,
onSend: (Directory.Site) -> Unit,
onClose: () -> Unit,
) {
PortalHeader(title = "The small web", subtitle = "From the FlameNet directory") {
PortalHeaderIcon(
icon = KitIcon.Close,
contentDescription = "Back to Today",
onClick = onClose,
modifier = Modifier.testTag("directory-close"),
)
}
if (directory == null) {
PortalBody {
Text("Opening the directory.", style = MaterialTheme.typography.bodyMedium)
}
return
}
PortalBody {
// Shown once, here, and never on a site or in anything sent.
directory.note?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag("directory-note"),
)
}
OutlinedTextField(
value = query,
onValueChange = onQuery,
placeholder = { Text("Search ${NumberFormat.getIntegerInstance().format(directory.sites.size)} sites", fontSize = 14.sp) },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("directory-search"),
)
said?.let {
Text(it, style = MaterialTheme.typography.bodySmall, modifier = Modifier.testTag("directory-said"))
}
}
if (query.isNotBlank()) {
val found = directory.search(query)
PortalBand(
when (found.size) {
0 -> "No sites match"
1 -> "1 site"
Directory.SEARCH_LIMIT -> "The first ${Directory.SEARCH_LIMIT} sites"
else -> "${found.size} sites"
},
)
for (site in found) SiteRow(site, onOpen, onSend)
return
}
val here = category?.let(directory::category)
if (here == null) {
PortalBand("Categories")
for (c in directory.top) CategoryRow(c, directory.count(c.id)) { onBrowse(c.id) }
return
}
// Where this category sits, and the way back up.
val parent = here.parent.takeIf { it.isNotEmpty() }?.let(directory::category)
PortalRow(modifier = Modifier.clickable { onBrowse(parent?.id) }.testTag("directory-up")) {
Icon(KitIcon.Back, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Text(
parent?.name ?: "All categories",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
}
PortalBand(here.name)
for (c in directory.children(here.id)) CategoryRow(c, directory.count(c.id)) { onBrowse(c.id) }
val sites = directory.sitesIn(here.id)
if (sites.isNotEmpty() && directory.children(here.id).isNotEmpty()) PortalBand("Sites")
for (site in sites) SiteRow(site, onOpen, onSend)
}
@Composable
private fun CategoryRow(
category: Directory.Category,
count: Int,
onClick: () -> Unit,
) {
PortalRow(modifier = Modifier.clickable(onClick = onClick).testTag("directory-category-${category.id}")) {
Text(category.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
Text(NumberFormat.getIntegerInstance().format(count), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun SiteRow(
site: Directory.Site,
onOpen: (String) -> Unit,
onSend: (Directory.Site) -> Unit,
) {
LinkRow(
title = site.title,
detail = site.description,
url = site.url,
onOpen = onOpen,
tag = "directory-site",
trailing = {
// 44dp, the floor a thumb needs, in the screen's ink, not the
// chrome's white.
androidx.compose.foundation.layout.Box(
modifier =
Modifier
.heightIn(min = 44.dp)
.clickable(onClickLabel = "Send to a chat") { onSend(site) }
.padding(Space.md)
.testTag("directory-send"),
contentAlignment = Alignment.Center,
) {
Icon(
KitIcon.Send,
contentDescription = "Send ${site.title} to a chat",
tint = MaterialTheme.colorScheme.primary,
)
}
},
)
}
/**
* Who to send a site to. One person at a time, through the ordinary send path
* with a preview made from the directory entry. Once sent, the dialog says
* what happened and offers nothing else.
*/
@Composable
internal fun SendSiteDialog(
site: Directory.Site,
people: List<MessengerApi.Buddy>,
outcome: String?,
onSend: (MessengerApi.Buddy) -> Unit,
onDismiss: () -> Unit,
) {
val sorted = remember(people) { people.sortedBy { it.displayName.ifBlank { it.handle }.lowercase() } }
var chosen by remember { mutableStateOf<MessengerApi.Buddy?>(null) }
val context = LocalContext.current
val withPreview = remember { MediaSettings(context).linkPreviews }
if (outcome != null) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Send to a chat") },
text = {
Text(outcome, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.testTag("send-outcome"))
},
confirmButton = { KitPrimaryButton(text = "Done", onClick = onDismiss, tag = "send-done") },
)
return
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Send to a chat") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) {
Text(
if (withPreview) {
"${site.title} goes as its address, with a preview that shows its name. " +
"Nothing is fetched to make the preview."
} else {
"${site.title} goes as its address. Link previews are off, so no preview goes with it."
},
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag("send-explained"),
)
if (sorted.isEmpty()) {
Text(
"Add someone to your contacts first. A site goes to one person at a time.",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.testTag("send-nobody"),
)
} else {
LazyColumn(modifier = Modifier.heightIn(max = 300.dp)) {
items(sorted, key = { it.userId }) { buddy ->
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { chosen = buddy }
.testTag("send-to-${buddy.userId}"),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = chosen == buddy, onClick = { chosen = buddy })
Column {
Text(
buddy.displayName.ifBlank { buddy.handle },
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text("@${buddy.handle}", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
}
},
confirmButton = {
KitPrimaryButton(
text = "Send",
onClick = { chosen?.let(onSend) },
enabled = chosen != null,
tag = "send-site",
)
},
dismissButton = {
KitChip(text = "Cancel", selected = false, onClick = onDismiss, tag = "send-cancel")
},
)
}
@@ -0,0 +1,37 @@
package io.flamenet.messenger.app
import io.flamenet.client.Conversations
import io.flamenet.e2e.Content
/**
* Sending a directory site to a chat (SPEC §6.12, DESIGN 18).
*
* An ordinary text through the ordinary 1:1 send path: the site's address as
* the words, and a link preview made from the directory entry. Nothing is
* fetched to make the preview, not by the sender and not by anyone who
* receives it.
*/
object DirectoryShare {
/** The words of the message: the address and nothing else. */
fun text(site: Directory.Site): String = site.url
/**
* The preview the message carries: the title, and an empty description.
* `d` is FlameNet's line about the site, and a preview reads as the
* site's own words, so it stays out. The key itself stays in, empty,
* because older builds require it.
*/
fun preview(site: Directory.Site): Content.LinkPreview = Content.LinkPreview.of(site.url, site.title, "")
/**
* Send [site] to one person, the way a typed message with a preview goes.
* With link previews turned off the address goes alone, as it would if
* they had typed it.
*/
fun send(
session: Session,
peerUserId: Long,
site: Directory.Site,
withPreview: Boolean,
): Conversations.Sent = session.send(peerUserId, text(site), if (withPreview) preview(site) else null)
}
@@ -18,7 +18,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.sp
import io.flamenet.messenger.R
/**
* How this phone draws things.
@@ -32,7 +34,7 @@ fun ColumnScope.DisplaySection() {
var larger by remember { mutableStateOf(settings.largerType) }
var appearance by remember { mutableStateOf(settings.appearance) }
Text("Display", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.set_display_title), style = MaterialTheme.typography.titleMedium)
@Composable
fun row(
@@ -54,25 +56,33 @@ fun ColumnScope.DisplaySection() {
}
}
row("Show whether contacts are online", "display-online", online) { on ->
row(stringResource(R.string.set_display_show_online), "display-online", online) { on ->
settings.showOnlineStatus = on
online = on
}
row("Show the time on messages", "display-timestamps", times) { on ->
row(stringResource(R.string.set_display_show_times), "display-timestamps", times) { on ->
settings.showTimestamps = on
times = on
}
row("Larger message text", "display-larger-type", larger) { on ->
row(stringResource(R.string.set_display_larger_text), "display-larger-type", larger) { on ->
settings.largerType = on
larger = on
}
Spacer(Modifier.height(Space.sm))
Text("Appearance", fontSize = 14.sp)
Text(stringResource(R.string.set_display_appearance), fontSize = 14.sp)
Row(modifier = Modifier.fillMaxWidth().padding(vertical = Space.xs)) {
DisplaySettings.Appearance.entries.forEach { option ->
val label =
stringResource(
when (option) {
DisplaySettings.Appearance.SYSTEM -> R.string.set_display_appearance_system
DisplaySettings.Appearance.LIGHT -> R.string.set_display_appearance_light
DisplaySettings.Appearance.DARK -> R.string.set_display_appearance_dark
},
)
KitSecondaryButton(
text = if (option == appearance) "✓ ${option.label}" else option.label,
text = if (option == appearance) stringResource(R.string.set_display_selected, label) else label,
onClick = {
settings.appearance = option
appearance = option
@@ -87,10 +97,7 @@ fun ColumnScope.DisplaySection() {
// 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.",
stringResource(R.string.set_display_footer),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
@@ -0,0 +1,108 @@
package io.flamenet.messenger.app
import io.flamenet.e2e.Content
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
/**
* How an event's time reads (§6.32), worked out from the event and the
* reader's zone and locale alone, so it can be checked without a device.
*/
object EventText {
/** The most days an all-day event is drawn across, whatever it says. */
private const val MAX_DAYS = 366
/** When it is, in the reader's own zone. */
fun whenLine(
event: Content.Event,
zone: ZoneId = ZoneId.systemDefault(),
locale: Locale = Locale.getDefault(),
): String {
val day = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
val start = event.start
if (start == null) {
val first = event.day?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: return ""
val span = (event.days ?: 1).coerceIn(1, MAX_DAYS)
if (span == 1) return "${first.format(day)}, all day"
return "${first.format(day)} to ${first.plusDays(span - 1L).format(day)}, all day"
}
val from = Instant.ofEpochSecond(start).atZone(zone)
val opening = "${from.format(day)}, ${from.format(time)}"
val end = event.end?.takeIf { it >= start } ?: return opening
val to = Instant.ofEpochSecond(end).atZone(zone)
return if (to.toLocalDate() == from.toLocalDate()) {
"$opening to ${to.format(time)}"
} else {
"$opening to ${to.format(day)}, ${to.format(time)}"
}
}
/**
* "7:00 PM New York time", when the organizer's clock reads differently
* from the reader's at the start, and null when it reads the same or the
* event has no zone. Compared by offset, not by name: a reader in Toronto
* does not need New York's time spelled out.
*/
fun organizerLine(
event: Content.Event,
readerZone: ZoneId = ZoneId.systemDefault(),
locale: Locale = Locale.getDefault(),
): String? {
val start = event.start ?: return null
val name = event.tz ?: return null
val theirs = runCatching { ZoneId.of(name) }.getOrNull() ?: return null
val instant = Instant.ofEpochSecond(start)
if (theirs.rules.getOffset(instant) == readerZone.rules.getOffset(instant)) return null
val there = instant.atZone(theirs)
val here = instant.atZone(readerZone)
val time = there.format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale))
// The weekday too when the two clocks are on different days.
val prefix =
if (there.toLocalDate() != here.toLocalDate()) {
there.format(DateTimeFormatter.ofPattern("EEE", locale)) + " "
} else {
""
}
return "$prefix$time ${city(name)} time"
}
/** "New York" for `America/New_York`, the last part of the zone's name. */
fun city(tz: String): String = tz.substringAfterLast('/').replace('_', ' ')
/** What the system's add-event sheet is handed. Times in epoch millis. */
data class CalendarSpan(
val beginMillis: Long,
val endMillis: Long?,
val allDay: Boolean,
/** The organizer's zone for a timed event, which the calendar keeps. */
val zone: String?,
)
/**
* An all-day event runs from midnight UTC on its first day to midnight
* UTC after its last, which is how the calendar provider stores all-day
* events. Null when the event has no time it could be put at.
*/
fun calendarSpan(event: Content.Event): CalendarSpan? {
val start = event.start
if (start != null) {
return CalendarSpan(
beginMillis = start * 1000,
endMillis = event.end?.takeIf { it >= start }?.let { it * 1000 },
allDay = false,
zone = event.tz?.takeIf { runCatching { ZoneId.of(it) }.isSuccess },
)
}
val first = event.day?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: return null
val span = (event.days ?: 1).coerceIn(1, MAX_DAYS)
val begin = first.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val end = first.plusDays(span.toLong()).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
return CalendarSpan(begin, end, allDay = true, zone = null)
}
}
@@ -0,0 +1,404 @@
package io.flamenet.messenger.app
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.provider.CalendarContract
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import io.flamenet.e2e.ChatEvents
import io.flamenet.e2e.Content
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/** The three answers, in the order the buttons sit. */
private val ANSWERS =
listOf(
Content.Rsvp.YES to "Going",
Content.Rsvp.MAYBE to "Maybe",
Content.Rsvp.NO to "Can't go",
)
/**
* An event in a conversation (§6.32): when and where, who is coming, and
* the three answers. Revisions and answers change this card; they never add
* another one.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun EventBubble(
line: Session.Line,
state: ChatEvents.State,
myUserId: Long,
/** A member's name as this phone knows it. */
nameOf: (Long) -> String,
onAnswer: (String) -> Unit,
onAddToCalendar: () -> Unit,
onLongPress: () -> Unit,
) {
val event = state.event
val mine = state.answers[myUserId]?.r
val shape = RoundedCornerShape(Kit.bubbleRadius)
val whenLine = remember(event) { EventText.whenLine(event) }
val theirZone = remember(event) { EventText.organizerLine(event) }
Column(
modifier =
Modifier
.fillMaxWidth(0.86f)
.clip(shape)
.background(MaterialTheme.colorScheme.surface)
.border(Kit.hairline, Portal.rule(), shape)
.combinedClickable(onClick = {}, onLongClick = onLongPress)
.padding(Kit.bubblePadding)
.testTag("event-${event.id}"),
verticalArrangement = Arrangement.spacedBy(Space.xs),
) {
Text("Event", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
// §6.32: a cancelled event's card reads "Cancelled: <title>".
Text(
if (event.cancelled) "Cancelled: ${event.shownTitle}" else event.shownTitle,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = if (event.cancelled) MaterialTheme.colorScheme.error else androidx.compose.ui.graphics.Color.Unspecified,
modifier = Modifier.testTag(if (event.cancelled) "event-cancelled" else "event-title"),
)
Text(whenLine, fontSize = 14.sp, modifier = Modifier.testTag("event-when"))
theirZone?.let {
Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.testTag("event-their-time"))
}
event.shownPlace?.let { Text(it, fontSize = 14.sp, modifier = Modifier.testTag("event-place")) }
event.shownNote?.let { Text(it, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.testTag("event-note")) }
Text(
when (state.organizer) {
myUserId -> "You organized this"
// Only a backup missing its organizer leaves this unknown,
// and then nobody can change the event.
0L -> "Organizer not known"
else -> "Organized by ${nameOf(state.organizer)}"
},
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (!event.cancelled) {
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs), modifier = Modifier.fillMaxWidth()) {
ANSWERS.forEach { (answer, label) ->
KitChip(
text = label,
selected = mine == answer,
onClick = { if (mine != answer) onAnswer(answer) },
modifier =
Modifier
.weight(1f)
.semantics { contentDescription = if (mine == answer) "$label, your answer" else label },
tag = "rsvp-$answer",
)
}
}
Text(
"Everyone in this chat sees your answer.",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag("rsvp-visibility"),
)
}
AnswerLines(state, myUserId, nameOf)
if (!event.cancelled && EventText.calendarSpan(event) != null) {
KitSecondaryButton(
text = "Add to calendar",
onClick = onAddToCalendar,
modifier = Modifier.fillMaxWidth(),
tag = "event-add-to-calendar",
)
}
}
}
/** "Going 2: Sam, you", one line per answer anybody gave. */
@Composable
private fun AnswerLines(
state: ChatEvents.State,
myUserId: Long,
nameOf: (Long) -> String,
) {
val lines =
ANSWERS.mapNotNull { (answer, label) ->
val who = state.who(answer)
if (who.isEmpty()) {
null
} else {
"$label ${who.size}: " + who.joinToString(", ") { if (it == myUserId) "you" else nameOf(it) }
}
}
if (lines.isEmpty()) {
Text("No answers yet.", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.testTag("rsvp-none"))
} else {
lines.forEach { Text(it, fontSize = 12.sp, modifier = Modifier.testTag("rsvp-line")) }
}
}
/**
* The system's own add-event sheet (§6.32). Nothing is read from the
* calendar and no permission is asked for: the person sees the event in their
* calendar app and decides there whether to save it.
*/
object EventCalendar {
fun intent(event: Content.Event): Intent? {
val span = EventText.calendarSpan(event) ?: return null
val intent =
Intent(Intent.ACTION_INSERT)
.setData(CalendarContract.Events.CONTENT_URI)
.putExtra(CalendarContract.Events.TITLE, event.shownTitle)
.putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, span.beginMillis)
.putExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, span.allDay)
span.endMillis?.let { intent.putExtra(CalendarContract.EXTRA_EVENT_END_TIME, it) }
span.zone?.let { intent.putExtra(CalendarContract.Events.EVENT_TIMEZONE, it) }
event.shownPlace?.let { intent.putExtra(CalendarContract.Events.EVENT_LOCATION, it) }
event.shownNote?.let { intent.putExtra(CalendarContract.Events.DESCRIPTION, it) }
return intent
}
/** Hand the event to the calendar app. False when the phone has none that takes it. */
fun open(
context: Context,
event: Content.Event,
): Boolean {
val intent = intent(event) ?: return false
return try {
context.startActivity(intent)
true
} catch (e: ActivityNotFoundException) {
false
}
}
}
/**
* Make an event, or change one this account organized. A timed event carries
* this phone's zone, so a reader elsewhere can be told the organizer's time.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventEditorDialog(
initial: Content.Event?,
onDismiss: () -> Unit,
onSave: (Content.Event) -> Unit,
) {
val zone = ZoneId.systemDefault()
val startAt = initial?.start?.let { java.time.Instant.ofEpochSecond(it).atZone(zone) }
val endAt = initial?.end?.let { java.time.Instant.ofEpochSecond(it).atZone(zone) }
val initialDay = initial?.day?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
var title by remember { mutableStateOf(initial?.title.orEmpty()) }
var allDay by remember { mutableStateOf(initial?.allDay == true) }
var day by remember { mutableStateOf(startAt?.toLocalDate() ?: initialDay ?: LocalDate.now().plusDays(1)) }
var time by remember { mutableStateOf(startAt?.toLocalTime()?.withSecond(0)?.withNano(0) ?: LocalTime.of(18, 0)) }
var hasEnd by remember {
mutableStateOf(endAt != null || (initial?.days ?: 1) > 1)
}
var endDay by remember {
mutableStateOf(endAt?.toLocalDate() ?: initialDay?.plusDays(((initial?.days ?: 1) - 1).toLong()) ?: day)
}
var endTime by remember { mutableStateOf(endAt?.toLocalTime()?.withSecond(0)?.withNano(0) ?: time.plusHours(2)) }
var place by remember { mutableStateOf(initial?.place.orEmpty()) }
var note by remember { mutableStateOf(initial?.note.orEmpty()) }
var picking by remember { mutableStateOf<String?>(null) }
val startMoment = day.atTime(time).atZone(zone).toEpochSecond()
val endMoment = if (allDay) null else endDay.atTime(endTime).atZone(zone).toEpochSecond()
val endsBeforeItStarts =
hasEnd && if (allDay) endDay.isBefore(day) else (endMoment ?: startMoment) < startMoment
val ready = title.isNotBlank() && !endsBeforeItStarts
val dayFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (initial == null) "New event" else "Change event") },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()).testTag("event-editor"),
verticalArrangement = Arrangement.spacedBy(Space.sm),
) {
OutlinedTextField(
value = title,
onValueChange = { title = it.take(Content.Event.MAX_TITLE) },
label = { Text("What") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("event-title-field"),
)
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = allDay, onCheckedChange = { allDay = it }, modifier = Modifier.testTag("event-all-day"))
Spacer(Modifier.width(Space.sm))
Text("All day", fontSize = 14.sp)
}
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs)) {
TextButton(onClick = { picking = "day" }, modifier = Modifier.testTag("event-day")) {
Text(day.format(dayFormat))
}
if (!allDay) {
TextButton(onClick = { picking = "time" }, modifier = Modifier.testTag("event-time")) {
Text(time.format(timeFormat))
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = hasEnd, onCheckedChange = { hasEnd = it }, modifier = Modifier.testTag("event-has-end"))
Spacer(Modifier.width(Space.sm))
Text(if (allDay) "More than one day" else "Add an end", fontSize = 14.sp)
}
if (hasEnd) {
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs)) {
TextButton(onClick = { picking = "endDay" }, modifier = Modifier.testTag("event-end-day")) {
Text(endDay.format(dayFormat))
}
if (!allDay) {
TextButton(onClick = { picking = "endTime" }, modifier = Modifier.testTag("event-end-time")) {
Text(endTime.format(timeFormat))
}
}
}
if (endsBeforeItStarts) {
Text("It ends before it starts.", fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
}
}
OutlinedTextField(
value = place,
onValueChange = { place = it.take(Content.Event.MAX_PLACE) },
label = { Text("Where (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("event-place-field"),
)
OutlinedTextField(
value = note,
onValueChange = { note = it.take(Content.Event.MAX_NOTE) },
label = { Text("Note (optional)") },
minLines = 2,
modifier = Modifier.fillMaxWidth().testTag("event-note-field"),
)
Text(
"Everyone in this chat gets it, and sees who answers what. A place is " +
"shown as written; nothing looks it up.",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(
enabled = ready,
onClick = {
val days = (endDay.toEpochDay() - day.toEpochDay() + 1).toInt()
onSave(
Content.Event(
id = initial?.id ?: java.util.UUID.randomUUID().toString(),
rev = initial?.rev ?: 1,
title = title.trim(),
start = if (allDay) null else startMoment,
end = if (!allDay && hasEnd) endMoment else null,
day = if (allDay) day.toString() else null,
days = if (allDay && hasEnd) Content.Event.clampDays(days.toLong()) else null,
tz = if (allDay) null else zone.id,
place = place.trim().takeIf { it.isNotEmpty() },
note = note.trim().takeIf { it.isNotEmpty() },
),
)
},
modifier = Modifier.testTag("event-save"),
) { Text(if (initial == null) "Send" else "Send change") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
when (picking) {
"day", "endDay" -> {
val current = if (picking == "day") day else endDay
val state = rememberDatePickerState(initialSelectedDateMillis = current.toEpochDay() * 86_400_000L)
DatePickerDialog(
onDismissRequest = { picking = null },
confirmButton = {
TextButton(onClick = {
state.selectedDateMillis?.let {
val chosen = LocalDate.ofEpochDay(it / 86_400_000L)
if (picking == "day") {
// Keep a following end the same distance away.
val shift = chosen.toEpochDay() - day.toEpochDay()
day = chosen
endDay = endDay.plusDays(shift)
} else {
endDay = chosen
}
}
picking = null
}) { Text("OK") }
},
dismissButton = { TextButton(onClick = { picking = null }) { Text("Cancel") } },
) { DatePicker(state = state) }
}
"time", "endTime" -> {
val current = if (picking == "time") time else endTime
val state =
rememberTimePickerState(
initialHour = current.hour,
initialMinute = current.minute,
is24Hour = android.text.format.DateFormat.is24HourFormat(LocalContext.current),
)
AlertDialog(
onDismissRequest = { picking = null },
text = { TimePicker(state = state) },
confirmButton = {
TextButton(onClick = {
val chosen = LocalTime.of(state.hour, state.minute)
if (picking == "time") time = chosen else endTime = chosen
picking = null
}) { Text("OK") }
},
dismissButton = { TextButton(onClick = { picking = null }) { Text("Cancel") } },
)
}
else -> Unit
}
}
@@ -31,12 +31,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.flamenet.client.LocalVault
import io.flamenet.messenger.R
import java.util.UUID
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.TextButton
@@ -51,7 +54,7 @@ fun ColumnScope.FilesScreen(
) {
val files = phase.files ?: MessengerViewModel.FilesState()
PortalHeader(title = "Files")
PortalHeader(title = stringResource(R.string.main_files))
when {
!files.hasIdentity -> FilesSetUp(model, files)
@@ -104,16 +107,13 @@ private fun ColumnScope.FilesSetUp(
// 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.",
stringResource(R.string.main_files_setup_intro),
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.",
stringResource(R.string.main_files_setup_warning),
fontSize = 13.sp,
color = Palette.Warning,
)
@@ -122,7 +122,7 @@ private fun ColumnScope.FilesSetUp(
OutlinedTextField(
value = passphrase,
onValueChange = { passphrase = it },
label = { Text("Your Flamenet passphrase") },
label = { Text(stringResource(R.string.main_files_your_passphrase)) },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("files-new-passphrase"),
@@ -133,7 +133,7 @@ private fun ColumnScope.FilesSetUp(
// already sign in with, so asking twice only suggested it was a different
// one, which is the opposite of what the paragraph above says.
KitPrimaryButton(
text = files.busy ?: "Set up Files",
text = files.busy ?: stringResource(R.string.main_files_set_up),
onClick = { model.createFilesIdentity(passphrase) },
enabled = files.busy == null && passphrase.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
@@ -150,7 +150,7 @@ private fun ColumnScope.FilesUnlock(
// Not "your Files passphrase".
Text(
"Enter your Flamenet passphrase to open them.",
stringResource(R.string.main_files_unlock_prompt),
fontSize = 14.sp,
color = Palette.SecondaryText,
)
@@ -158,14 +158,14 @@ private fun ColumnScope.FilesUnlock(
OutlinedTextField(
value = passphrase,
onValueChange = { passphrase = it },
label = { Text("Passphrase") },
label = { Text(stringResource(R.string.main_passphrase)) },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("files-passphrase"),
)
Spacer(Modifier.height(Space.md))
KitPrimaryButton(
text = files.busy ?: "Unlock",
text = files.busy ?: stringResource(R.string.common_unlock),
onClick = { model.unlockFiles(passphrase) },
enabled = files.busy == null && passphrase.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
@@ -183,6 +183,7 @@ private fun ColumnScope.FilesBrowser(
val context = LocalContext.current
var pending by remember { mutableStateOf<PendingFile?>(null) }
var namingFolder by remember { mutableStateOf(false) }
val untitled = stringResource(R.string.main_files_untitled)
/**
* `OpenDocument`, never `GetContent`.
@@ -191,7 +192,7 @@ private fun ColumnScope.FilesBrowser(
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri ?: return@rememberLauncherForActivityResult
val saf = SafReferences(context)
pending = PendingFile(uri, saf.displayName(uri) ?: "Untitled")
pending = PendingFile(uri, saf.displayName(uri) ?: untitled)
}
FilesSectionSwitch(model, files)
@@ -203,10 +204,9 @@ private fun ColumnScope.FilesBrowser(
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."
stringResource(R.string.main_files_vault_empty)
} 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."
stringResource(R.string.main_files_linked_empty)
}
Spacer(Modifier.height(Space.md))
Text(empty, fontSize = 13.sp, color = Palette.SecondaryText)
@@ -218,14 +218,14 @@ private fun ColumnScope.FilesBrowser(
Spacer(Modifier.height(Space.md))
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
KitSecondaryButton(
text = "Add a file",
text = stringResource(R.string.main_files_add_file),
// Everything: the vault does not care what a file is.
onClick = { pick.launch(arrayOf("*/*")) },
modifier = Modifier.weight(1f),
tag = "files-add",
)
KitSecondaryButton(
text = "New folder",
text = stringResource(R.string.main_files_new_folder),
onClick = { namingFolder = true },
modifier = Modifier.weight(1f),
tag = "files-new-folder",
@@ -234,14 +234,14 @@ private fun ColumnScope.FilesBrowser(
Spacer(Modifier.height(Space.sm))
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
KitSecondaryButton(
text = if (files.selecting) "Done" else "Select",
text = if (files.selecting) stringResource(R.string.common_done) else stringResource(R.string.main_files_select),
onClick = { model.filesToggleSelecting() },
modifier = Modifier.weight(1f),
tag = "files-select",
)
if (files.selecting) {
KitSecondaryButton(
text = "Delete ${files.selected.size}",
text = stringResource(R.string.main_files_delete_count, files.selected.size),
onClick = { model.filesDeleteSelected() },
enabled = files.selected.isNotEmpty(),
modifier = Modifier.weight(1f),
@@ -249,7 +249,7 @@ private fun ColumnScope.FilesBrowser(
)
}
KitSecondaryButton(
text = "Lock",
text = stringResource(R.string.main_files_lock),
onClick = { model.lockFiles() },
modifier = Modifier.weight(1f),
tag = "files-lock",
@@ -296,13 +296,13 @@ private fun ColumnScope.FilesSectionSwitch(
) {
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
KitChip(
text = "In the vault",
text = stringResource(R.string.main_files_in_vault),
selected = files.section == LocalVault.Storage.VAULT,
onClick = { model.filesSetSection(LocalVault.Storage.VAULT) },
tag = "files-section-vault",
)
KitChip(
text = "Linked",
text = stringResource(R.string.main_files_linked),
selected = files.section == LocalVault.Storage.REFERENCE,
onClick = { model.filesSetSection(LocalVault.Storage.REFERENCE) },
tag = "files-section-linked",
@@ -321,7 +321,7 @@ private fun ColumnScope.FilesBreadcrumb(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Files",
stringResource(R.string.main_files),
fontSize = 13.sp,
color = Palette.Accent,
modifier = Modifier.clickable { model.filesGoTo(0) }.testTag("files-crumb-root"),
@@ -370,7 +370,7 @@ private fun ColumnScope.FileRow(
Text(
// A row this phone cannot read is still SHOWN, with its
// classification.
text = entry.name ?: "Encrypted for someone else",
text = entry.name ?: stringResource(R.string.main_files_encrypted_for_other),
fontSize = 15.sp,
fontWeight = if (entry.isFolder) FontWeight.Medium else FontWeight.Normal,
color = if (entry.name == null) Palette.SecondaryText else MaterialTheme.colorScheme.onSurface,
@@ -384,7 +384,7 @@ private fun ColumnScope.FileRow(
color = Palette.SecondaryText,
)
if (entry.isMissing) {
Text(" · file has moved", fontSize = 11.sp, color = Palette.Warning)
Text(stringResource(R.string.main_files_has_moved), fontSize = 11.sp, color = Palette.Warning)
} else if (!entry.isFolder && entry.byteCount > 0) {
Text(
" · ${bytes(entry.byteCount)}",
@@ -400,7 +400,7 @@ private fun ColumnScope.FileRow(
!entry.isFolder && !entry.isMissing
) {
KitSecondaryButton(
text = "Encrypt",
text = stringResource(R.string.main_files_encrypt),
onClick = { model.filesEncrypt(entry.id) },
tag = "file-encrypt",
)
@@ -408,11 +408,12 @@ private fun ColumnScope.FileRow(
}
}
@Composable
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"
count >= 1_000_000 -> stringResource(R.string.main_files_size_mb, count / 1_000_000.0)
count >= 1_000 -> stringResource(R.string.main_files_size_kb, count / 1_000.0)
else -> pluralStringResource(R.plurals.main_files_size_bytes, count, count)
}
// ---- Sheets --------------------------------------------------------------.
@@ -442,12 +443,12 @@ private fun ColumnScope.AddFileSheet(
.padding(Space.md)
.testTag("files-add-sheet"),
) {
Text("Add a file", fontWeight = FontWeight.Medium, fontSize = 16.sp)
Text(stringResource(R.string.main_files_add_file), fontWeight = FontWeight.Medium, fontSize = 16.sp)
Spacer(Modifier.height(Space.sm))
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
label = { Text(stringResource(R.string.common_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("files-add-name"),
)
@@ -458,36 +459,33 @@ 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.",
stringResource(R.string.main_files_copy_in_note),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.xs))
KitPrimaryButton(
text = "Copy in and encrypt",
text = stringResource(R.string.main_files_copy_in),
onClick = { onVault(name.trim().ifEmpty { file.name }, level, areas) },
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.",
stringResource(R.string.main_files_link_note),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.xs))
KitSecondaryButton(
text = "Link to it instead",
text = stringResource(R.string.main_files_link_instead),
onClick = { onLink(name.trim().ifEmpty { file.name }, level, areas) },
modifier = Modifier.fillMaxWidth(),
tag = "files-add-link",
)
Spacer(Modifier.height(Space.sm))
KitSecondaryButton(
text = "Cancel",
text = stringResource(R.string.common_cancel),
onClick = onCancel,
modifier = Modifier.fillMaxWidth(),
tag = "files-add-cancel",
@@ -514,12 +512,12 @@ private fun ColumnScope.FolderNameSheet(
.padding(Space.md)
.testTag("files-folder-sheet"),
) {
Text("New folder", fontWeight = FontWeight.Medium, fontSize = 16.sp)
Text(stringResource(R.string.main_files_new_folder), fontWeight = FontWeight.Medium, fontSize = 16.sp)
Spacer(Modifier.height(Space.sm))
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
label = { Text(stringResource(R.string.common_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("files-folder-name"),
)
@@ -531,21 +529,20 @@ private fun ColumnScope.FolderNameSheet(
// 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.",
stringResource(R.string.main_files_folder_level_note),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.md))
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
KitSecondaryButton(
text = "Cancel",
text = stringResource(R.string.common_cancel),
onClick = onCancel,
modifier = Modifier.weight(1f),
tag = "files-folder-cancel",
)
KitPrimaryButton(
text = "Create",
text = stringResource(R.string.main_files_create),
onClick = { onCreate(name, level, areas) },
enabled = name.isNotBlank(),
modifier = Modifier.weight(1f),
@@ -572,12 +569,11 @@ private fun ColumnScope.AreaPicker(
var adding by remember { mutableStateOf("") }
var renaming by remember { mutableStateOf<Pair<Int, String>?>(null) }
Text("Areas", fontSize = 12.sp, color = Palette.SecondaryText)
Text(stringResource(R.string.main_files_areas), fontSize = 12.sp, color = Palette.SecondaryText)
Spacer(Modifier.height(Space.xs))
if (areas.isEmpty()) {
Text(
"Nothing here is named for you. Add the words your organisation " +
"already uses.",
stringResource(R.string.main_files_areas_empty),
fontSize = 12.sp,
color = Palette.SecondaryText,
modifier = Modifier.testTag("files-areas-empty"),
@@ -607,7 +603,7 @@ private fun ColumnScope.AreaPicker(
OutlinedTextField(
value = adding,
onValueChange = { adding = it },
label = { Text("Add an area") },
label = { Text(stringResource(R.string.main_files_add_area)) },
singleLine = true,
modifier = Modifier.weight(1f).testTag("files-area-new"),
)
@@ -619,13 +615,12 @@ private fun ColumnScope.AreaPicker(
areas = LocalAreas.all(context)
},
modifier = Modifier.testTag("files-area-add"),
) { Text("Add") }
) { Text(stringResource(R.string.common_add)) }
}
if (areas.isNotEmpty()) {
Spacer(Modifier.height(Space.xs))
Text(
"Long-press an area to rename or forget it. A file carries the area " +
"itself; the name is this phone's.",
stringResource(R.string.main_files_areas_hint),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
@@ -634,7 +629,7 @@ private fun ColumnScope.AreaPicker(
var text by remember(bit) { mutableStateOf(current) }
AlertDialog(
onDismissRequest = { renaming = null },
title = { Text("Rename area") },
title = { Text(stringResource(R.string.main_files_rename_area)) },
text = {
OutlinedTextField(
value = text,
@@ -648,14 +643,14 @@ private fun ColumnScope.AreaPicker(
LocalAreas.rename(context, bit, text)
renaming = null
areas = LocalAreas.all(context)
}) { Text("Rename") }
}) { Text(stringResource(R.string.common_rename)) }
},
dismissButton = {
TextButton(onClick = {
LocalAreas.forget(context, bit)
renaming = null
areas = LocalAreas.all(context)
}) { Text("Forget") }
}) { Text(stringResource(R.string.main_files_forget)) }
},
)
}
@@ -666,7 +661,7 @@ private fun ColumnScope.LevelPicker(
selected: Short,
onPick: (Short) -> Unit,
) {
Text("Classification", fontSize = 12.sp, color = Palette.SecondaryText)
Text(stringResource(R.string.main_files_classification), fontSize = 12.sp, color = Palette.SecondaryText)
Spacer(Modifier.height(Space.xs))
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
@@ -687,10 +682,7 @@ private fun ColumnScope.LevelPicker(
// The last sentence is the honest boundary: neither app stops somebody
// filing sensitive material somewhere low. See android#15.
Text(
"Who can open this. A folder's level doesn't limit what you put inside " +
"it, each file keeps its own. It is sealed with the label bound in, " +
"so this is chosen now rather than changed later. It decides who can " +
"open a file, not where the file can be put.",
stringResource(R.string.main_files_level_note),
fontSize = 12.sp,
color = Palette.SecondaryText,
modifier = Modifier.testTag("files-level-note"),
@@ -707,29 +699,35 @@ fun ColumnScope.AttachPanel(
model: MessengerViewModel,
onPhoto: () -> Unit,
onFiles: () -> Unit,
/** A greeting card (§6.31). */
onCard: () -> Unit,
/** An event the chat can answer (§6.32). */
onEvent: () -> Unit,
/** A game (§6.33); null where none can be played, which is anywhere but a one-to-one chat. */
onGame: (() -> Unit)? = null,
) {
Column(
// One row, because they sit together, scrolling sideways when the screen
// is narrow. Locked Files still opens its sheet, which says how to unlock it.
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(Radius.small))
.background(Palette.SurfaceAlt)
.horizontalScroll(rememberScrollState())
.padding(Space.md)
.testTag("attach-panel"),
verticalArrangement = Arrangement.spacedBy(Space.sm),
horizontalArrangement = Arrangement.spacedBy(Space.xs),
) {
KitSecondaryButton(text = stringResource(R.string.main_files_photo), onClick = onPhoto, 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)",
text = if (model.filesUnlocked()) stringResource(R.string.main_files_from_files) else stringResource(R.string.main_files_from_files_locked),
onClick = onFiles,
modifier = Modifier.fillMaxWidth(),
tag = "attach-files",
)
KitSecondaryButton(text = stringResource(R.string.main_files_card), onClick = onCard, tag = "attach-card")
KitSecondaryButton(text = stringResource(R.string.main_files_event), onClick = onEvent, tag = "attach-event")
onGame?.let { KitSecondaryButton(text = stringResource(R.string.main_files_game), onClick = it, tag = "attach-game") }
}
}
@@ -757,32 +755,27 @@ fun ColumnScope.PickFileToSendSheet(
.padding(Space.md)
.testTag("pick-file-sheet"),
) {
Text("Send a file", fontWeight = FontWeight.Medium, fontSize = 16.sp)
Text(stringResource(R.string.main_files_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.",
stringResource(R.string.main_files_locked_note),
fontSize = 13.sp,
color = Palette.SecondaryText,
)
} else if (entries.isEmpty()) {
Text("Nothing in your files yet.", fontSize = 13.sp, color = Palette.SecondaryText)
Text(stringResource(R.string.main_files_none_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.",
stringResource(R.string.main_files_send_note),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.xs))
// 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.",
stringResource(R.string.main_files_send_metadata_note),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
@@ -799,7 +792,7 @@ fun ColumnScope.PickFileToSendSheet(
) {
Column(Modifier.weight(1f)) {
Text(
entry.name ?: "Unnamed",
entry.name ?: stringResource(R.string.main_files_unnamed),
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -815,7 +808,7 @@ fun ColumnScope.PickFileToSendSheet(
}
Spacer(Modifier.height(Space.sm))
KitSecondaryButton(
text = "Cancel",
text = stringResource(R.string.common_cancel),
onClick = onCancel,
modifier = Modifier.fillMaxWidth(),
tag = "pick-file-cancel",
@@ -0,0 +1,579 @@
package io.flamenet.messenger.app
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.flamenet.games.Board
import io.flamenet.games.GameType
import io.flamenet.games.Piece
import io.flamenet.games.PieceKind
import io.flamenet.games.Side
/** Square colours: plain, and the same in both themes, as a board is. */
private object BoardColors {
val light = Color(0xFFDCE7F2)
val dark = Color(0xFF5E7A96)
val reversi = Color(0xFF137A4B)
val reversiLine = Color(0xFF0B5233)
val grid = Color(0xFF5E7A96)
val selected = Color(0xFFFFCC00)
val target = Color(0xCCFFCC00)
val firstPiece = Color(0xFFFFFFFF)
val secondPiece = Color(0xFF12212F)
val darkMan = Color(0xFF7A1F2B)
val lightMan = Color(0xFFF5E6C8)
val crown = Color(0xFFFFCC00)
}
/**
* A game in a one-to-one chat (§6.33): the board, whose move it is, and what
* either side may do now. The board is turned round for the second player,
* except tic-tac-toe's.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun GameBubble(
state: ChatGames.State,
/** Who the game is with. */
peerName: String,
onMove: (String) -> Unit,
onEnd: (String) -> Unit,
onNewGame: () -> Unit,
onLongPress: () -> Unit,
) {
val shape = RoundedCornerShape(Kit.bubbleRadius)
var confirmResign by remember(state.id) { mutableStateOf(false) }
var showMoves by remember(state.id) { mutableStateOf(false) }
Column(
modifier =
Modifier
.fillMaxWidth(0.92f)
.clip(shape)
.background(MaterialTheme.colorScheme.surface)
.border(Kit.hairline, Portal.rule(), shape)
.combinedClickable(onClick = {}, onLongClick = onLongPress)
.padding(Kit.bubblePadding)
.testTag("game-${state.id}"),
verticalArrangement = Arrangement.spacedBy(Space.xs),
) {
Text(
"${ChatGames.name(state.type)} with $peerName",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
ChatGames.status(state),
fontSize = 14.sp,
color = if (state.outOfStep) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.testTag("game-status"),
)
Text(
"You play ${ChatGames.sideName(state.type, state.me)}",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val moves = state.myMoves
val onlyPass = moves == listOf("pass")
GameBoard(state, enabled = moves.isNotEmpty() && !onlyPass, onMove = onMove)
if (onlyPass) {
// §6.33: when a reversi side has no square, Pass is the only move offered.
KitPrimaryButton(
text = "Pass",
onClick = { onMove("pass") },
modifier = Modifier.fillMaxWidth(),
tag = "game-pass",
)
}
GameControls(state, onEnd = onEnd, onResign = { confirmResign = true })
if (state.outOfStep) {
KitPrimaryButton(
text = "Start a new game",
onClick = onNewGame,
modifier = Modifier.fillMaxWidth(),
tag = "game-new",
)
}
if (state.log.isNotEmpty()) {
TextButton(onClick = { showMoves = !showMoves }, modifier = Modifier.testTag("game-moves-toggle")) {
Text(if (showMoves) "Hide moves" else "Moves (${state.log.size})", fontSize = 12.sp)
}
if (showMoves) {
Text(
moveList(state.log),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag("game-moves"),
)
}
}
}
if (confirmResign) {
AlertDialog(
onDismissRequest = { confirmResign = false },
title = { Text("Resign this game?") },
text = { Text("They win, and the game ends for both of you.", fontSize = 14.sp) },
confirmButton = {
TextButton(
onClick = {
confirmResign = false
onEnd(ChatGames.RESIGN)
},
modifier = Modifier.testTag("game-resign-confirm"),
) { Text("Resign", color = MaterialTheme.colorScheme.error) }
},
dismissButton = { TextButton(onClick = { confirmResign = false }) { Text("Keep playing") } },
)
}
}
/** "1. e2e4 e7e5", one line per turn. */
internal fun moveList(log: List<String>): String =
log.chunked(2).mapIndexed { i, pair -> "${i + 1}. ${pair.joinToString(" ")}" }.joinToString("\n")
/** What either side may send now, in one row. */
@Composable
private fun GameControls(
state: ChatGames.State,
onEnd: (String) -> Unit,
onResign: () -> Unit,
) {
val buttons = mutableListOf<Triple<String, String, () -> Unit>>()
if (ChatGames.canEnd(state, ChatGames.DRAW_ACCEPT)) {
buttons += Triple("Accept draw", "game-draw-accept") { onEnd(ChatGames.DRAW_ACCEPT) }
buttons += Triple("Decline draw", "game-draw-decline") { onEnd(ChatGames.DRAW_DECLINE) }
} else {
if (ChatGames.canEnd(state, ChatGames.DECLINE)) buttons += Triple("Decline", "game-decline") { onEnd(ChatGames.DECLINE) }
if (ChatGames.canEnd(state, ChatGames.CANCEL)) buttons += Triple("Cancel game", "game-cancel") { onEnd(ChatGames.CANCEL) }
if (ChatGames.canEnd(state, ChatGames.RESIGN)) buttons += Triple("Resign", "game-resign", onResign)
if (ChatGames.canEnd(state, ChatGames.DRAW_OFFER)) {
buttons += Triple("Offer a draw", "game-draw-offer") { onEnd(ChatGames.DRAW_OFFER) }
}
}
if (buttons.isEmpty()) return
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs), modifier = Modifier.fillMaxWidth()) {
buttons.forEach { (label, tag, action) ->
KitSecondaryButton(text = label, onClick = action, modifier = Modifier.weight(1f), tag = tag)
}
}
}
/**
* The board, from the engine's `board` with row 0 at the top as the first
* player sees it, turned round for the second except in tic-tac-toe. Tap a
* piece, then one of the squares it can reach; a checkers jump is entered
* square by square.
*/
@Composable
fun GameBoard(
state: ChatGames.State,
enabled: Boolean,
onMove: (String) -> Unit,
) {
val position = state.position
if (position == null) {
Text("These moves do not add up.", fontSize = 13.sp, color = MaterialTheme.colorScheme.error)
return
}
val board = position.board
val moves = if (enabled) state.myMoves else emptyList()
var path by remember(state.id, state.log.size) { mutableStateOf<List<String>>(emptyList()) }
var promotions by remember(state.id, state.log.size) { mutableStateOf<List<String>>(emptyList()) }
val selection = MoveSelection(state.type, moves)
val targets = selection.targets(path)
val flipped = ChatGames.turnedRound(state)
BoxWithConstraints(modifier = Modifier.fillMaxWidth().testTag("game-board")) {
val side: Dp = minOf(maxWidth, 320.dp)
val cell = side / board.columns
Column(modifier = Modifier.width(side).border(Kit.hairline, BoardColors.grid)) {
for (shownRow in 0 until board.rows) {
Row {
for (shownColumn in 0 until board.columns) {
val row = if (flipped) board.rows - 1 - shownRow else shownRow
val column = if (flipped) board.columns - 1 - shownColumn else shownColumn
val name = board.squareAt(row, column)
val piece = board.pieceAt(row, column)
BoardSquare(
type = state.type,
board = board,
row = row,
column = column,
name = name,
piece = piece,
size = cell,
// The whole path so far, so a jump shows where it started.
selected = name != null && name in path,
target = name != null && name in targets,
onTap = {
if (name == null || moves.isEmpty()) return@BoardSquare
when (val next = selection.tap(path, name)) {
is MoveSelection.Next.Play -> {
path = emptyList()
onMove(next.move)
}
is MoveSelection.Next.Choose -> {
promotions = next.moves
}
is MoveSelection.Next.Select -> {
path = next.path
}
}
},
)
}
}
}
}
}
if (promotions.isNotEmpty()) {
AlertDialog(
onDismissRequest = { promotions = emptyList() },
title = { Text("Promote to") },
text = {
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs)) {
promotions.forEach { move ->
val name = promotionName(move.last())
KitSecondaryButton(
text = name,
onClick = {
promotions = emptyList()
path = emptyList()
onMove(move)
},
modifier = Modifier.weight(1f),
tag = "promote-${move.last()}",
)
}
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = { promotions = emptyList() }) { Text("Cancel") } },
)
}
}
private fun promotionName(letter: Char): String =
when (letter) {
'q' -> "Queen"
'r' -> "Rook"
'b' -> "Bishop"
'n' -> "Knight"
else -> letter.toString()
}
@Composable
private fun BoardSquare(
type: GameType,
board: Board,
row: Int,
column: Int,
name: String?,
piece: Piece?,
size: Dp,
selected: Boolean,
target: Boolean,
onTap: () -> Unit,
) {
val ground =
when (type) {
GameType.REVERSI -> BoardColors.reversi
GameType.TICTACTOE -> MaterialTheme.colorScheme.surface
else -> if ((row + column) % 2 == 0) BoardColors.light else BoardColors.dark
}
val label =
buildString {
append(name ?: "")
append(", ")
append(piece?.let { describe(type, it) } ?: "empty")
if (selected) append(", selected")
if (target) append(", can move here")
}
Box(
modifier =
Modifier
.size(size)
.background(ground)
.then(
when {
selected -> Modifier.border(3.dp, BoardColors.selected)
type == GameType.REVERSI -> Modifier.border(0.5.dp, BoardColors.reversiLine)
type == GameType.TICTACTOE -> Modifier.border(1.dp, BoardColors.grid)
else -> Modifier
},
).then(
if (name != null) {
Modifier
.clickable(onClick = onTap)
.semantics {
contentDescription = label
this.selected = selected
}.testTag("square-$name")
} else {
Modifier
},
),
contentAlignment = Alignment.Center,
) {
if (piece != null) PieceMark(type, piece, size)
if (target) {
Box(
Modifier
.size(size / 4)
.clip(CircleShape)
.background(BoardColors.target),
)
}
}
}
@Composable
private fun PieceMark(
type: GameType,
piece: Piece,
size: Dp,
) {
when (type) {
GameType.CHESS -> {
val fill = if (piece.side == Side.FIRST) BoardColors.firstPiece else BoardColors.secondPiece
val edge = if (piece.side == Side.FIRST) BoardColors.secondPiece else BoardColors.firstPiece
Text(
chessGlyph(piece.kind),
fontSize = (size.value * 0.62f).sp,
color = fill,
style = TextStyle(shadow = Shadow(color = edge, offset = Offset(0f, 0f), blurRadius = 3f)),
)
}
GameType.CHECKERS -> {
val fill = if (piece.side == Side.FIRST) BoardColors.darkMan else BoardColors.lightMan
Box(
modifier =
Modifier
.size(size * 0.72f)
.clip(CircleShape)
.background(fill)
.border(1.dp, BoardColors.secondPiece, CircleShape),
contentAlignment = Alignment.Center,
) {
if (piece.kind == PieceKind.KING) {
Box(Modifier.size(size * 0.34f).clip(CircleShape).border(3.dp, BoardColors.crown, CircleShape))
}
}
}
GameType.REVERSI -> {
val fill = if (piece.side == Side.FIRST) BoardColors.secondPiece else BoardColors.firstPiece
Box(
Modifier
.size(size * 0.78f)
.clip(CircleShape)
.background(fill)
.border(1.dp, BoardColors.reversiLine, CircleShape),
)
}
GameType.TICTACTOE -> {
// The cells take the card's own ground, so the marks follow the theme.
val dark = LocalPortalDark.current
Text(
if (piece.side == Side.FIRST) "X" else "O",
fontSize = (size.value * 0.55f).sp,
fontWeight = FontWeight.Bold,
color =
when {
piece.side == Side.FIRST && dark -> Palette.NightLink
piece.side == Side.FIRST -> Palette.Navy
dark -> Palette.NightDanger
else -> Palette.Channel
},
)
}
}
}
/** The solid chess glyphs, asked for as text so no platform draws them as emoji. */
private fun chessGlyph(kind: PieceKind): String =
when (kind) {
PieceKind.KING -> "\u265A"
PieceKind.QUEEN -> "\u265B"
PieceKind.ROOK -> "\u265C"
PieceKind.BISHOP -> "\u265D"
PieceKind.KNIGHT -> "\u265E"
else -> "\u265F"
} + "\uFE0E"
/** A piece in words, for TalkBack: "white knight", "dark king", "black disc", "X". */
internal fun describe(
type: GameType,
piece: Piece,
): String {
val side = ChatGames.sideName(type, piece.side)
return when (piece.kind) {
PieceKind.DISC -> "$side disc"
PieceKind.MARK -> side
else -> "$side ${piece.kind.name.lowercase()}"
}
}
/**
* Which squares a tap can lead to, worked out from the legal moves alone, so
* whatever is sent is exactly a string the engine offered.
*/
internal class MoveSelection(
private val type: GameType,
private val moves: List<String>,
) {
sealed interface Next {
/** A whole move: send it. */
data class Play(
val move: String,
) : Next
/** Several moves end on that square: a chess promotion. */
data class Choose(
val moves: List<String>,
) : Next
/** Keep going: this is the path so far, maybe empty again. */
data class Select(
val path: List<String>,
) : Next
}
/** The squares each move passes through, in order. */
private fun squares(move: String): List<String> =
when (type) {
GameType.CHESS -> listOf(move.substring(0, 2), move.substring(2, 4))
GameType.CHECKERS -> move.split('-', 'x')
else -> listOf(move)
}
private val paths = moves.filter { it != "pass" }.map { it to squares(it) }
/** Where the piece on [path] can go next. With nothing chosen, nothing is marked. */
fun targets(path: List<String>): Set<String> {
if (type == GameType.REVERSI || type == GameType.TICTACTOE) return paths.map { it.second[0] }.toSet()
if (path.isEmpty()) return emptySet()
return paths.filter { it.second.size > path.size && it.second.subList(0, path.size) == path }.map { it.second[path.size] }.toSet()
}
fun tap(
path: List<String>,
square: String,
): Next {
if (type == GameType.REVERSI || type == GameType.TICTACTOE) {
return if (paths.any { it.second[0] == square }) Next.Play(square) else Next.Select(emptyList())
}
if (path.isNotEmpty() && path.last() == square) return Next.Select(emptyList())
val next = path + square
val through = paths.filter { it.second.size >= next.size && it.second.subList(0, next.size) == next }
if (through.isEmpty()) {
// Not a step from here: maybe another of their own pieces instead.
return if (paths.any { it.second[0] == square }) Next.Select(listOf(square)) else Next.Select(emptyList())
}
val complete = through.filter { it.second.size == next.size }.map { it.first }
return when {
complete.size == 1 && through.size == 1 -> Next.Play(complete.single())
complete.size > 1 -> Next.Choose(complete)
complete.size == 1 -> Next.Play(complete.single())
else -> Next.Select(next)
}
}
}
/** Start a game: which one, and who moves first. */
@Composable
fun NewGameDialog(
onDismiss: () -> Unit,
onStart: (GameType, Boolean) -> Unit,
) {
var type by remember { mutableStateOf(GameType.CHESS) }
var iGoFirst by remember { mutableStateOf(true) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Play a game") },
text = {
Column(modifier = Modifier.testTag("new-game"), verticalArrangement = Arrangement.spacedBy(Space.xs)) {
GameType.entries.forEach { option ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clickable { type = option }
.testTag("new-game-${option.wire}"),
) {
RadioButton(selected = type == option, onClick = { type = option })
Text(ChatGames.name(option), fontSize = 15.sp)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs), modifier = Modifier.padding(top = Space.sm)) {
KitChip(
text = "I go first",
selected = iGoFirst,
onClick = { iGoFirst = true },
modifier = Modifier.weight(1f),
tag = "new-game-first",
)
KitChip(
text = "You go first",
selected = !iGoFirst,
onClick = { iGoFirst = false },
modifier = Modifier.weight(1f),
tag = "new-game-second",
)
}
Text(
"Moves travel inside your messages, like everything else here. Their first " +
"move accepts the game; they can decline it instead.",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(onClick = { onStart(type, iGoFirst) }, modifier = Modifier.testTag("new-game-start")) { Text("Start") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@@ -0,0 +1,144 @@
package io.flamenet.messenger.app
import java.util.Locale
/**
* Hello links (SPEC §6.34): `https://flamenetmessenger.com/hi#<address>`.
*
* The address is in the fragment, which a browser keeps to itself, so the web
* page the link opens never receives it. The app reads the same address from
* `flamenetmessenger://hi#<address>`, from a scanned code, or as a bare
* address, and only ever pre-fills the add screen with it.
*
* The rule is the one both apps and the relay's page share (DESIGN 21 and
* 35): the part before the `#` is one of the known forms exactly, the
* fragment is percent-decoded once, and what is left must be an address and
* nothing else.
*/
object HelloLink {
/** What the address card shares and its QR code holds. */
const val WEB_PREFIX = "https://flamenetmessenger.com/hi#"
/** The app's own scheme, which the relay's /hi page links to. */
const val SCHEME = "flamenetmessenger"
/** `fnid:` and the 52 characters of an account key in the FNID alphabet. */
private val FNID = Regex("fnid:[0-9a-hjkmnp-tv-z]{52}")
/**
* ASCII letters, digits, hyphens and dots, checked before lowercasing,
* which in Java turns a Kelvin sign into an ASCII k (DESIGN 41). The dot
* is checked separately.
*/
private val HOST = Regex("[A-Za-z0-9.-]+")
/** The longest fragment read, as the relay's /hi page caps it (DESIGN 41). */
private const val MAX_FRAGMENT = 400
/** The paths of the page, in English and in Spanish, in exactly this case. */
private val PAGE_PATHS = setOf("/hi", "/es/hi")
private const val CARD_PREFIX = "flamenet:"
/** The hello link for [address], or null when it is not an address. */
fun forAddress(address: String): String? = canonical(address)?.let { WEB_PREFIX + it }
/**
* [text] as an address, `fnid:<52>@<host>` with the host in lower case, or
* null. Nothing around it is trimmed. The account part must already be
* lower case: it is a key, and a changed character is a different person.
*/
fun canonical(text: String): String? {
val at = text.indexOf('@')
if (at <= 0 || at != text.lastIndexOf('@')) return null
val fnid = text.substring(0, at)
val host = text.substring(at + 1)
if (!FNID.matches(fnid) || !HOST.matches(host) || '.' !in host) return null
return "$fnid@${host.lowercase(Locale.ROOT)}"
}
/**
* The address in a hello link, web or app form, or null. A link with a
* query, a port, a user name or another host is not a hello link.
*/
fun fromLink(link: String): String? {
// Whitespace around a link is not part of it, as with a scanned code.
val text = link.trim()
val hash = text.indexOf('#')
if (hash < 0 || !isHead(text.substring(0, hash))) return null
val fragment = text.substring(hash + 1)
if (fragment.length > MAX_FRAGMENT) return null
return decodeOnce(fragment)?.let(::canonical)
}
/**
* Whether [head], the part before the `#`, is `https://flamenetmessenger.com`
* with the path `/hi` or `/es/hi`, or the app's own `flamenetmessenger://hi`
* (DESIGN 35). The scheme and the host may be in any case; the path is
* exact, so no trailing slash, and no query.
*/
private fun isHead(head: String): Boolean {
val sep = head.indexOf("://")
if (sep < 0) return false
val scheme = head.substring(0, sep).lowercase(Locale.ROOT)
val rest = head.substring(sep + 3)
val slash = rest.indexOf('/')
val host = (if (slash < 0) rest else rest.substring(0, slash)).lowercase(Locale.ROOT)
val path = if (slash < 0) "" else rest.substring(slash)
return when (scheme) {
"https" -> host == "flamenetmessenger.com" && path in PAGE_PATHS
SCHEME -> host == "hi" && path.isEmpty()
else -> false
}
}
/**
* What a scanned code or a pasted line names: a hello link, a bare
* address, or one written `flamenet:<address>`.
*/
fun address(text: String): String? {
val t = text.trim()
fromLink(t)?.let { return it }
val bare = if (t.lowercase(Locale.ROOT).startsWith(CARD_PREFIX)) t.substring(CARD_PREFIX.length) else t
return canonical(bare)
}
/** Whether [scheme] is the app's own, so the link was meant for this app. */
fun isAppScheme(scheme: String?): Boolean = scheme?.lowercase(Locale.ROOT) == SCHEME
/**
* `%XX` escapes decoded once. Null for a broken escape, or for anything
* outside ASCII, which no address contains.
*/
internal fun decodeOnce(s: String): String? {
val out = StringBuilder(s.length)
var i = 0
while (i < s.length) {
val c = s[i]
if (c == '%') {
if (i + 2 >= s.length) return null
val hi = hex(s[i + 1])
val lo = hex(s[i + 2])
if (hi < 0 || lo < 0) return null
val v = hi * 16 + lo
if (v >= 0x80) return null
out.append(v.toChar())
i += 3
} else {
if (c.code >= 0x80) return null
out.append(c)
i++
}
}
return out.toString()
}
/** An ASCII hex digit's value, or -1. */
private fun hex(c: Char): Int =
when (c) {
in '0'..'9' -> c - '0'
in 'a'..'f' -> c - 'a' + 10
in 'A'..'F' -> c - 'A' + 10
else -> -1
}
}
@@ -23,9 +23,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.sp
import io.flamenet.e2e.BackupPayload
import io.flamenet.messenger.R
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
@@ -57,8 +59,8 @@ fun HistoryBackupSection(model: MessengerViewModel) {
context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) }
?: error("nothing to write to")
}.fold(
onSuccess = { "Backup saved. Keep the passphrase, it is the only key." },
onFailure = { "Couldn't write the file: ${it.message}" },
onSuccess = { context.getString(R.string.set_backup_saved) },
onFailure = { context.getString(R.string.set_backup_write_failed, it.message) },
)
}
@@ -67,13 +69,9 @@ fun HistoryBackupSection(model: MessengerViewModel) {
ActivityResultContracts.OpenDocument(),
) { uri -> if (uri != null) pendingImport = uri }
Text("History backup", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.set_backup_title), 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 opens on any phone signed into this account, Android " +
"or iPhone. Lose the passphrase and the backup cannot be opened, " +
"by anyone.",
stringResource(R.string.set_backup_intro),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
@@ -83,7 +81,7 @@ fun HistoryBackupSection(model: MessengerViewModel) {
onClick = { showExportDialog = true },
enabled = !working,
modifier = Modifier.testTag("export-history"),
) { Text("Export…") }
) { Text(stringResource(R.string.set_backup_export)) }
Spacer(Modifier.width(Space.sm))
OutlinedButton(
// SAF filters on MIME, and a custom extension has none, offer
@@ -91,7 +89,7 @@ fun HistoryBackupSection(model: MessengerViewModel) {
onClick = { openLauncher.launch(arrayOf("*/*")) },
enabled = !working,
modifier = Modifier.testTag("import-history"),
) { Text("Import…") }
) { Text(stringResource(R.string.set_backup_import)) }
}
status?.let {
Spacer(Modifier.height(Space.xs))
@@ -105,11 +103,8 @@ fun HistoryBackupSection(model: MessengerViewModel) {
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.",
title = stringResource(R.string.set_backup_export_title),
explainer = stringResource(R.string.set_backup_export_explainer),
requireRepeat = true,
busy = working,
onDismiss = { if (!working) showExportDialog = false },
@@ -120,7 +115,7 @@ fun HistoryBackupSection(model: MessengerViewModel) {
working = false
showExportDialog = false
if (bytes == null) {
status = "Sign in first."
status = context.getString(R.string.set_backup_sign_in_first)
} else {
pendingExport = bytes
val stamp = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
@@ -134,10 +129,8 @@ 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.",
title = stringResource(R.string.set_backup_import_title),
explainer = stringResource(R.string.set_backup_import_explainer),
requireRepeat = false,
busy = working,
onDismiss = { if (!working) pendingImport = null },
@@ -151,7 +144,7 @@ fun HistoryBackupSection(model: MessengerViewModel) {
if (bytes == null) {
working = false
pendingImport = null
status = "Couldn't read that file."
status = context.getString(R.string.set_backup_read_failed)
return@launch
}
val outcome = model.importHistory(bytes, phrase.toCharArray())
@@ -161,14 +154,24 @@ fun HistoryBackupSection(model: MessengerViewModel) {
outcome.fold(
onSuccess = { counts ->
if (counts.messages + counts.stories == 0) {
"Backup opened, everything in it was already here."
context.getString(R.string.set_backup_nothing_new)
} else {
"Imported ${counts.messages} message${if (counts.messages == 1) "" else "s"}" +
" and ${counts.stories} " +
if (counts.stories == 1) "story." else "stories."
context.getString(
R.string.set_backup_imported,
context.resources.getQuantityString(
R.plurals.set_backup_messages,
counts.messages,
counts.messages,
),
context.resources.getQuantityString(
R.plurals.set_backup_stories,
counts.stories,
counts.stories,
),
)
}
},
onFailure = { it.message ?: "That backup couldn't be opened." },
onFailure = { it.message ?: context.getString(R.string.set_backup_open_failed) },
)
}
}
@@ -199,7 +202,7 @@ private fun PassphraseDialog(
OutlinedTextField(
value = phrase,
onValueChange = { phrase = it },
placeholder = { Text("Passphrase", fontSize = 14.sp) },
placeholder = { Text(stringResource(R.string.set_backup_passphrase), fontSize = 14.sp) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth().testTag("backup-passphrase"),
@@ -209,7 +212,7 @@ private fun PassphraseDialog(
OutlinedTextField(
value = repeat,
onValueChange = { repeat = it },
placeholder = { Text("Repeat passphrase", fontSize = 14.sp) },
placeholder = { Text(stringResource(R.string.set_backup_repeat_passphrase), fontSize = 14.sp) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
isError = mismatch,
@@ -217,7 +220,7 @@ private fun PassphraseDialog(
)
if (mismatch) {
Text(
"The two passphrases don't match yet.",
stringResource(R.string.set_backup_mismatch),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.error,
)
@@ -225,7 +228,7 @@ private fun PassphraseDialog(
}
if (phrase.isNotEmpty() && phrase.length < 6) {
Text(
"Use at least 6 characters.",
stringResource(R.string.set_backup_min_length),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
@@ -237,10 +240,10 @@ private fun PassphraseDialog(
onClick = { onConfirm(phrase) },
enabled = ready && !busy,
modifier = Modifier.testTag("backup-confirm"),
) { Text(if (busy) "Working…" else "Continue") }
) { Text(if (busy) stringResource(R.string.set_backup_working) else stringResource(R.string.set_backup_continue)) }
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") }
TextButton(onClick = onDismiss, enabled = !busy) { Text(stringResource(R.string.common_cancel)) }
},
)
}
@@ -64,7 +64,7 @@ object KeystoreVault {
/**
* Records as files under `filesDir`, which is app-private storage.
*/
private class FileStorage(
internal class FileStorage(
private val root: File,
) : Vault.Storage {
init {
@@ -87,9 +87,13 @@ object KeystoreVault {
bytes: ByteArray,
) {
// Write beside and rename, so a kill mid-write leaves the previous
// record intact rather than a truncated one.
// record intact rather than a truncated one. Each write stages
// under its own name: two writes of one record (a draft saved per
// keystroke) shared one `.tmp`, the first rename took it, and the
// second threw on a worker thread, which killed the app. The last
// rename wins, as the last write should.
val target = fileFor(name)
val staging = File(root, target.name + ".tmp")
val staging = File(root, target.name + "." + java.util.UUID.randomUUID() + ".tmp")
staging.writeBytes(bytes)
if (!staging.renameTo(target)) {
staging.delete()
@@ -28,6 +28,7 @@ import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Mic
import androidx.compose.material.icons.outlined.MicOff
import androidx.compose.material.icons.outlined.MoreHoriz
import androidx.compose.material.icons.outlined.Newspaper
import androidx.compose.material.icons.outlined.NotificationsNone
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material.icons.outlined.PersonOutline
@@ -76,6 +77,9 @@ object KitIcon {
val Files: ImageVector = Icons.Outlined.FolderOpen
val Wallet: ImageVector = Icons.Outlined.AccountBalanceWallet
/** The Today tab: headlines first, so a newspaper. */
val Today: ImageVector = Icons.Outlined.Newspaper
// Composer.
val Attach: ImageVector = Icons.Outlined.AddCircleOutline
val Camera: ImageVector = Icons.Outlined.PhotoCamera
@@ -0,0 +1,53 @@
package io.flamenet.messenger.app
// What the long-press menu may do with a line, kept apart from the screen so
// the rules can be checked without composing anything.
/**
* The words Copy takes: a message's text, an open card's lines, an event's
* details. A card still sealed here has none to give.
*/
internal fun copyableWords(
line: Session.Line,
nowMillis: Long = System.currentTimeMillis(),
): String {
val card = line.card
val event = line.event?.event
return when {
// A game is a board, not words.
line.game != null -> ""
card != null ->
if (card.isSealed(line.mine, line.at, nowMillis)) {
""
} else {
listOfNotNull(card.shownTo, card.shownMsg.takeIf { it.isNotBlank() }, card.shownFrom).joinToString("\n")
}
event != null ->
listOfNotNull(event.shownTitle, EventText.whenLine(event), event.shownPlace, event.shownNote).joinToString("\n")
else -> line.text
}
}
/**
* What a report may carry: the same as Copy, so a sealed card's words are
* never sent to whoever runs the server by somebody who has not read them.
*/
internal fun reportableWords(line: Session.Line): String = copyableWords(line).ifBlank { line.summary }
/**
* §6.31 and §6.32: a card is not forwarded while it is still sealed here,
* and a cancelled event is not forwarded at all. §6.33: a game never is.
*/
internal fun canForward(
line: Session.Line,
nowMillis: Long = System.currentTimeMillis(),
): Boolean {
val card = line.card
val event = line.event
return when {
line.game != null -> false
card != null -> !card.isSealed(line.mine, line.at, nowMillis)
event != null -> !event.event.cancelled
else -> line.text.isNotBlank()
}
}
@@ -54,6 +54,7 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
@@ -106,6 +107,10 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
val lock = AppLock(this)
// Only on a fresh start: an activity recreated for a rotation is handed
// the same launch intent again, and the link was already taken.
if (savedInstanceState == null) openHelloLinkIfAsked(intent)
setContent {
MessengerTheme {
// `android:windowSoftInputMode="adjustResize"` stopped doing
@@ -187,10 +192,32 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
}
}
/** A contact link opened from outside the app (spec 8.12), once. */
private fun openLinkIfAsked(intent: Intent?) {
if (intent?.action != Intent.ACTION_VIEW) return
val text = intent.dataString ?: return
intent.data = null
model.handleLinkIntent(text)
}
/**
* A hello link (§6.34), `flamenetmessenger://hi#<address>`, opened from a
* browser or another app. Anything can send one of these, so all it does is
* fill in the add screen; the person decides whether to write.
*/
private fun openHelloLinkIfAsked(intent: Intent?) {
val link = helloLinkIn(intent) ?: return
// Taken once. `onStart` and a later `onNewIntent` see this intent again.
intent?.data = null
model.openHelloLink(link)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
answerIfAsked(intent)
openHelloLinkIfAsked(intent)
openLinkIfAsked(intent)
}
// The handover: exactly one poller at a time.
@@ -200,6 +227,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
// Also on the way in: a cold start from the notification arrives here
// rather than through onNewIntent.
answerIfAsked(intent)
openLinkIfAsked(intent)
}
override fun onStop() {
@@ -209,6 +237,17 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
}
}
/**
* The link a VIEW intent for the app's own scheme carries, as text, or null
* for any other intent. Whether it holds an address is the view model's call.
*/
internal fun helloLinkIn(intent: Intent?): String? {
if (intent?.action != Intent.ACTION_VIEW) return null
val data = intent.data ?: return null
if (!HelloLink.isAppScheme(data.scheme)) return null
return data.toString()
}
@Composable
private fun Root(model: MessengerViewModel = viewModel()) {
val phase by model.phase.collectAsStateWithLifecycle()
@@ -262,7 +301,7 @@ private fun Root(model: MessengerViewModel = viewModel()) {
) {
when (current) {
is MessengerViewModel.Phase.Starting -> CircularProgressIndicator()
is MessengerViewModel.Phase.SignedOut -> SignIn(model, current)
is MessengerViewModel.Phase.SignedOut -> SignInScreen(model, current)
is MessengerViewModel.Phase.VaultUnreadable -> VaultUnreadable(model, current)
else -> Unit
}
@@ -308,6 +347,8 @@ private fun Root(model: MessengerViewModel = viewModel()) {
myUserId = current.userId,
groups = current.groups,
heldMessages = current.heldMessages,
unsealedSendAt = current.unsealedSendAt,
myName = current.handle,
)
} else {
// The content area is weighted, and the tab bar is not.
@@ -330,6 +371,16 @@ private fun Root(model: MessengerViewModel = viewModel()) {
MessagesScreen(model, current)
}
// Hidden with the tab under supervision
// (DESIGN 31), wherever the screen was left.
MessengerViewModel.Phase.Screen.TODAY -> {
if (ParentalControls.todayHidden(androidx.compose.ui.platform.LocalContext.current)) {
SignedIn(model, current)
} else {
TodayScreen(model, current)
}
}
MessengerViewModel.Phase.Screen.CALLS -> {
CallsScreen(model, current)
}
@@ -357,6 +408,10 @@ private fun Root(model: MessengerViewModel = viewModel()) {
MessengerViewModel.Phase.Screen.ACCOUNT -> {
AccountScreen(model, current)
}
MessengerViewModel.Phase.Screen.SEARCH -> {
WebSearchScreen(model, current)
}
}
}
BottomNav(model, current)
@@ -368,7 +423,7 @@ private fun Root(model: MessengerViewModel = viewModel()) {
}
@Composable
private fun ColumnScope.SignIn(
internal fun ColumnScope.SignInScreen(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedOut,
) {
@@ -380,6 +435,9 @@ private fun ColumnScope.SignIn(
var passphrase by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var registering by remember { mutableStateOf(false) }
// Spec 8.12, decision DB2: new accounts are Listed, and the choice is
// offered here.
var listed by remember { mutableStateOf(true) }
var birthYear by remember { mutableStateOf("") }
var ageError by remember { mutableStateOf<String?>(null) }
// Only a relay keeping its own accounts needs an address for reset mail.
@@ -409,11 +467,10 @@ private fun ColumnScope.SignIn(
)
}
Spacer(Modifier.height(Space.lg))
Text("Hi there!", fontSize = 40.sp, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.main_welcome_title), 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.",
stringResource(R.string.main_welcome_body),
fontSize = 14.sp,
color = Palette.SecondaryText,
textAlign = TextAlign.Center,
@@ -424,7 +481,7 @@ private fun ColumnScope.SignIn(
OutlinedTextField(
value = relay,
onValueChange = model::setRelay,
label = { Text("Server") },
label = { Text(stringResource(R.string.main_server)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth().testTag("relay"),
@@ -433,7 +490,7 @@ private fun ColumnScope.SignIn(
// Say which kind of server this is BEFORE the passphrase is typed.
site?.let {
Text(
it.identityDescription,
stringResource(it.identityDescriptionRes),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag("identity-mode"),
)
@@ -442,7 +499,7 @@ private fun ColumnScope.SignIn(
OutlinedTextField(
value = handle,
onValueChange = { handle = it },
label = { Text("Handle") },
label = { Text(stringResource(R.string.main_handle)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth().testTag("handle"),
@@ -454,14 +511,14 @@ private fun ColumnScope.SignIn(
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
label = { Text(stringResource(R.string.main_email)) },
singleLine = true,
keyboardOptions =
KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth().testTag("email"),
)
Text(
"Used only by this server, to send a password reset.",
stringResource(R.string.main_email_note),
style = MaterialTheme.typography.bodySmall,
)
}
@@ -469,7 +526,7 @@ private fun ColumnScope.SignIn(
OutlinedTextField(
value = passphrase,
onValueChange = { passphrase = it },
label = { Text("Passphrase") },
label = { Text(stringResource(R.string.main_passphrase)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions =
@@ -482,14 +539,14 @@ private fun ColumnScope.SignIn(
OutlinedTextField(
value = birthYear,
onValueChange = { birthYear = it.filter(Char::isDigit).take(4) },
label = { Text("Year of birth") },
label = { Text(stringResource(R.string.main_birth_year)) },
singleLine = true,
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.",
stringResource(R.string.main_birth_year_note),
style = MaterialTheme.typography.bodySmall,
)
ageError?.let {
@@ -499,12 +556,23 @@ private fun ColumnScope.SignIn(
modifier = Modifier.testTag("age-error"),
)
}
Text(stringResource(R.string.main_who_can_find_you), style = MaterialTheme.typography.titleSmall)
Row(verticalAlignment = Alignment.CenterVertically) {
androidx.compose.material3.RadioButton(selected = listed, onClick = { listed = true }, modifier = Modifier.testTag("signup-listed"))
Text(stringResource(R.string.main_signup_listed), style = MaterialTheme.typography.bodySmall)
}
Row(verticalAlignment = Alignment.CenterVertically) {
androidx.compose.material3.RadioButton(selected = !listed, onClick = { listed = false }, modifier = Modifier.testTag("signup-unlisted"))
Text(stringResource(R.string.main_signup_unlisted), style = MaterialTheme.typography.bodySmall)
}
}
phase.error?.let {
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("error"))
}
val enterYearText = stringResource(R.string.main_age_enter_year)
val tooYoungText = stringResource(R.string.main_age_too_young)
// 240 wide and centred, which is the kit's primary button on this screen,
// not a full-width slab.
Spacer(Modifier.height(Space.md))
@@ -522,15 +590,15 @@ private fun ColumnScope.SignIn(
if (year < 1900 || age < 14) {
ageError =
if (year < 1900) {
"Enter your year of birth."
enterYearText
} else {
"You must be at least 13 to use Flamenet Messenger."
tooYoungText
}
return@Button
}
ageError = null
}
model.signIn(handle.trim(), passphrase, register = registering, email = email.trim())
model.signIn(handle.trim(), passphrase, register = registering, email = email.trim(), listed = listed)
},
enabled =
!phase.busy && handle.isNotBlank() && passphrase.isNotBlank() &&
@@ -543,7 +611,7 @@ private fun ColumnScope.SignIn(
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Text(
if (registering) "Create account" else "Sign in",
if (registering) stringResource(R.string.main_create_account) else stringResource(R.string.main_sign_in),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
@@ -561,10 +629,27 @@ private fun ColumnScope.SignIn(
},
modifier = Modifier.fillMaxWidth().testTag("toggle-register"),
) {
Text(if (registering) "I already have an account" else "Create an account")
Text(if (registering) stringResource(R.string.main_have_account) else stringResource(R.string.main_create_an_account))
}
if (!registering) ResetPassword(model, handle)
// Before any account exists, for a network that blocks Flamenet: without
// a bridge here, the sign-up that needs one cannot happen (SPEC §24).
var bridges by remember { mutableStateOf(false) }
TextButton(
onClick = { bridges = !bridges },
modifier = Modifier.fillMaxWidth().testTag("signin-bridges"),
) {
Text(
if (bridges) {
stringResource(R.string.set_bridge_signin_hide)
} else {
stringResource(R.string.set_bridge_signin_show)
},
)
}
if (bridges) NetworkSection()
}
/**
@@ -582,32 +667,31 @@ private fun ColumnScope.ResetPassword(
TextButton(
onClick = { open = !open },
modifier = Modifier.fillMaxWidth().testTag("toggle-reset"),
) { Text(if (open) "Back to signing in" else "Forgotten your password?") }
) { Text(if (open) stringResource(R.string.main_back_to_sign_in) else stringResource(R.string.main_forgot_password)) }
if (!open) return
Text(
"Enter your handle above, then send yourself a code. " +
"This server can only do that if it has an address on file for you.",
stringResource(R.string.main_reset_explainer),
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = { model.requestPasswordReset(handle.trim()) },
enabled = handle.isNotBlank(),
modifier = Modifier.fillMaxWidth().testTag("send-reset-code"),
) { Text("Send a code") }
) { Text(stringResource(R.string.main_send_code)) }
OutlinedTextField(
value = code,
onValueChange = { code = it },
label = { Text("Code from the email") },
label = { Text(stringResource(R.string.main_reset_code)) },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("reset-code"),
)
OutlinedTextField(
value = fresh,
onValueChange = { fresh = it },
label = { Text("New password") },
label = { Text(stringResource(R.string.main_new_password)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth().testTag("reset-password"),
@@ -616,7 +700,7 @@ private fun ColumnScope.ResetPassword(
onClick = { model.confirmPasswordReset(handle.trim(), code.trim(), fresh) },
enabled = code.isNotBlank() && fresh.length >= 8,
modifier = Modifier.fillMaxWidth().testTag("confirm-reset"),
) { Text("Set the new password") }
) { Text(stringResource(R.string.main_set_new_password)) }
}
/**
@@ -627,20 +711,26 @@ object NavDestinations {
val screen: MessengerViewModel.Phase.Screen,
val label: String,
val tag: String,
@androidx.annotation.StringRes val labelRes: Int,
)
/**
* The bar, in order.
*/
fun tabs(storiesHidden: Boolean): List<Tab> =
fun tabs(
storiesHidden: Boolean,
todayHidden: Boolean = false,
): 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"),
Tab(Screen.CONTACTS, "Home", "nav-contacts", R.string.main_home),
Tab(Screen.MESSAGES, "Messages", "nav-messages", R.string.main_messages),
// Beside Messages: the public Today file and the directory (§8.12).
Tab(Screen.TODAY, "Today", "nav-today", R.string.main_today).takeUnless { todayHidden },
Tab(Screen.CALLS, "Calls", "nav-calls", R.string.main_calls),
Tab(Screen.STORIES, "Stories", "nav-stories", R.string.main_stories).takeUnless { storiesHidden },
Tab(Screen.FILES, "Files", "nav-files", R.string.main_files),
Tab(Screen.WALLET, "Wallet", "nav-wallet", R.string.main_wallet),
Tab(Screen.ACCOUNT, "Account", "nav-account", R.string.main_account),
)
}
@@ -680,9 +770,13 @@ internal fun BottomNav(
horizontalArrangement = Arrangement.SpaceEvenly,
) {
// Supervision removes the Stories surface rather than emptying it.
val storiesHidden =
ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current)
for (tab in NavDestinations.tabs(storiesHidden)) {
val context = androidx.compose.ui.platform.LocalContext.current
val tabs =
NavDestinations.tabs(
storiesHidden = ParentalControls.storiesHidden(context),
todayHidden = ParentalControls.todayHidden(context),
)
for (tab in tabs) {
val badge =
when (tab.screen) {
// The inbox badge lives on Messages.
@@ -700,13 +794,24 @@ internal fun BottomNav(
when (tab.screen) {
Screen.CONTACTS -> KitIcon.Home to KitIcon.Home
Screen.MESSAGES -> KitIcon.Chats to KitIcon.ChatsActive
Screen.TODAY -> KitIcon.Today to KitIcon.Today
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)
NavButton(
model,
phase,
tab.screen,
icon,
active,
stringResource(tab.labelRes),
badge,
tab.tag,
compact = tabs.size > 7,
)
}
}
}
@@ -726,9 +831,15 @@ private fun RowScope.NavButton(
label: String,
badge: Int,
tag: String,
/**
* Eight tabs leave about 45dp each on a 360dp phone, so the labels go one
* size smaller and stay on one line.
*/
compact: Boolean = false,
) {
val selected = phase.screen == screen
val face = if (selected) Color.White else Color(0xFFB4D2EE)
val unreadLabel = stringResource(R.string.main_nav_unread, label, badge)
Box(
modifier =
Modifier
@@ -739,7 +850,7 @@ private fun RowScope.NavButton(
.clickable { model.navigateTo(screen) }
.semantics {
contentDescription =
if (badge > 0) "$label, $badge unread" else label
if (badge > 0) unreadLabel else label
}.testTag(tag),
contentAlignment = Alignment.Center,
) {
@@ -768,6 +879,9 @@ private fun RowScope.NavButton(
style = MaterialTheme.typography.labelSmall,
color = face,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
fontSize = if (compact) 10.sp else MaterialTheme.typography.labelSmall.fontSize,
maxLines = 1,
softWrap = false,
)
}
if (badge > 0) {
@@ -815,6 +929,17 @@ internal fun ColumnScope.SignedIn(
scanning = true
}
// A hello link that was opened (§6.34) lands in the add screen and stops
// there. Nothing is looked up or sent until the person chooses to.
val hello by model.helloAddress.collectAsStateWithLifecycle()
LaunchedEffect(hello) {
val address = hello ?: return@LaunchedEffect
openProfile = null
scannedPrefill = address
addingContact = true
model.helloAddressTaken()
}
// 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 } }
@@ -825,13 +950,13 @@ internal fun ColumnScope.SignedIn(
// Home is two things now, on purpose: your people, and their stories.
PortalHeader(
title = "Home",
title = stringResource(R.string.main_home),
subtitle = phase.handle.ifBlank { null }?.let { "$it · ${phase.relay}" },
) {
// Scan their Address screen, iOS's home has the same button.
PortalHeaderIcon(
icon = KitIcon.Scan,
contentDescription = "Scan an address",
contentDescription = stringResource(R.string.main_scan_address),
modifier = Modifier.testTag("scan-address"),
onClick = {
val has =
@@ -848,7 +973,7 @@ internal fun ColumnScope.SignedIn(
)
PortalHeaderIcon(
icon = if (addingContact) KitIcon.Close else KitIcon.Add,
contentDescription = "Add contact",
contentDescription = stringResource(R.string.main_add_contact),
modifier = Modifier.testTag("add-contact"),
onClick = { addingContact = !addingContact },
)
@@ -858,9 +983,12 @@ internal fun ColumnScope.SignedIn(
ScanAddressDialog(
onFound = { raw ->
scanning = false
parseScannedAddress(raw)?.let { card ->
// Bind by the fnid, never the handle (audit LOW).
scannedPrefill = "${'$'}{card.fnid}@${'$'}{card.relay}"
// A hello link's code, an older card, or a bare address.
val prefill = prefillFor(raw)
if (prefill == null) {
model.unreadableCode()
} else {
scannedPrefill = prefill
addingContact = true
}
},
@@ -887,14 +1015,14 @@ internal fun ColumnScope.SignedIn(
}
}
PortalBand("Your people")
PortalBand(stringResource(R.string.main_your_people))
if (phase.buddies.isEmpty()) {
PortalBody {
Text(
if (phase.buddiesLoaded) {
"Nobody here yet. Add someone by the username they signed up with."
stringResource(R.string.main_no_contacts)
} else {
"Couldn't load your contacts. Check the connection and sign in again."
stringResource(R.string.main_contacts_load_failed)
},
style = MaterialTheme.typography.bodyMedium,
)
@@ -904,7 +1032,7 @@ internal fun ColumnScope.SignedIn(
}
if (!ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current)) {
PortalBand("Stories")
PortalBand(stringResource(R.string.main_stories))
StoriesCards(model, phase)
}
}
@@ -940,16 +1068,13 @@ private fun ColumnScope.VaultUnreadable(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.VaultUnreadable,
) {
Text("Stored keys unreadable", style = MaterialTheme.typography.headlineMedium)
Text(stringResource(R.string.main_vault_unreadable_title), style = MaterialTheme.typography.headlineMedium)
Text(
"This phone has keys saved for Flamenet Messenger, but they cannot be opened. " +
"That usually means the app was restored onto a different device, where the " +
"key that protects them could not follow.",
stringResource(R.string.main_vault_unreadable_body),
)
Text(phase.detail, style = MaterialTheme.typography.bodySmall)
Text(
"Starting fresh publishes a new device. Everyone you talk to will see " +
"\"security keys changed\", so tell them to expect it.",
stringResource(R.string.main_vault_fresh_note),
)
// Behind a confirmation, because the button is one tap from a first
// screen and what it does cannot be undone: the stored keys go, and with
@@ -963,23 +1088,20 @@ private fun ColumnScope.VaultUnreadable(
onClick = { confirming = true },
modifier = Modifier.testTag("start-fresh"),
) {
Text("Erase and start fresh")
Text(stringResource(R.string.main_vault_erase_start_fresh))
}
if (confirming) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { confirming = false },
title = { Text("Erase the stored keys?") },
title = { Text(stringResource(R.string.main_vault_erase_title)) },
text = {
Text(
"This deletes the keys on this phone and every message they " +
"unlock. It cannot be undone. If these keys came from another " +
"device, they can still be opened there -- erasing here does " +
"not bring anything back.",
stringResource(R.string.main_vault_erase_body),
)
},
confirmButton = {
KitSecondaryButton(
text = "Erase everything",
text = stringResource(R.string.main_vault_erase_everything),
onClick = {
confirming = false
model.discardUnreadableVault()
@@ -990,7 +1112,7 @@ private fun ColumnScope.VaultUnreadable(
},
dismissButton = {
KitChip(
text = "Keep them",
text = stringResource(R.string.main_vault_keep_them),
selected = false,
onClick = { confirming = false },
tag = "start-fresh-cancel",
@@ -1017,21 +1139,19 @@ private fun CallKeyReviewDialog(
) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { model.dismissBlockedCallKeys() },
title = { Text("Security keys changed") },
title = { Text(stringResource(R.string.main_call_keys_title)) },
text = {
androidx.compose.foundation.layout.Column(
verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(Space.sm),
) {
Text(
"${review.peerName}'s keys are not the ones you accepted before, so the " +
"call was not placed.",
stringResource(R.string.main_call_keys_body, review.peerName),
style = MaterialTheme.typography.bodyMedium,
)
// Both readings, in the order that matters, as the chat banner
// has them.
Text(
"That happens when someone reinstalls or adds a device, and it is also " +
"what an intercepted conversation looks like.",
stringResource(R.string.main_call_keys_why),
style = MaterialTheme.typography.bodySmall,
)
review.safetyNumber?.let { number ->
@@ -1044,14 +1164,12 @@ private fun CallKeyReviewDialog(
}
if (review.accepted) {
Text(
"New keys accepted. You can call ${review.peerName} again.",
stringResource(R.string.main_call_keys_accepted, review.peerName),
style = MaterialTheme.typography.bodyMedium,
)
} else {
Text(
"Accepting means this call, and everything you send after it, goes to " +
"whoever holds the new keys. Read the number to them on a channel " +
"this app does not control, and accept only if it matches.",
stringResource(R.string.main_call_keys_warning),
style = MaterialTheme.typography.bodySmall,
)
}
@@ -1060,13 +1178,13 @@ private fun CallKeyReviewDialog(
confirmButton = {
if (review.accepted) {
KitSecondaryButton(
text = "Done",
text = stringResource(R.string.common_done),
onClick = { model.dismissBlockedCallKeys() },
tag = "call-keys-done",
)
} else {
KitSecondaryButton(
text = "Accept new keys",
text = stringResource(R.string.main_call_keys_accept),
onClick = { model.acceptBlockedCallKeys() },
tag = "call-accept-new-keys",
danger = true,
@@ -1076,7 +1194,7 @@ private fun CallKeyReviewDialog(
dismissButton = {
if (!review.accepted) {
KitChip(
text = "Not now",
text = stringResource(R.string.common_not_now),
selected = false,
onClick = { model.dismissBlockedCallKeys() },
tag = "call-keys-not-now",
@@ -1,7 +1,9 @@
package io.flamenet.messenger.app
import androidx.compose.foundation.background
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -28,16 +30,19 @@ 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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.flamenet.e2e.Vault
import io.flamenet.messenger.R
/**
* The kit's `07 Messages-Listing` as its own tab: large title, search box, the
* Recent circles row, then a flat list of conversations.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ColumnScope.MessagesScreen(
model: MessengerViewModel,
@@ -62,10 +67,10 @@ fun ColumnScope.MessagesScreen(
// 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.
PortalHeader(title = "Messages") {
PortalHeader(title = stringResource(R.string.set_messages_title)) {
PortalHeaderIcon(
icon = KitIcon.Scan,
contentDescription = "Scan an address",
contentDescription = stringResource(R.string.set_messages_scan),
modifier = Modifier.testTag("messages-scan"),
onClick = {
val has =
@@ -82,7 +87,7 @@ fun ColumnScope.MessagesScreen(
)
PortalHeaderIcon(
icon = if (addingContact) KitIcon.Close else KitIcon.Add,
contentDescription = "Add contact",
contentDescription = stringResource(R.string.set_messages_add_contact),
modifier = Modifier.testTag("messages-add-contact"),
onClick = { addingContact = !addingContact },
)
@@ -92,15 +97,19 @@ fun ColumnScope.MessagesScreen(
ScanAddressDialog(
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}"
}
// A contact link first (8.12): its app form looks like an
// address too, and reading it as one would drop the token.
if (io.flamenet.e2e.LinkCodes.decodeContactLink(raw) != null) {
model.openContactLink(raw)
return@ScanAddressDialog
}
// A hello link's code, an older card, or a bare address,
// bound by the fnid as Home's scanner is.
val prefill = prefillFor(raw)
if (prefill == null) {
model.unreadableCode()
} else {
scannedPrefill = prefill
addingContact = true
}
},
@@ -117,11 +126,15 @@ fun ColumnScope.MessagesScreen(
}
}
HeldLinkBanner(model, phase)
ExportNudge(model, phase)
RequestsSection(model, phase)
PortalBody {
OutlinedTextField(
value = query.value,
onValueChange = { query.value = it },
placeholder = { Text("Search conversations") },
placeholder = { Text(stringResource(R.string.set_messages_search)) },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("messages-search"),
)
@@ -130,7 +143,7 @@ fun ColumnScope.MessagesScreen(
MessageHits(model, query.value)
}
PortalBand("Recent")
PortalBand(stringResource(R.string.set_messages_recent))
BuddyCirclesRow(model, phase)
// The kit's listing holds every conversation, groups included.
@@ -138,12 +151,12 @@ fun ColumnScope.MessagesScreen(
val showNewChat = remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxWidth()) {
PortalBand("Chats")
PortalBand(stringResource(R.string.set_messages_chats))
// The way in, on the band itself, exactly as New group sits beside its
// heading in the bar above.
androidx.compose.material3.Icon(
KitIcon.NewChat,
contentDescription = "New chat",
contentDescription = stringResource(R.string.set_messages_new_chat),
tint = MaterialTheme.colorScheme.primary,
modifier =
Modifier
@@ -157,17 +170,18 @@ fun ColumnScope.MessagesScreen(
if (showNewChat.value) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showNewChat.value = false },
title = { Text("New chat") },
title = { Text(stringResource(R.string.set_messages_new_chat)) },
text = {
if (phase.buddies.isEmpty()) {
Text("Add a contact first, on Home, with their username or address.")
Text(stringResource(R.string.set_messages_add_contact_first))
} else {
Column {
phase.buddies.forEach { buddy ->
val fallbackName = stringResource(R.string.set_messages_user_fallback, buddy.userId)
val rowName =
buddy.displayName
.ifBlank { buddy.handle }
.ifBlank { "user ${buddy.userId}" }
.ifBlank { fallbackName }
Row(
modifier =
Modifier
@@ -199,30 +213,40 @@ fun ColumnScope.MessagesScreen(
}
},
confirmButton = {
androidx.compose.material3.TextButton(onClick = { showNewChat.value = false }) { Text("Cancel") }
androidx.compose.material3.TextButton(onClick = { showNewChat.value = false }) { Text(stringResource(R.string.common_cancel)) }
},
)
}
val shown =
var showArchived by remember { mutableStateOf(false) }
var menuFor by remember { mutableStateOf<Long?>(null) }
// Requests are listed above, not here (14.5); archived ones under their
// own heading; starred ones first.
val listed =
phase.buddies
.filter { it.userId !in phase.requests }
.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 archived = listed.filter { isArchived(phase, it.userId) }
val shown =
listed
.filterNot { isArchived(phase, it.userId) }
.sortedWith(
compareByDescending<io.flamenet.client.MessengerApi.Buddy> { isStarred(phase, it.userId) }
.thenByDescending { Vault.threadForUser(it.userId) in phase.unread }
.thenBy { it.displayName.ifBlank { it.handle } },
)
if (shown.isEmpty()) {
if (shown.isEmpty() && archived.isEmpty()) {
PortalBody {
Text(
if (phase.buddies.isEmpty()) {
"No conversations yet. Add someone on Home, everyone you add is a tap away."
stringResource(R.string.set_messages_empty_none)
} else {
"Nothing matches that search."
stringResource(R.string.set_messages_empty_no_match)
},
style = MaterialTheme.typography.bodyMedium,
)
@@ -231,18 +255,29 @@ fun ColumnScope.MessagesScreen(
}
Column(modifier = Modifier.fillMaxWidth().testTag("conversations")) {
shown.forEach { buddy ->
(if (showArchived) shown + archived else shown).forEach { buddy ->
val fallbackName = stringResource(R.string.set_messages_user_fallback, buddy.userId)
val name =
buddy.displayName
.ifBlank { buddy.handle }
.ifBlank { "user ${buddy.userId}" }
.ifBlank { fallbackName }
val unread = Vault.threadForUser(buddy.userId) in phase.unread
val thread = Vault.threadForUser(buddy.userId)
PortalRow(
modifier =
Modifier
.clickable { model.openChat(buddy.userId, name) }
.testTag("conversation-${buddy.userId}"),
.combinedClickable(
onClick = { model.openChat(buddy.userId, name) },
onLongClick = { menuFor = buddy.userId },
).testTag("conversation-${buddy.userId}"),
) {
ConversationFlagsMenu(
model,
thread,
phase.convFlags[thread] ?: io.flamenet.client.AccountSync.ConvFlags(),
expanded = menuFor == buddy.userId,
onDismiss = { menuFor = null },
)
Avatar(
name = name,
online = buddy.presence == "online",
@@ -258,7 +293,12 @@ fun ColumnScope.MessagesScreen(
overflow = TextOverflow.Ellipsis,
)
Text(
if (unread) "New messages" else buddy.presence,
when {
isArchived(phase, buddy.userId) -> stringResource(R.string.set_messages_archived)
unread -> stringResource(R.string.set_messages_new_messages)
isStarred(phase, buddy.userId) -> stringResource(R.string.set_messages_starred, presenceLabel(buddy.presence))
else -> presenceLabel(buddy.presence)
},
fontSize = 13.sp,
color =
if (unread) {
@@ -274,6 +314,20 @@ fun ColumnScope.MessagesScreen(
}
}
}
if (archived.isNotEmpty()) {
androidx.compose.material3.TextButton(
onClick = { showArchived = !showArchived },
modifier = Modifier.padding(horizontal = Space.md).testTag("archived-toggle"),
) {
Text(
if (showArchived) {
stringResource(R.string.set_messages_hide_archived)
} else {
stringResource(R.string.set_messages_archived_count, archived.size)
},
)
}
}
}
/**
@@ -298,7 +352,7 @@ private fun MessageHits(
if (hits.isEmpty()) return
Text(
"IN MESSAGES",
stringResource(R.string.set_messages_in_messages),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = Palette.SecondaryText,
@@ -14,6 +14,7 @@ import androidx.core.app.NotificationCompat
import io.flamenet.client.MessengerApi
import io.flamenet.client.RelayClient
import io.flamenet.e2e.Vault
import io.flamenet.messenger.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -165,8 +166,8 @@ class MessengerService : Service() {
val notice =
NotificationCompat
.Builder(this, CHANNEL_ONGOING)
.setContentTitle("Flamenet is receiving")
.setContentText("Messages arrive without Google or Apple in the middle.")
.setContentTitle(getString(R.string.core_notif_receiving_title))
.setContentText(getString(R.string.core_notif_receiving_text))
.setSmallIcon(android.R.drawable.stat_notify_chat)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_MIN)
@@ -212,7 +213,10 @@ class MessengerService : Service() {
relay = relay,
api =
MessengerApi(credential.relayBaseUrl, credential.username, credential.appPassword),
)
).also { s ->
s.onWiped = { TodayData.forget(applicationContext) }
s.attachmentCacheDir = cacheDir
}
}.getOrNull()
private suspend fun run() {
@@ -315,7 +319,24 @@ class MessengerService : Service() {
"call=$onACall touched=${touched.size} " +
(outcome.exceptionOrNull()?.let { "failed=${it::class.simpleName}" } ?: "ok"),
)
if (touched.isNotEmpty()) notifyMessages(touched.size)
// A card, an event or an invitation that came alone gets its own
// words. A game's moves, offers and endings are said too, but they
// never count as unread, so they are not among `touched` (DESIGN 29).
val notice = s.takeNotice()
val gameNews = s.takeGameNews()
// A snoozed chat holds all of it, an ordinary message as well as
// a game's news (owner, 0.32).
val snoozed = runCatching { s.snoozedThreads() }.getOrDefault(emptySet())
// Requests and copies from this account's own devices are quiet
// (spec 14.5, 17.2): they count as unread, never as an alert, so
// they come out before anything is raised.
val loud = touched - s.quietThreads
NotificationSettings.due(loud, notice, gameNews, snoozed)?.let { due ->
notifyMessages(due.threads, notice = due.notice, s = s)
}
// Answers only redraw a card, and nothing here is drawing one
// (§6.32: answers do not notify). Taken so they do not pile up.
s.takeQuietlyChanged()
// 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
@@ -352,12 +373,14 @@ class MessengerService : Service() {
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"
return names[userId] ?: getString(R.string.core_someone)
}
private fun notifyMessages(
threads: Int,
from: String? = null,
notice: Session.Notice? = null,
s: Session? = null,
) {
val settings = NotificationSettings(this)
// The master switch and the per-kind one, both checked before a
@@ -372,14 +395,27 @@ class MessengerService : Service() {
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)
val text =
if (notice != null && threads == 1 && s != null) {
// Never the words of a card: who sent it and, when it is
// sealed, the day it opens. Only looked up when it will be shown.
val name = if (settings.showPreviews) runCatching { nameFor(notice.from, s) }.getOrNull() else null
val opensOn = (notice as? Session.Notice.Card)?.opensAtMillis?.let { CardArt.day(it) }
settings.noticeText(notice, name, opensOn)
} else {
settings.messageText(threads, from)
}
// Their move and a draw offer come without a sound; an invitation,
// an ending and everything else with one.
val quiet = threads == 1 && NotificationSettings.isQuiet(notice)
notify(
CHANNEL_MESSAGES,
2,
text,
NotificationCompat.PRIORITY_HIGH,
if (quiet) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_HIGH,
open,
number = threads,
silent = quiet,
)
}
@@ -495,6 +531,8 @@ class MessengerService : Service() {
open: PendingIntent,
category: String? = null,
number: Int = 0,
/** Shown without a sound or a buzz, as a game's next move is (§6.33). */
silent: Boolean = false,
) {
val n =
NotificationCompat
@@ -503,6 +541,7 @@ class MessengerService : Service() {
.setContentText(text)
.setSmallIcon(android.R.drawable.stat_notify_chat)
.setPriority(priority)
.setSilent(silent)
.setAutoCancel(true)
.setContentIntent(open)
.also { b -> category?.let { b.setCategory(it) } }
@@ -521,10 +560,10 @@ class MessengerService : Service() {
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ONGOING,
"Receiving",
getString(R.string.core_channel_receiving),
NotificationManager.IMPORTANCE_MIN,
).apply {
description = "The notice Android requires while Flamenet is receiving."
description = getString(R.string.core_channel_receiving_description)
// 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)
@@ -533,14 +572,14 @@ class MessengerService : Service() {
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_MESSAGES,
"Messages",
getString(R.string.core_channel_messages),
NotificationManager.IMPORTANCE_HIGH,
).apply { setShowBadge(true) },
)
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_CALLS,
"Calls",
getString(R.string.core_channel_calls),
NotificationManager.IMPORTANCE_HIGH,
).apply {
setBypassDnd(false)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,526 @@
package io.flamenet.messenger.app
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Checkbox
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.flamenet.client.AccountSync
import io.flamenet.client.WebSearch
import io.flamenet.e2e.Vault
import io.flamenet.messenger.R
import kotlinx.coroutines.launch
/** A QR code for [text], drawn crisp at [side]. */
@Composable
internal fun QrImage(
text: String,
side: androidx.compose.ui.unit.Dp = 220.dp,
tag: String,
) {
val bitmap = remember(text) { qrBitmap(text) } ?: return
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = stringResource(R.string.social_qr_code_desc),
filterQuality = FilterQuality.None,
modifier = Modifier.size(side).testTag(tag),
)
}
// -- Linking this account's devices (spec 17.3) ----------------------------------.
/** How a linked device left the set, in the words the spec asks for. */
internal fun linkNoticeText(notice: io.flamenet.client.Conversations.LinkNotice): String {
val who = notice.deviceId.take(8)
return when (notice.reason) {
"revoke" -> "Device $who is no longer linked. You removed it."
"expire" -> "Device $who is no longer linked. The server retired it, because it was inactive or to make room for a new sign-in."
else -> "Device $who is no longer linked. The server stopped listing it and gave no reason."
}
}
/**
* Linking, in Account under "Your devices": show this device's code, scan
* another's, or compare a number; which devices are linked; and why one
* stopped being.
*/
@Composable
fun LinkedDevicesSection(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
var scanning by remember { mutableStateOf(false) }
var scanned by remember { mutableStateOf<String?>(null) }
var withHistory by remember { mutableStateOf(true) }
var numberFor by remember { mutableStateOf<String?>(null) }
var number by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) { model.refreshContactState() }
Text(stringResource(R.string.social_linked_devices_title), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.social_linked_devices_explainer),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.sm))
phase.linkNotices.forEach { notice ->
Text(linkNoticeText(notice), fontSize = 13.sp, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("link-notice"))
}
if (phase.linkNotices.isNotEmpty()) {
TextButton(onClick = { model.dismissLinkNotices() }) { Text(stringResource(R.string.social_ok)) }
}
val others = phase.registeredDevices.filter { it.deviceId != phase.deviceId }
others.forEach { device ->
val linked = device.deviceId in phase.linkedDevices
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.xs).testTag("linked-${device.deviceId}"),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(device.deviceId.take(8), fontSize = 14.sp, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace)
Text(if (linked) stringResource(R.string.social_device_linked) else stringResource(R.string.social_device_signed_in_not_linked), fontSize = 12.sp, color = Palette.SecondaryText)
}
if (linked) {
TextButton(onClick = { model.unlinkDevice(device.deviceId) }, modifier = Modifier.testTag("unlink-${device.deviceId}")) {
Text(stringResource(R.string.social_unlink))
}
} else {
TextButton(
onClick = {
numberFor = device.deviceId
number = null
scope.launch { number = model.linkingNumber(device.deviceId) }
},
modifier = Modifier.testTag("link-by-number-${device.deviceId}"),
) { Text(stringResource(R.string.social_link_by_number)) }
}
}
}
Spacer(Modifier.height(Space.sm))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = withHistory, onCheckedChange = { withHistory = it }, modifier = Modifier.testTag("link-with-history"))
Text(stringResource(R.string.social_link_with_history), fontSize = 13.sp)
}
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
TextButton(onClick = { scanning = true }, modifier = Modifier.testTag("link-scan")) { Text(stringResource(R.string.social_scan_device_code)) }
TextButton(onClick = { model.beginLink() }, modifier = Modifier.testTag("link-show-code")) { Text(stringResource(R.string.social_link_this_device)) }
}
phase.linkCode?.let { code ->
Spacer(Modifier.height(Space.sm))
Text(
stringResource(R.string.social_link_code_instructions),
fontSize = 13.sp,
)
QrImage(code, tag = "link-code")
TextButton(onClick = { model.cancelLink() }) { Text(stringResource(R.string.social_stop_showing_code)) }
}
if (scanning) {
ScanAddressDialog(
onFound = { raw ->
scanning = false
scanned = raw
},
onDismiss = { scanning = false },
)
}
// The person confirms before anything is sent: a scanned code is a
// device asking to read everything.
scanned?.let { raw ->
val code = io.flamenet.e2e.LinkCodes.decodeDeviceCode(raw)
androidx.compose.material3.AlertDialog(
onDismissRequest = { scanned = null },
title = { Text(stringResource(R.string.social_link_this_device_question)) },
text = {
Text(
if (code == null) {
stringResource(R.string.social_not_a_link_code)
} else if (withHistory) {
stringResource(R.string.social_link_confirm_with_history, code.deviceId.take(8))
} else {
stringResource(R.string.social_link_confirm_no_history, code.deviceId.take(8))
},
)
},
confirmButton = {
if (code != null) {
TextButton(
onClick = {
scanned = null
model.linkScanned(raw, withHistory)
},
modifier = Modifier.testTag("link-confirm"),
) { Text(stringResource(R.string.social_link)) }
}
},
dismissButton = { TextButton(onClick = { scanned = null }) { Text(stringResource(R.string.common_cancel)) } },
)
}
numberFor?.let { deviceId ->
androidx.compose.material3.AlertDialog(
onDismissRequest = { numberFor = null },
title = { Text(stringResource(R.string.social_compare_number_title)) },
text = {
Column {
Text(
stringResource(R.string.social_compare_number_body, deviceId.take(8)),
)
Spacer(Modifier.height(Space.sm))
Text(
number ?: stringResource(R.string.social_reading_device_list),
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
fontSize = 16.sp,
modifier = Modifier.testTag("link-number"),
)
}
},
confirmButton = {
if (number != null) {
TextButton(onClick = {
numberFor = null
model.linkByNumber(deviceId, withHistory)
}) { Text(stringResource(R.string.social_numbers_match)) }
}
},
dismissButton = { TextButton(onClick = { numberFor = null }) { Text(stringResource(R.string.common_cancel)) } },
)
}
}
/**
* On the new device, in the numeric fallback: an offer waits until the
* person compares the number here too. A confirmation on one side links
* nothing.
*/
@Composable
fun HeldLinkBanner(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
val from = phase.heldLinkOffers.firstOrNull() ?: return
var number by remember(from) { mutableStateOf<String?>(null) }
LaunchedEffect(from) { number = model.linkingNumber(from) }
PortalBand(stringResource(R.string.social_link_request))
PortalBody {
Text(stringResource(R.string.social_held_link_body, from.take(8)))
Text(number ?: "...", fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, fontSize = 16.sp, modifier = Modifier.testTag("held-link-number"))
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
TextButton(onClick = { model.acceptHeldLink(from) }, enabled = number != null, modifier = Modifier.testTag("held-link-accept")) {
Text(stringResource(R.string.social_numbers_match))
}
TextButton(onClick = { model.refuseHeldLink(from) }, modifier = Modifier.testTag("held-link-refuse")) { Text(stringResource(R.string.social_dont_link)) }
}
}
}
// -- Requests (spec 14.5) ------------------------------------------------------------.
/**
* People who wrote and were never accepted. A quiet list, not an alert:
* anybody who can get an introduction can put something here.
*/
@Composable
fun RequestsSection(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
if (phase.requests.isEmpty()) return
PortalBand(stringResource(R.string.social_requests_title, phase.requests.size))
Column(Modifier.fillMaxWidth().testTag("requests")) {
phase.requests.forEach { id ->
val (name, certified) = phase.requestNames[id] ?: (stringResource(R.string.social_user_fallback, id) to false)
PortalRow(modifier = Modifier.clickable { model.openChat(id, name) }.testTag("request-$id")) {
Column(Modifier.weight(1f)) {
Text(name, fontSize = 15.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
if (certified) stringResource(R.string.social_name_confirmed) else stringResource(R.string.social_name_not_confirmed),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
}
TextButton(onClick = { model.acceptRequest(id) }, modifier = Modifier.testTag("request-accept-$id")) { Text(stringResource(R.string.common_accept)) }
TextButton(onClick = { model.setBlocked(id, true) }, modifier = Modifier.testTag("request-block-$id")) {
Text(stringResource(R.string.common_block), color = MaterialTheme.colorScheme.error)
}
TextButton(onClick = { model.deleteRequest(id) }, modifier = Modifier.testTag("request-delete-$id")) { Text(stringResource(R.string.social_delete)) }
}
}
}
}
/** Star and archive for one conversation, as a small menu. */
@Composable
fun ConversationFlagsMenu(
model: MessengerViewModel,
threadId: String,
flags: AccountSync.ConvFlags,
expanded: Boolean,
onDismiss: () -> Unit,
) {
androidx.compose.material3.DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
androidx.compose.material3.DropdownMenuItem(
text = { Text(if (flags.starred) stringResource(R.string.social_unstar) else stringResource(R.string.social_star)) },
onClick = {
onDismiss()
model.setStarred(threadId, !flags.starred)
},
modifier = Modifier.testTag("star-$threadId"),
)
androidx.compose.material3.DropdownMenuItem(
text = { Text(if (flags.archived) stringResource(R.string.social_move_back_to_chats) else stringResource(R.string.social_archive)) },
onClick = {
onDismiss()
model.setArchived(threadId, !flags.archived)
},
modifier = Modifier.testTag("archive-$threadId"),
)
}
}
// -- The export nudge (decision DA9) ----------------------------------------------.
/**
* A single device and no backup for a month: if this phone goes, everything
* goes with it. Said once, with the way out.
*/
@Composable
fun ExportNudge(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
if (!phase.exportNudge) return
PortalBody {
Text(
stringResource(R.string.social_export_nudge_body),
fontSize = 13.sp,
)
TextButton(onClick = { model.showScreen(MessengerViewModel.Phase.Screen.ACCOUNT) }, modifier = Modifier.testTag("export-nudge")) {
Text(stringResource(R.string.social_export_backup))
}
}
}
// -- Who can find you, and contact links (spec 8.12) --------------------------------.
enum class ContactLinkKind { ONE_TIME, REUSABLE }
/**
* Which links this relay lets the person make. A one-time link is a delivery
* token (21.4) and needs no introduction, so every relay gets it; a reusable
* one needs `POST /e2e/contact-links` (8.12).
*/
internal fun contactLinkKindsOffered(features: io.flamenet.client.RelayClient.RelayFeatures): List<ContactLinkKind> =
if (features.contactLinks) {
listOf(ContactLinkKind.ONE_TIME, ContactLinkKind.REUSABLE)
} else {
listOf(ContactLinkKind.ONE_TIME)
}
@Composable
fun FindMeSection(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
val context = LocalContext.current
LaunchedEffect(Unit) { model.refreshListed() }
if (phase.features.unlisted && phase.listed != null) {
Text(stringResource(R.string.social_who_can_find_you), style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
androidx.compose.material3.RadioButton(selected = phase.listed == true, onClick = { model.setListed(true) }, modifier = Modifier.testTag("listed"))
Text(stringResource(R.string.social_listed_option))
}
Row(verticalAlignment = Alignment.CenterVertically) {
androidx.compose.material3.RadioButton(selected = phase.listed == false, onClick = { model.setListed(false) }, modifier = Modifier.testTag("unlisted"))
Text(stringResource(R.string.social_unlisted_option))
}
Text(
stringResource(R.string.social_unlisted_note),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.md))
}
val kinds = contactLinkKindsOffered(phase.features)
Text(stringResource(R.string.social_contact_links_title), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.social_contact_links_explainer),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
TextButton(onClick = { model.createContactLink(reusable = false) }, modifier = Modifier.testTag("link-one-time")) { Text(stringResource(R.string.social_new_one_time_link)) }
if (ContactLinkKind.REUSABLE in kinds) {
TextButton(onClick = { model.createContactLink(reusable = true) }, modifier = Modifier.testTag("link-reusable")) { Text(stringResource(R.string.social_new_reusable_link)) }
}
}
val shareLinkTitle = stringResource(R.string.social_share_link_chooser)
phase.contactLinks.forEach { link ->
Spacer(Modifier.height(Space.sm))
Text(if (link.reusable) stringResource(R.string.social_reusable_link) else stringResource(R.string.social_one_time_link), fontWeight = FontWeight.Medium)
QrImage(link.forms.flamenet, side = 160.dp, tag = "contact-link-qr")
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
TextButton(onClick = {
val send =
android.content.Intent(android.content.Intent.ACTION_SEND)
.setType("text/plain")
.putExtra(android.content.Intent.EXTRA_TEXT, link.forms.https)
context.startActivity(android.content.Intent.createChooser(send, shareLinkTitle))
}) { Text(stringResource(R.string.social_share)) }
TextButton(onClick = { model.revokeContactLink(link.cap) }, modifier = Modifier.testTag("link-revoke")) {
Text(stringResource(R.string.common_remove), color = MaterialTheme.colorScheme.error)
}
}
}
}
// -- Web search ------------------------------------------------------------------.
/**
* Search the web through the relay, which asks an index and keeps nothing.
* Shown only where the relay offers it.
*/
@Composable
fun ColumnScope.WebSearchScreen(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
var query by remember { mutableStateOf("") }
var page by remember { mutableStateOf(1) }
var results by remember { mutableStateOf<WebSearch.Page?>(null) }
var problem by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
val searchUnavailable = stringResource(R.string.social_search_unavailable)
val searchNothingFound = stringResource(R.string.social_search_nothing_found)
val searchRateLimited = stringResource(R.string.social_search_rate_limited)
PortalHeader(title = stringResource(R.string.social_search_the_web), subtitle = phase.relay)
if (!phase.features.webSearch) {
PortalBody { Text(stringResource(R.string.social_web_search_not_offered)) }
return
}
fun run(p: Int) {
if (query.isBlank() || busy) return
busy = true
problem = null
scope.launch {
val got = model.webSearch(query, p)
busy = false
got
.onSuccess {
page = p
results = it
problem =
when {
it.unavailable -> searchUnavailable
it.results.isEmpty() -> searchNothingFound
else -> null
}
}.onFailure { f ->
results = null
problem =
when {
f is io.flamenet.client.RelayException.RateLimited ->
searchRateLimited
f is IllegalArgumentException -> f.message
else -> describeFailure(f)
}
}
}
}
PortalBody {
OutlinedTextField(
value = query,
onValueChange = { query = it.take(WebSearch.MAX_QUERY) },
placeholder = { Text(stringResource(R.string.common_search)) },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("web-search-field"),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { run(1) }),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Search),
)
TextButton(onClick = { run(1) }, enabled = !busy, modifier = Modifier.testTag("web-search-go")) { Text(if (busy) stringResource(R.string.social_searching) else stringResource(R.string.common_search)) }
Text(
stringResource(R.string.social_search_privacy_note),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
problem?.let { Text(it, modifier = Modifier.testTag("web-search-problem")) }
}
results?.let { found ->
Column(Modifier.fillMaxWidth().testTag("web-search-results")) {
found.results.forEach { r ->
PortalRow(
height = 104.dp,
modifier =
Modifier.clickable {
runCatching {
context.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(r.url)))
}
},
) {
Column(Modifier.weight(1f)) {
Text(r.title.ifBlank { r.host }, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(r.host, fontSize = 12.sp, color = MaterialTheme.colorScheme.primary)
if (r.snippet.isNotBlank()) Text(r.snippet, fontSize = 13.sp, maxLines = 3, overflow = TextOverflow.Ellipsis)
}
}
}
if (found.sources.isNotEmpty()) {
PortalBody { Text(stringResource(R.string.social_search_results_from, found.sources.joinToString(", ")), fontSize = 12.sp, color = Palette.SecondaryText) }
}
if (found.results.isNotEmpty() && page < WebSearch.MAX_PAGE) {
TextButton(onClick = { run(page + 1) }, modifier = Modifier.padding(horizontal = Space.md)) { Text(stringResource(R.string.social_more_results)) }
}
}
}
}
/** Whether a thread belongs in the conversation list, the Archived list, or neither. */
internal fun isArchived(
phase: MessengerViewModel.Phase.SignedIn,
userId: Long,
): Boolean = phase.convFlags[Vault.threadForUser(userId)]?.archived == true
internal fun isStarred(
phase: MessengerViewModel.Phase.SignedIn,
userId: Long,
): Boolean = phase.convFlags[Vault.threadForUser(userId)]?.starred == true
@@ -7,9 +7,11 @@ 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.AlertDialog
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.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -22,14 +24,24 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.sp
import io.flamenet.messenger.R
import io.flamenet.client.Obfs4Routing
import io.flamenet.obfs4.BridgeLine
import io.flamenet.obfs4.BridgeLineResult
import io.flamenet.obfs4.Obfs4Stats
import io.flamenet.obfs4.Obfs4Bridge
import java.text.DateFormat
import java.util.Date
/**
* Reaching Flamenet on a network that blocks it.
* Reaching Flamenet on a network that blocks it (SPEC §24).
*
* The same panel sits in Settings when signed in and behind "Connect through a
* bridge" on the sign-in and sign-up screens, before any account exists. Offers
* from contacts and sharing a line need an account, so they appear only when
* [model] is signed in and [buddies] is not empty.
*/
@Composable
fun ColumnScope.NetworkSection(
@@ -38,25 +50,30 @@ fun ColumnScope.NetworkSection(
) {
val context = LocalContext.current
val settings = remember { Obfs4Settings(context) }
var draft by remember { mutableStateOf(settings.bridgeLine ?: "") }
var stored by remember { mutableStateOf(settings.bridgeLine) }
// Bumped on save/remove so the live status below re-reads.
var draft by remember { mutableStateOf("") }
var refusal by remember { mutableStateOf<String?>(null) }
// Bumped on every change so the lists and the live status re-read.
var tick by remember { mutableIntStateOf(0) }
// Who has been handed the line this sitting.
var sharedWith by remember { mutableStateOf(setOf<Long>()) }
var confirmShare by remember { mutableStateOf<io.flamenet.client.MessengerApi.Buddy?>(null) }
var scanning by remember { mutableStateOf(false) }
val askCamera =
androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts
.RequestPermission(),
) { _ -> scanning = true }
@Suppress("UNUSED_EXPRESSION")
tick
val lines = remember(tick) { settings.lines }
val parsed = if (draft.isBlank()) null else BridgeLine.parse(draft)
val isUnchanged = draft.trim() == (stored ?: "")
Text("Network", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.set_network_title), style = MaterialTheme.typography.titleMedium)
// Reads the handshake counter and the bridge in force, never the stored
// line.
// Reads what the transport did, never the stored list.
Text(
statusLine(),
statusLineText(),
fontSize = 13.sp,
color = Palette.SecondaryText,
modifier = Modifier.testTag("obfs4-status"),
@@ -66,11 +83,11 @@ fun ColumnScope.NetworkSection(
val offers = remember(tick) { model?.bridgeOffers().orEmpty() }
if (offers.isNotEmpty()) {
Spacer(Modifier.height(Space.md))
Text("Offered to you", fontWeight = FontWeight.Medium, fontSize = 15.sp)
Text(stringResource(R.string.set_network_offered), fontWeight = FontWeight.Medium, fontSize = 15.sp)
for (offer in offers) {
Spacer(Modifier.height(Space.sm))
Text(
"${offer.fromName} shared a bridge",
stringResource(R.string.set_network_shared_a_bridge, offer.fromName),
fontSize = 14.sp,
modifier = Modifier.testTag("obfs4-offer-${offer.fromUser}"),
)
@@ -81,36 +98,23 @@ fun ColumnScope.NetworkSection(
fontFamily = FontFamily.Monospace,
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,
)
}
}
Reading(offer.line)
Spacer(Modifier.height(Space.xs))
Row(modifier = Modifier.fillMaxWidth()) {
KitPrimaryButton(
text = "Use this bridge",
text = stringResource(R.string.set_network_use_bridge),
onClick = {
if (settings.accept(offer.line) is BridgeLineResult.Ok) {
draft = offer.line
stored = settings.bridgeLine
model?.dismissBridgeOffer(offer.fromUser)
tick++
when (val result = settings.accept(offer.line, from = offer.fromName)) {
is Obfs4Settings.AddResult.Added, Obfs4Settings.AddResult.AlreadySaved -> {
model?.dismissBridgeOffer(offer.fromUser)
refusal = null
}
else -> {
refusal = addResultWords(context, result)
}
}
tick++
},
enabled = parsedOk(offer.line),
modifier = Modifier.weight(1f),
@@ -118,7 +122,7 @@ fun ColumnScope.NetworkSection(
)
Spacer(Modifier.width(Space.sm))
KitSecondaryButton(
text = "Dismiss",
text = stringResource(R.string.set_network_dismiss),
onClick = {
model?.dismissBridgeOffer(offer.fromUser)
tick++
@@ -130,11 +134,55 @@ fun ColumnScope.NetworkSection(
}
}
// The stored lines (§24.6).
if (lines.isNotEmpty()) {
Spacer(Modifier.height(Space.md))
Text(stringResource(R.string.set_network_your_bridges), fontWeight = FontWeight.Medium, fontSize = 15.sp)
lines.forEachIndexed { index, stored ->
Spacer(Modifier.height(Space.sm))
Text(
stored.line,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier.testTag("obfs4-line-$index"),
)
Reading(stored.line)
stored.offeredBy?.let {
Text(stringResource(R.string.set_network_from, it), fontSize = 12.sp, color = Palette.SecondaryText)
}
Text(
stored.lastWorked?.let {
stringResource(
R.string.set_network_last_worked,
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(it * 1000)),
)
} ?: stringResource(R.string.set_network_not_connected_yet),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
TextButton(
onClick = {
settings.remove(stored.line)
tick++
},
modifier = Modifier.testTag("obfs4-remove-$index"),
) { Text(stringResource(R.string.common_remove), color = Palette.Danger) }
}
Text(
stringResource(R.string.set_network_tries_in_turn),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
}
Spacer(Modifier.height(Space.md))
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
onValueChange = {
draft = it
refusal = null
},
placeholder = { Text("obfs4 198.51.100.7:4444 …", fontSize = 13.sp) },
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
minLines = 2,
@@ -156,7 +204,7 @@ fun ColumnScope.NetworkSection(
is BridgeLineResult.Ok -> {
Text(
"Reads as ${parsed.bridge.host} port ${parsed.bridge.port}",
stringResource(R.string.set_network_reads_as, parsed.bridge.host, parsed.bridge.port),
fontSize = 12.sp,
color = Palette.SecondaryText,
modifier = Modifier.testTag("obfs4-parsed"),
@@ -175,47 +223,56 @@ fun ColumnScope.NetworkSection(
Unit
}
}
refusal?.let {
Text(it, fontSize = 12.sp, color = Palette.Danger, modifier = Modifier.testTag("obfs4-refusal"))
}
Spacer(Modifier.height(Space.sm))
KitPrimaryButton(
text = "Save this bridge",
text = stringResource(R.string.set_network_save),
onClick = {
// Goes through `save`, which parses again and stores only what it
// Goes through `add`, which parses again and stores only what it
// could use.
if (settings.save(draft) is BridgeLineResult.Ok) {
stored = settings.bridgeLine
tick++
when (val result = settings.add(draft)) {
is Obfs4Settings.AddResult.Added -> {
draft = ""
refusal = null
}
else -> {
refusal = addResultWords(context, result)
}
}
tick++
},
enabled = parsed is BridgeLineResult.Ok && !isUnchanged,
enabled = parsed is BridgeLineResult.Ok,
modifier = Modifier.fillMaxWidth(),
tag = "obfs4-save",
)
Spacer(Modifier.height(Space.sm))
KitSecondaryButton(
text = stringResource(R.string.set_network_scan_code),
onClick = {
val has =
androidx.core.content.ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.CAMERA,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (has) scanning = true else askCamera.launch(android.Manifest.permission.CAMERA)
},
modifier = Modifier.fillMaxWidth(),
tag = "obfs4-scan",
)
if (stored != null) {
Spacer(Modifier.height(Space.sm))
KitSecondaryButton(
text = "Stop using a bridge",
onClick = {
settings.clear()
draft = ""
stored = null
tick++
},
modifier = Modifier.fillMaxWidth(),
tag = "obfs4-remove",
)
}
// The other half of receiving one.
if (stored != null && buddies.isNotEmpty()) {
// The other half of receiving one (§24.3).
val share = remember(tick) { settings.bridgeLine }
if (share != null && model != null && buddies.isNotEmpty()) {
Spacer(Modifier.height(Space.md))
Text("Share it with", fontWeight = FontWeight.Medium, fontSize = 15.sp)
Text(stringResource(R.string.set_network_share_with), fontWeight = FontWeight.Medium, fontSize = 15.sp)
Text(
// 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.",
stringResource(R.string.set_network_share_note),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
@@ -230,11 +287,13 @@ fun ColumnScope.NetworkSection(
modifier = Modifier.weight(1f),
)
KitSecondaryButton(
text = if (sharedWith.contains(buddy.userId)) "Sent" else "Send",
onClick = {
model?.shareBridge(buddy.userId)
sharedWith = sharedWith + buddy.userId
},
text =
if (sharedWith.contains(buddy.userId)) {
stringResource(R.string.set_network_sent)
} else {
stringResource(R.string.common_send)
},
onClick = { confirmShare = buddy },
// Not disabled after sending.
tag = "obfs4-share-${buddy.userId}",
)
@@ -245,25 +304,84 @@ fun ColumnScope.NetworkSection(
Spacer(Modifier.height(Space.sm))
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=.",
stringResource(R.string.set_network_paste_hint),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.sm))
Text(EXPLANATION, fontSize = 12.sp, color = Palette.SecondaryText)
Text(stringResource(R.string.set_network_explanation), fontSize = 12.sp, color = Palette.SecondaryText)
confirmShare?.let { buddy ->
AlertDialog(
onDismissRequest = { confirmShare = null },
title = { Text(stringResource(R.string.set_network_share_title)) },
text = { Text(stringResource(R.string.set_network_share_note)) },
confirmButton = {
TextButton(
onClick = {
model?.shareBridge(buddy.userId)
sharedWith = sharedWith + buddy.userId
confirmShare = null
},
modifier = Modifier.testTag("obfs4-share-confirm"),
) { Text(stringResource(R.string.common_send)) }
},
dismissButton = {
TextButton(onClick = { confirmShare = null }) { Text(stringResource(R.string.common_cancel)) }
},
)
}
if (scanning) {
ScanAddressDialog(
onFound = { raw ->
scanning = false
draft = raw.trim()
refusal = null
},
onDismiss = { scanning = false },
hint = stringResource(R.string.set_network_scan_hint),
denied = stringResource(R.string.set_network_scan_denied),
)
}
}
/** What reading a line gave (§24.2): the problem, or where it points. */
@Composable
private fun Reading(line: String) {
when (val parsed = BridgeLine.parse(line)) {
is BridgeLineResult.Bad -> {
// An offer that cannot be used says why, rather than failing when
// it is accepted.
Text(parsed.problem.message, fontSize = 12.sp, color = Palette.Danger)
}
is BridgeLineResult.Ok -> {
Text(
stringResource(R.string.set_network_reads_as, parsed.bridge.host, parsed.bridge.port),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
parsed.caveat?.let { Text(it, fontSize = 12.sp, color = Palette.SecondaryText) }
}
}
}
private fun parsedOk(line: String) = BridgeLine.parse(line) is BridgeLineResult.Ok
/**
* What is actually happening, as opposed to what is stored.
* What is actually happening, as opposed to what is stored (§24.6).
*
* @param bridge the line that carried the last connection, else the first to try.
* @param handshakes above zero when [bridge] carried the last connection.
*/
internal fun statusLine(
bridge: io.flamenet.obfs4.Obfs4Bridge? = Obfs4Routing.bridge,
handshakes: Int = Obfs4Stats.completedHandshakes(),
bridge: Obfs4Bridge? = Obfs4Routing.lastCarried ?: Obfs4Routing.bridge,
handshakes: Int = if (Obfs4Routing.lastCarried != null) 1 else 0,
count: Int = Obfs4Routing.bridges.size,
everyLineFailed: Boolean = Obfs4Routing.everyLineFailed,
): String =
when {
bridge == null -> {
@@ -277,7 +395,19 @@ internal fun statusLine(
"always did."
}
// Never a direct connection while a line is stored.
everyLineFailed -> {
"No bridge could be reached. The app tried each one in turn and keeps trying, " +
"and it won't connect without a bridge while one is saved. Remove them all " +
"to connect directly."
}
// Not "protected".
count > 1 -> {
"$count bridges saved. Nothing has gone through them yet, the app will try them " +
"the next time it goes online."
}
else -> {
"Bridge saved: ${bridge.host}:${bridge.port}. Nothing has gone through it yet, the " +
"app will try the next time it goes online."
@@ -285,15 +415,35 @@ internal fun statusLine(
}
/**
* Says what it does not protect, in the same breath as what it does.
* The refusal for a line that could not be added, in the phone's language.
* [Obfs4Settings.AddResult.message] keeps the English, which tests read.
*/
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 " +
"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 " +
"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."
private fun addResultWords(
context: android.content.Context,
result: Obfs4Settings.AddResult,
): String =
when (result) {
Obfs4Settings.AddResult.TooMany -> context.getString(R.string.set_network_too_many)
Obfs4Settings.AddResult.AlreadySaved -> context.getString(R.string.set_network_already_saved)
else -> result.message
}
/**
* [statusLine] in the phone's language, for the screen. The English function
* stays because tests pin it without a Context; the branches here must stay
* in step with it, including that nothing is said to go around a saved bridge.
*/
@Composable
internal fun statusLineText(
bridge: Obfs4Bridge? = Obfs4Routing.lastCarried ?: Obfs4Routing.bridge,
handshakes: Int = if (Obfs4Routing.lastCarried != null) 1 else 0,
count: Int = Obfs4Routing.bridges.size,
everyLineFailed: Boolean = Obfs4Routing.everyLineFailed,
): String =
when {
bridge == null -> stringResource(R.string.set_network_status_direct)
handshakes > 0 -> stringResource(R.string.set_network_status_through, bridge.host, bridge.port)
everyLineFailed -> stringResource(R.string.set_network_status_none_reached)
count > 1 -> pluralStringResource(R.plurals.set_network_status_saved_many, count, count)
else -> stringResource(R.string.set_network_status_saved, bridge.host, bridge.port)
}
@@ -23,7 +23,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.sp
import io.flamenet.messenger.R
/**
* What this phone's notifications are allowed to say.
@@ -37,7 +39,7 @@ fun ColumnScope.NotificationSection() {
var previews by remember { mutableStateOf(settings.showPreviews) }
var sounds by remember { mutableStateOf(settings.inAppSounds) }
Text("Notifications", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.set_notify_title), style = MaterialTheme.typography.titleMedium)
@Composable
fun row(
@@ -59,20 +61,20 @@ fun ColumnScope.NotificationSection() {
}
}
row("Notify me", "notify-enabled", enabled) { on ->
row(stringResource(R.string.set_notify_enabled), "notify-enabled", enabled) { on ->
settings.enabled = on
enabled = on
}
if (enabled) {
row("New messages", "notify-messages", newMessages) { on ->
row(stringResource(R.string.set_notify_new_messages), "notify-messages", newMessages) { on ->
settings.newMessages = on
newMessages = on
}
row("Show who it is from", "notify-previews", previews) { on ->
row(stringResource(R.string.set_notify_show_sender), "notify-previews", previews) { on ->
settings.showPreviews = on
previews = on
}
row("Sound while the app is open", "notify-sounds", sounds) { on ->
row(stringResource(R.string.set_notify_sound_in_app), "notify-sounds", sounds) { on ->
settings.inAppSounds = on
sounds = on
}
@@ -92,20 +94,18 @@ fun ColumnScope.NotificationSection() {
if (manager?.canUseFullScreenIntent() == false) {
Spacer(Modifier.height(Space.sm))
Text(
"Calls will not ring full screen",
stringResource(R.string.set_notify_fsi_title),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.testTag("notify-fsi-title"),
)
Text(
"Android is holding back the permission that lets an incoming call " +
"take over the screen, so a call shows as a banner for a couple of " +
"seconds and then goes. Grant it and calls ring properly.",
stringResource(R.string.set_notify_fsi_body),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.xs))
KitSecondaryButton(
text = "Let calls ring",
text = stringResource(R.string.set_notify_fsi_grant),
onClick = {
runCatching {
context.startActivity(
@@ -127,11 +127,9 @@ fun ColumnScope.NotificationSection() {
// 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."
stringResource(R.string.set_notify_previews_on_note)
} else {
"Notifications say how many messages arrived and nothing else, no name, no " +
"words. Calls still ring."
stringResource(R.string.set_notify_previews_off_note)
},
fontSize = 12.sp,
color = Palette.SecondaryText,
@@ -1,6 +1,7 @@
package io.flamenet.messenger.app
import android.content.Context
import io.flamenet.e2e.Vault
/**
* What a notification is allowed to say, and whether to post one at all.
@@ -10,6 +11,9 @@ class NotificationSettings(
) {
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
/** For the notification text in the phone's language; see [messageText]. */
private val res = context.resources
/** The master switch. Off means no notification of any kind is posted. */
var enabled: Boolean
get() = prefs.getBoolean(ENABLED, true)
@@ -38,13 +42,40 @@ class NotificationSettings(
fun messageText(
threads: Int,
from: String?,
): String = messageText(showPreviews, threads, from)
): String =
// The same three cases as the companion's messageText, which stays in
// English because NotificationTextTest pins it without a Context.
when {
showPreviews && threads == 1 && !from.isNullOrBlank() ->
res.getString(io.flamenet.messenger.R.string.core_notif_new_message_from, from)
threads == 1 -> res.getString(io.flamenet.messenger.R.string.core_notif_new_message)
else -> res.getString(io.flamenet.messenger.R.string.core_notif_new_messages_in_chats, threads)
}
/** The text for a card or an event that arrived alone. */
fun noticeText(
notice: Session.Notice,
from: String?,
opensOn: String?,
): String = noticeText(showPreviews, notice, from, opensOn)
/** The same question for a reply reminder. */
fun replyReminderText(
count: Int,
from: String?,
): String = replyReminderText(showPreviews, count, from)
): String =
when {
showPreviews && count == 1 && !from.isNullOrBlank() ->
res.getString(io.flamenet.messenger.R.string.core_notif_no_reply_from, from)
count == 1 -> res.getString(io.flamenet.messenger.R.string.core_notif_no_reply)
else -> res.getString(io.flamenet.messenger.R.string.core_notif_no_reply_in_chats, count)
}
/** One poll's notification: how many chats it covers, and the notice that words it. */
data class Due(
val threads: Int,
val notice: Session.Notice?,
)
companion object {
/**
@@ -62,6 +93,82 @@ class NotificationSettings(
else -> "New messages in $threads chats"
}
/**
* The line for a card, an event or a game's news that was the only
* thing to arrive (§6.31 to §6.33). Never the words of a card, sealed
* or not: a notification is read on a lock screen. With previews off,
* or nobody to name, it is the generic line any message gets.
*
* @param opensOn the day a sealed card opens, as the reader's calendar
* writes it, or null when it is not sealed.
*/
fun noticeText(
showPreviews: Boolean,
notice: Session.Notice,
from: String?,
opensOn: String?,
): String {
val generic = messageText(showPreviews = false, threads = 1, from = null)
if (!showPreviews) return generic
val name = from?.takeIf { it.isNotBlank() }
return when (notice) {
is Session.Notice.Card ->
when {
name == null -> generic
opensOn != null -> "$name sent you a card to open on $opensOn"
else -> "$name sent you a card"
}
is Session.Notice.Invite -> if (name == null) generic else "$name invited you: ${notice.title}"
is Session.Notice.Changed ->
if (notice.cancelled) "${notice.title} was cancelled" else "${notice.title} changed"
// §6.33: "Sam invited you to play chess" (DESIGN 29), "Your
// move: chess with Sam", and how a game ended.
is Session.Notice.GameInvite ->
if (name == null) generic else "$name invited you to play ${ChatGames.lowerName(notice.type)}"
is Session.Notice.GameTurn ->
"Your move: ${ChatGames.lowerName(notice.type)}" + (name?.let { " with $it" } ?: "")
is Session.Notice.GameOver ->
ChatGames.name(notice.type) + (name?.let { " with $it" } ?: "") + ": " + notice.result
is Session.Notice.GameDrawOffer -> if (name == null) generic else "$name offers a draw: ${ChatGames.name(notice.type)}"
}
}
/**
* Said without a sound: their move and a draw offer (§6.33). An
* invitation and an ending come with one (DESIGN 29), like a message.
*/
fun isQuiet(notice: Session.Notice?): Boolean =
notice is Session.Notice.GameTurn || notice is Session.Notice.GameDrawOffer
/**
* What one poll's notification is about, or null for none. A snoozed
* chat holds every notification until the snooze ends, an ordinary
* message's as well as a game's, and what came still counts as
* unread. Game news that is not unread (DESIGN 29) is said only when
* no chat has anything else to say.
*
* @param touched the chats the poll made unread.
* @param snoozed the chats snoozed now, by thread id.
*/
fun due(
touched: Set<String>,
notice: Session.Notice?,
gameNews: Session.Notice?,
snoozed: Set<String>,
): Due? {
val heard = touched - snoozed
if (heard.isNotEmpty()) return Due(heard.size, notice.takeIf { heard == touched })
// Games are one-to-one (§6.33), so the sender names the chat.
val news = gameNews?.takeIf { Vault.threadForUser(it.from) !in snoozed } ?: return null
return Due(1, news)
}
fun replyReminderText(
showPreviews: Boolean,
count: Int,
@@ -3,72 +3,190 @@ package io.flamenet.messenger.app
import android.content.Context
import io.flamenet.client.Obfs4Routing
import io.flamenet.obfs4.BridgeLine
import io.flamenet.obfs4.BridgeLineProblem
import io.flamenet.obfs4.BridgeLineResult
import io.flamenet.obfs4.Obfs4Bridge
import org.json.JSONArray
import org.json.JSONObject
/**
* The bridge line this phone uses, if any.
* The bridge lines this phone uses, SPEC §24.6: up to eight, in the order they
* were added. A line the person pasted and one accepted from a contact are
* stored alike; who offered it is kept only to show beside it.
*
* In the app's private preferences, which `dataExtractionRules` keeps out of
* every backup and device transfer (§24.5).
*/
class Obfs4Settings(
context: Context,
) {
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
/**
* The stored line, exactly as it was pasted.
*/
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()
/** One stored line. */
data class StoredLine(
val line: String,
val addedAt: Long,
val offeredBy: String?,
val lastWorked: Long?,
)
/** What adding a line came to. */
sealed interface AddResult {
val message: String
data class Added(
val bridge: Obfs4Bridge,
val caveat: String?,
) : AddResult {
override val message = ""
}
/**
* Stores a line and starts using it, or reports why it cannot be used.
*/
fun save(line: String): BridgeLineResult {
data class Unreadable(
val problem: BridgeLineProblem,
) : AddResult {
override val message get() = problem.message
}
object TooMany : AddResult {
override val message = "You can keep up to 8 bridges. Remove one before adding another."
}
object AlreadySaved : AddResult {
override val message = "This bridge is already in your list."
}
}
/** The stored lines, in the order they were added. */
val lines: List<StoredLine>
get() {
prefs.getString(LINES, null)?.let { return decode(it) }
// A one-line build stored its line on its own. Take it, and remove
// the old copy.
val old = prefs.getString(BRIDGE_LINE, null)?.trim()
prefs.edit().remove(BRIDGE_LINE).apply()
if (old.isNullOrEmpty()) return emptyList()
val migrated = listOf(StoredLine(old, 0, null, null))
write(migrated)
return migrated
}
/** The line to hand a contact: the one in use now, else the first. */
val bridgeLine: String?
get() {
val all = lines
val live = Obfs4Routing.order().firstOrNull()
return all.firstOrNull { BridgeLine.parse(it.line).let { r -> r is BridgeLineResult.Ok && r.bridge == live } }?.line
?: all.firstOrNull()?.line
}
private fun decode(text: String): List<StoredLine> =
runCatching {
val array = JSONArray(text)
(0 until array.length()).mapNotNull { i ->
val o = array.optJSONObject(i) ?: return@mapNotNull null
val line = o.optString("line").takeIf { it.isNotBlank() } ?: return@mapNotNull null
StoredLine(
line = line,
addedAt = o.optLong("addedAt", 0),
offeredBy = o.optString("offeredBy").takeIf { o.has("offeredBy") && it.isNotEmpty() },
lastWorked = if (o.has("lastWorked")) o.optLong("lastWorked") else null,
)
}
}.getOrDefault(emptyList())
private fun write(all: List<StoredLine>) {
if (all.isEmpty()) {
prefs.edit().remove(LINES).apply()
return
}
val array = JSONArray()
for (l in all) {
array.put(
JSONObject()
.put("line", l.line)
.put("addedAt", l.addedAt)
.apply {
l.offeredBy?.let { put("offeredBy", it) }
l.lastWorked?.let { put("lastWorked", it) }
},
)
}
prefs.edit().putString(LINES, array.toString()).apply()
}
/** Puts the stored list into force, the line that last worked first. */
private fun route(all: List<StoredLine>) {
val parsed = all.mapNotNull { l -> (BridgeLine.parse(l.line) as? BridgeLineResult.Ok)?.let { l to it.bridge } }
val latest = parsed.filter { it.first.lastWorked != null }.maxByOrNull { it.first.lastWorked!! }?.second
Obfs4Routing.onWorked = { bridge -> markWorked(bridge) }
Obfs4Routing.use(parsed.map { it.second }, lastWorked = latest)
}
/** Adds a line at the end of the list and starts using it. */
fun add(
line: String,
offeredBy: String? = null,
): AddResult {
val trimmed = line.trim()
val result = BridgeLine.parse(trimmed)
if (result is BridgeLineResult.Ok) {
bridgeLine = trimmed
Obfs4Routing.use(result.bridge)
}
return result
val bridge =
when (val result = BridgeLine.parse(trimmed)) {
is BridgeLineResult.Bad -> return AddResult.Unreadable(result.problem)
is BridgeLineResult.Ok -> result
}
val all = lines
val same =
all.any { stored ->
(BridgeLine.parse(stored.line) as? BridgeLineResult.Ok)?.bridge?.let {
it.host.equals(bridge.bridge.host, ignoreCase = true) && it.port == bridge.bridge.port
} ?: false
}
if (same) return AddResult.AlreadySaved
if (all.size >= MAX_LINES) return AddResult.TooMany
val next = all + StoredLine(trimmed, System.currentTimeMillis() / 1000, offeredBy, null)
write(next)
route(next)
return AddResult.Added(bridge.bridge, bridge.caveat)
}
/** Stop using a bridge, and drop the connections that were using one. */
/**
* Removes a line. Connections it carried close at once (§24.7). Removing
* the last turns bridges off.
*/
fun remove(line: String) {
val next = lines.filter { it.line != line }
write(next)
route(next)
}
/** Stop using bridges altogether. */
fun clear() {
bridgeLine = null
Obfs4Routing.use(null)
write(emptyList())
prefs.edit().remove(BRIDGE_LINE).apply()
Obfs4Routing.use(emptyList())
}
/**
* 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.Bad -> {
bridgeLine = null
Obfs4Routing.use(null)
}
}
/** Records that a line carried a connection that reached the far end. */
fun markWorked(bridge: Obfs4Bridge) {
val all = lines
val i =
all.indexOfFirst { (BridgeLine.parse(it.line) as? BridgeLineResult.Ok)?.bridge == bridge }
if (i < 0) return
write(all.toMutableList().also { it[i] = it[i].copy(lastWorked = System.currentTimeMillis() / 1000) })
}
/**
* Accept a bridge a contact offered.
*/
fun accept(offeredLine: String): BridgeLineResult = save(offeredLine)
/** Puts the stored lines into force. */
fun applyAtStartup() = route(lines)
/** Accept a bridge a contact offered: it joins the list. */
fun accept(
offeredLine: String,
from: String,
): AddResult = add(offeredLine, offeredBy = from)
private companion object {
const val PREFS = "flamenet.obfs4"
const val BRIDGE_LINE = "bridge-line"
const val LINES = "bridge-lines"
const val MAX_LINES = 8
}
}
@@ -46,6 +46,13 @@ object ParentalControls {
fun callsDisabled(context: Context) = isEnabled(context) && prefs(context).getBoolean(CALLS_DISABLED, false)
/**
* The Today tab and the small-web directory behind it: hidden whenever
* supervision is on (DESIGN 31). Headlines and links to the open web are
* not a gate a parent releases one by one.
*/
fun todayHidden(context: Context) = isEnabled(context)
fun setGate(
context: Context,
key: String,
@@ -20,8 +20,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.sp
import io.flamenet.messenger.R
/**
* Parental controls on the Account screen: a parent's PIN guarding what this
@@ -41,16 +43,12 @@ fun ParentalControlsSection() {
@Suppress("UNUSED_EXPRESSION")
tick
Text("Parental controls", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.set_parental_title), 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.",
stringResource(R.string.set_parental_intro),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
@@ -58,7 +56,7 @@ fun ParentalControlsSection() {
OutlinedTextField(
value = newPin,
onValueChange = { newPin = it },
placeholder = { Text("Parent PIN (at least 4 digits)", fontSize = 14.sp) },
placeholder = { Text(stringResource(R.string.set_parental_pin_placeholder), fontSize = 14.sp) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth().testTag("parental-pin"),
@@ -67,7 +65,7 @@ fun ParentalControlsSection() {
OutlinedTextField(
value = repeatPin,
onValueChange = { repeatPin = it },
placeholder = { Text("Repeat PIN", fontSize = 14.sp) },
placeholder = { Text(stringResource(R.string.set_parental_repeat_pin), fontSize = 14.sp) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth().testTag("parental-pin-repeat"),
@@ -83,12 +81,12 @@ fun ParentalControlsSection() {
},
enabled = newPin.length >= 4 && newPin == repeatPin,
modifier = Modifier.testTag("parental-enable"),
) { Text("Turn on supervision") }
) { Text(stringResource(R.string.set_parental_turn_on)) }
}
!unlocked -> {
Text(
"Supervision is on. The settings behind this are the parent's to change.",
stringResource(R.string.set_parental_on_note),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
@@ -99,7 +97,7 @@ fun ParentalControlsSection() {
pinEntry = it
wrong = false
},
placeholder = { Text("Parent PIN", fontSize = 14.sp) },
placeholder = { Text(stringResource(R.string.set_parental_pin), fontSize = 14.sp) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
isError = wrong,
@@ -107,7 +105,7 @@ fun ParentalControlsSection() {
)
if (wrong) {
Text(
"That's not the PIN.",
stringResource(R.string.set_parental_wrong_pin),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.error,
)
@@ -120,17 +118,16 @@ fun ParentalControlsSection() {
},
enabled = pinEntry.isNotEmpty(),
modifier = Modifier.testTag("parental-unlock"),
) { Text("Unlock") }
) { Text(stringResource(R.string.common_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++ }
GateRow(stringResource(R.string.set_parental_gate_contacts), ParentalControls.CONTACTS_GATED) { tick++ }
GateRow(stringResource(R.string.set_parental_gate_stories), ParentalControls.STORIES_HIDDEN) { tick++ }
GateRow(stringResource(R.string.set_parental_gate_calls), ParentalControls.CALLS_DISABLED) { tick++ }
Text(
"With calls off, this phone neither places calls nor rings, a " +
"caller is answered busy.",
stringResource(R.string.set_parental_calls_off_note),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
@@ -142,7 +139,7 @@ fun ParentalControlsSection() {
tick++
},
modifier = Modifier.testTag("parental-disable"),
) { Text("Turn off supervision") }
) { Text(stringResource(R.string.set_parental_turn_off)) }
}
}
}
@@ -11,7 +11,9 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
@@ -28,24 +30,26 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.flamenet.messenger.R
/**
* Stickers, from the shipped pack.
* Stickers, one section per shipped pack, in manifest order.
*/
@Composable
fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) {
val context = LocalContext.current
val stickers = StickerPack.all(context)
val packs = StickerPack.packs(context).filter { it.stickers.isNotEmpty() }
if (stickers.isEmpty()) {
// The manifest failed to load.
if (packs.isEmpty()) {
// No manifest would load.
Text(
"Stickers could not be loaded on this device.",
stringResource(R.string.main_stickers_failed),
fontSize = 14.sp,
color = Palette.SecondaryText,
modifier = Modifier.padding(Kit.gutter),
@@ -58,7 +62,7 @@ fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) {
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 220.dp)
.heightIn(max = 260.dp)
.clip(RoundedCornerShape(Radius.large))
.background(Palette.SurfaceAlt)
.testTag("sticker-picker"),
@@ -66,23 +70,66 @@ fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) {
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}"),
contentAlignment = Alignment.Center,
) {
Text(sticker.glyph, fontSize = 34.sp, modifier = Modifier.padding(Space.sm))
packs.forEach { pack ->
item(key = "pack-${pack.id}", span = { GridItemSpan(maxLineSpan) }) {
Text(
pack.name,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
color = Palette.SecondaryText,
modifier =
Modifier
.padding(horizontal = Space.xs, vertical = Space.xs)
.testTag("sticker-pack-${pack.id}"),
)
}
items(pack.stickers, key = { "${it.pack}/${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}"),
contentAlignment = Alignment.Center,
) {
StickerArt(StickerPack.Resolved(sticker.image, sticker.glyph, sticker.name), size = 48.dp)
}
}
}
}
}
/**
* One sticker as drawn: its picture when the pack ships one and it loads,
* otherwise its glyph. Described by the caller, so this adds no label.
*/
@Composable
fun StickerArt(
sticker: StickerPack.Resolved,
size: androidx.compose.ui.unit.Dp,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val picture = remember(sticker.image) { sticker.image?.let { StickerPack.image(context, it) } }
if (picture != null) {
androidx.compose.foundation.Image(
bitmap = picture,
contentDescription = null,
modifier = modifier.size(size),
)
} else {
// Glyphs are set in sp at about two thirds of the box, which is how
// the old 64sp sticker sat in its space.
Text(
sticker.glyph,
fontSize = (size.value * 0.68f).sp,
modifier = modifier.padding(Space.xs),
)
}
}
/**
* Emoji, straight into the draft.
*/
@@ -116,13 +163,16 @@ fun ColumnScope.EmojiPicker(onPick: (String) -> Unit) {
.testTag("emoji-picker"),
verticalArrangement = Arrangement.spacedBy(Space.xs),
) {
if (recents.isNotEmpty()) EmojiSection("Recent", recents, ::pick)
EMOJI_GROUPS.forEach { (name, emoji) -> EmojiSection(name, emoji, ::pick) }
if (recents.isNotEmpty()) EmojiSection("Recent", stringResource(R.string.main_emoji_recent), recents, ::pick)
EMOJI_GROUPS.forEach { (name, emoji) -> EmojiSection(name, stringResource(emojiGroupLabel(name)), emoji, ::pick) }
}
}
@Composable
private fun EmojiSection(
// The English group name, kept for the test tag so tests do not depend
// on the device language.
tag: String,
title: String,
emoji: List<String>,
onPick: (String) -> Unit,
@@ -149,7 +199,7 @@ private fun EmojiSection(
// 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"),
.testTag("emoji-$tag-$e"),
) {
Text(e, fontSize = 24.sp, modifier = Modifier.padding(Space.xs))
}
@@ -157,6 +207,15 @@ private fun EmojiSection(
}
}
/** The shown name of an EMOJI_GROUPS entry. */
private fun emojiGroupLabel(name: String): Int =
when (name) {
"Smileys" -> R.string.main_emoji_smileys
"Gestures" -> R.string.main_emoji_gestures
"Hearts" -> R.string.main_emoji_hearts
else -> R.string.main_emoji_things
}
/**
* The same set as iOS, grouped the same way, kept in step on purpose.
*/
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -26,11 +27,13 @@ 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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.flamenet.e2e.Poll
import io.flamenet.e2e.PollBook
import io.flamenet.messenger.R
/**
* A poll in a group, and what it is waiting for (§6.29).
@@ -62,10 +65,10 @@ fun ColumnScope.PollCard(
// your vote and sees the tally, and subtracting gives it, with nobody
// to collude with. The word is not offered for a pair.
Text(
if (poll.members.size > 2) "Anonymous poll" else "Poll",
if (poll.members.size > 2) stringResource(R.string.social_poll_anonymous) else stringResource(R.string.social_poll),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Palette.SecondaryText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(poll.question, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
@@ -89,15 +92,15 @@ fun ColumnScope.PollCard(
}
} else {
Text(
"You chose ${poll.options[poll.myChoice ?: 0]}.",
stringResource(R.string.social_poll_you_chose, poll.options[poll.myChoice ?: 0]),
fontSize = 14.sp,
color = Palette.SecondaryText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
waitingLine(poll, standing, name),
fontSize = 13.sp,
color = Palette.SecondaryText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -105,6 +108,7 @@ fun ColumnScope.PollCard(
Spacer(Modifier.height(Space.sm))
}
@Composable
private fun waitingLine(
poll: PollBook.Entry,
standing: PollBook.Standing,
@@ -113,27 +117,28 @@ private fun waitingLine(
when (standing) {
is PollBook.Standing.WaitingForVotes ->
if (poll.myChoice == null) {
"Nobody sees a count until everyone has voted."
stringResource(R.string.social_poll_hidden_until_all_voted)
} else {
"Waiting for ${list(standing.from, name)} to vote."
stringResource(R.string.social_poll_waiting_for_votes, list(standing.from, name))
}
is PollBook.Standing.WaitingForSums ->
"Everyone voted. Counting, waiting for ${list(standing.from, name)}."
stringResource(R.string.social_poll_waiting_for_sums, list(standing.from, name))
is PollBook.Standing.Counted -> ""
}
@Composable
private fun list(
ids: List<Long>,
name: (Long) -> String,
): String {
val names = ids.map(name)
return when (names.size) {
0 -> "nobody"
0 -> stringResource(R.string.social_poll_nobody)
1 -> names[0]
2 -> "${names[0]} and ${names[1]}"
else -> names.dropLast(1).joinToString(", ") + " and " + names.last()
2 -> stringResource(R.string.social_poll_name_pair, names[0], names[1])
else -> stringResource(R.string.social_poll_name_pair, names.dropLast(1).joinToString(", "), names.last())
}
}
@@ -174,14 +179,12 @@ private fun ColumnScope.Results(
// The guarantee, stated as it actually is.
Text(
if (poll.members.size > 2) {
"Nobody was sent anybody's vote. Everyone else here would have to " +
"work together to know how you voted."
stringResource(R.string.social_poll_guarantee_group)
} else {
"Nobody was sent your vote, but in a poll of two the other person " +
"can work it out from the result."
stringResource(R.string.social_poll_guarantee_pair)
},
fontSize = 12.sp,
color = Palette.SecondaryText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -201,13 +204,13 @@ fun NewPollDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("New poll") },
title = { Text(stringResource(R.string.social_poll_new)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) {
OutlinedTextField(
value = question,
onValueChange = { question = it },
label = { Text("Ask something") },
label = { Text(stringResource(R.string.social_poll_ask_something)) },
singleLine = false,
modifier = Modifier.fillMaxWidth().testTag("poll-question"),
)
@@ -217,34 +220,29 @@ fun NewPollDialog(
onValueChange = { text ->
options = options.toMutableList().also { it[i] = text }
},
label = { Text("Option ${i + 1}") },
label = { Text(stringResource(R.string.social_poll_option, i + 1)) },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("poll-new-option-$i"),
)
}
if (options.size < Poll.MAX_OPTIONS) {
TextButton(onClick = { options = options + "" }) {
Text("Add an option")
Text(stringResource(R.string.social_poll_add_option))
}
}
// Said before the poll is asked, not after, because it is what
// somebody is deciding on.
Text(
when {
members < 2 -> "A poll needs somebody else in the group."
members < 2 -> stringResource(R.string.social_poll_needs_members)
members == 2 ->
"In a group of two this is not anonymous: the other " +
"person can work out your vote from the result. " +
"It is still a poll."
stringResource(R.string.social_poll_pair_note)
else ->
"Nobody is sent anybody's vote, not even you as the " +
"person asking. Everyone other than the voter would " +
"have to work together to know how one person voted. " +
"Everyone has to vote before there is a result."
stringResource(R.string.social_poll_group_note)
},
fontSize = 12.sp,
color = Palette.SecondaryText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
@@ -256,9 +254,9 @@ fun NewPollDialog(
onDismiss()
},
modifier = Modifier.testTag("poll-ask"),
) { Text("Ask") }
) { Text(stringResource(R.string.social_poll_ask)) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } },
)
Spacer(Modifier.width(0.dp))
}
@@ -182,7 +182,7 @@ fun ChannelHeader(
.padding(start = 4.dp, end = 4.dp, top = 8.dp, bottom = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PortalHeaderIcon(KitIcon.Back, "Back", onBack, Modifier.testTag("back"))
PortalHeaderIcon(KitIcon.Back, androidx.compose.ui.res.stringResource(io.flamenet.messenger.R.string.common_back), onBack, Modifier.testTag("back"))
if (leading != null) {
leading()
}
@@ -43,7 +43,7 @@ class SafReferences(
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(context.getString(io.flamenet.messenger.R.string.main_files_file_gone))
/**
* Zero when the provider will not say, which is a real answer and not a
@@ -0,0 +1,119 @@
package io.flamenet.messenger.app
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import io.flamenet.e2e.Vault
import io.flamenet.messenger.R
/**
* Two things the iPhone app started saying on 2026-09-23 (audit F2 and F5),
* said here too. Both were facts the app already had and kept to itself.
*/
/**
* The address this device had before this sign-in derived a different one, or
* null, which is the ordinary case (finding F5).
*
* The account key comes from the passphrase and a salt the account service
* holds and hands back at every sign-in. It is never written to this phone. So
* that service can serve a different salt, the phone derives a different fnid,
* and the same service verifies it: the sign-in succeeds and the person now
* holds a different identity. The new fnid used to replace the old one in the
* saved credential without anything comparing the two, which made the
* substitution silent, and silence is what makes it worth doing.
*
* Only for the SAME account on the SAME relay. A different account on a shared
* phone is a different person, not a changed address.
*/
object AddressChange {
fun previousAddress(
previous: Vault.Credential?,
sameAccount: Boolean,
fnid: String?,
host: String,
): String? {
if (previous == null || !sameAccount) return null
val was = previous.fnid ?: return null
// No new address is not a changed one: it means this sign-in could not
// derive one, and AddressCard already says so.
val now = fnid ?: return null
if (was == now) return null
return "$was@$host"
}
}
/**
* Conversations where a send fell back from sealed to identified, and when
* (SPEC §14.5, finding F2).
*
* §14.5: "a user who has enabled sealed sending SHOULD be told when a message
* could not be sent sealed." The sealing record in Privacy settings counts
* the fallback, which answers "how often" and never "did this one".
*
* In memory only, as on iOS. The fallback is a fact about a moment, the notice
* exists to be seen at that moment, and the running total already carries it
* across restarts. A later sealed send does not clear it: the message that went
* identified still did.
*/
object UnsealedSends {
fun record(
seen: Map<Long, Long>,
peerUserId: Long,
wasSealed: Boolean,
at: Long,
): Map<Long, Long> = if (wasSealed) seen else seen + (peerUserId to at)
}
/**
* Said in the conversation where it happened. A note, not an alarm: nothing
* was readable by anyone, and the usual cause is the other person reinstalling.
* Same words as the iPhone app.
*/
@Composable
fun UnsealedSendNotice(name: String) {
Surface(
color = Palette.SurfaceAlt,
shape = RoundedCornerShape(Radius.medium),
modifier = Modifier.fillMaxWidth().testTag("unsealed-send"),
) {
Column(modifier = Modifier.padding(Space.md)) {
Text(
stringResource(R.string.social_unsealed_title),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
Text(
stringResource(R.string.social_unsealed_body, name),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
}
}
}
/** Beside the address it replaced. Same words as the iPhone app. */
@Composable
fun AddressChangedNotice(was: String) {
Column(modifier = Modifier.fillMaxWidth().padding(bottom = Space.md).testTag("address-changed")) {
Text(
stringResource(R.string.social_address_changed_title),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
Text(
stringResource(R.string.social_address_changed_body, was),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
}
}
@@ -14,52 +14,58 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import io.flamenet.messenger.R
/**
* 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. */
/** What a valid code carries: an address, and nothing else is taken from it. */
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.
* A hello link (§6.34), which is what an Address screen's code holds now; the
* bare `fnid@relay` somebody retypes; or `flamenet:<fnid>@<relay>[?u=<handle>&k=…]`,
* the card older builds draw, whose query is ignored. Null for anything else,
* a code that only looks like an address included.
*/
internal fun parseScannedAddress(text: String): ScannedAddress? {
HelloLink.address(text)?.let { address ->
val at = address.indexOf('@')
return ScannedAddress(address.substring(0, at), address.substring(at + 1))
}
var s = text.trim()
if (s.lowercase().startsWith("flamenet:")) s = s.substring("flamenet:".length)
var handle: String? = null
val q = s.indexOf('?')
if (q >= 0) {
s.substring(q + 1).split('&').forEach { pair ->
if (pair.startsWith("u=")) handle = pair.substring(2)
}
s = s.substring(0, q)
}
val at = s.indexOf('@')
if (at <= 0 || at == s.length - 1) return null
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.
if (relay.contains('/') || relay.contains(':')) return null
return ScannedAddress(fnid, relay, handle?.takeIf { it.isNotBlank() })
s = s.substringBefore('?')
// The same rule as a hello link, so a code holding `mailto:someone@host`
// or a web address with an @ in it is not taken for a person.
val address = HelloLink.canonical(s.trim()) ?: return null
val at = address.indexOf('@')
return ScannedAddress(address.substring(0, at), address.substring(at + 1))
}
/**
* What an add screen is filled with for a scanned code, on Home and on
* Messages alike: the address, bound by the fnid and never by a handle the
* code also names (audit LOW, DESIGN 32). Null when the code holds no address.
*/
internal fun prefillFor(scanned: String): String? = parseScannedAddress(scanned)?.let { "${it.fnid}@${it.relay}" }
/** Full-screen camera dialog. Calls [onFound] once with the raw decoded text. */
@Composable
fun ScanAddressDialog(
onFound: (String) -> Unit,
onDismiss: () -> Unit,
hint: String = stringResource(R.string.social_scan_point_at_code),
denied: String = stringResource(R.string.social_scan_camera_denied),
) {
val context = LocalContext.current
val granted =
@@ -77,8 +83,7 @@ fun ScanAddressDialog(
// 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.",
denied,
color = Color.White,
fontSize = 14.sp,
modifier = Modifier.testTag("scan-denied"),
@@ -95,7 +100,7 @@ fun ScanAddressDialog(
onRelease = { view -> (view.tag as? QrScan)?.stop() },
)
Text(
"Point at the code on their Address screen.",
hint,
color = Color.White,
fontSize = 13.sp,
modifier =
@@ -111,7 +116,7 @@ fun ScanAddressDialog(
.align(Alignment.TopEnd)
.padding(top = 24.dp, end = 8.dp)
.testTag("scan-cancel"),
) { Text("Cancel", color = Color.White) }
) { Text(stringResource(R.string.common_cancel), color = Color.White) }
}
}
}
File diff suppressed because it is too large Load Diff
@@ -84,4 +84,20 @@ data class SiteConfig(
"This server keeps its own accounts."
}
}
/**
* The same four sentences as [identityDescription], as string resources,
* so the sign-in screen shows them in the phone's language. The English
* property stays because SiteConfigIdentityTest pins it without a Context;
* the branches here must stay in step with it.
*/
@get:androidx.annotation.StringRes
val identityDescriptionRes: Int
get() =
when {
probeFailed -> io.flamenet.messenger.R.string.main_identity_unreachable
discovered == null -> io.flamenet.messenger.R.string.main_identity_checking
accountProvider == AccountProvider.SHARED -> io.flamenet.messenger.R.string.main_identity_shared
else -> io.flamenet.messenger.R.string.main_identity_local
}
}
@@ -1,89 +1,133 @@
package io.flamenet.messenger.app
import android.content.Context
import androidx.compose.ui.graphics.ImageBitmap
import org.json.JSONObject
/**
* The shipped sticker set, read from the same manifest the iOS app reads.
* The shipped sticker packs, read from the same manifests the iOS app reads.
*
* Every manifest under `assets/stickers/` is a pack: the glyph set first, then
* the FlameNet art, then anything else by id. A sticker draws as its image when
* the pack ships one, as its glyph when it does not or the image will not load,
* and as the pack's fallback when this build does not know the id. A pack this
* build does not have at all draws as the core fallback (docs/STICKERS.md).
*/
object StickerPack {
data class Sticker(
val pack: String,
val id: String,
val glyph: String,
val name: String,
/** A file beside the manifest, `stickers/<pack>/<image>`, or null. */
val image: String? = null,
)
data class Pack(
val id: String,
val name: String,
val fallback: String,
val stickers: List<Sticker>,
)
/** What one reference draws as. */
data class Resolved(
/** The asset path of the picture, or null to draw [glyph]. */
val image: String?,
val glyph: String,
val name: String,
)
const val CORE = "flamenet.core"
const val RETRO = "flamenet.retro"
private var loaded: Map<String, Sticker>? = null
private var order: List<Sticker> = emptyList()
private var fallbackGlyph = "🙂"
/** Manifest order in the picker: the glyphs, then the art. */
private val PICKER_ORDER = listOf(CORE, RETRO)
/** Every sticker in the shipped pack, in manifest order. */
fun all(context: Context): List<Sticker> {
ensure(context)
return order
}
private const val DEFAULT_FALLBACK = "\uD83D\uDE42"
/**
* What to draw for a reference.
*/
fun glyph(
context: Context,
pack: String,
id: String,
): String {
ensure(context)
if (pack != CORE) return fallbackGlyph
return loaded?.get(id)?.glyph ?: fallbackGlyph
}
@Volatile private var loaded: List<Pack>? = null
fun name(
/** Every pack, in picker order. Empty when no manifest would load. */
fun packs(context: Context): List<Pack> =
loaded ?: synchronized(this) {
loaded ?: load(context).also { loaded = it }
}
/** What to draw for a reference. */
fun resolve(
context: Context,
pack: String,
id: String,
): String {
ensure(context)
if (pack != CORE) return "Sticker"
return loaded?.get(id)?.name ?: "Sticker"
}
): Resolved = resolve(packs(context), pack, id)
private fun ensure(context: Context) {
if (loaded != null) 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
}
/** The image for a resolved sticker, or null when there is none or it will not decode. */
fun image(
context: Context,
path: String,
): ImageBitmap? = AssetImages.load(context, path)
val json =
runCatching { JSONObject(text) }.getOrNull()
?: run {
loaded = emptyMap()
return
private fun load(context: Context): List<Pack> {
// Failure is an empty list, never a crash: the picker says so.
val names = runCatching { context.assets.list("stickers")?.toList() }.getOrNull().orEmpty()
val packs =
names
.filter { it.endsWith(".json") }
.mapNotNull { file ->
runCatching {
context.assets.open("stickers/$file").bufferedReader().use { it.readText() }
}.getOrNull()?.let(::parse)
}
return ordered(packs)
}
// -- Pure, so the rules can be checked without a device --------------------.
fallbackGlyph = json.optString("fallback").ifBlank { "🙂" }
/** One manifest, or null when it is not one. Stickers without an id are skipped. */
fun parse(text: String): Pack? {
val json = runCatching { JSONObject(text) }.getOrNull() ?: return null
val id = json.optString("pack").takeIf { it.isNotBlank() } ?: return null
val fallback = json.optString("fallback").ifBlank { DEFAULT_FALLBACK }
val array = json.optJSONArray("stickers")
val list = mutableListOf<Sticker>()
for (i in 0 until (array?.length() ?: 0)) {
val o = array!!.optJSONObject(i) ?: continue
val id = o.optString("id")
if (id.isBlank()) continue
list +=
val stickers =
(0 until (array?.length() ?: 0)).mapNotNull { i ->
val o = array!!.optJSONObject(i) ?: return@mapNotNull null
val sid = o.optString("id").takeIf { it.isNotBlank() } ?: return@mapNotNull null
Sticker(
id = id,
glyph = o.optString("glyph").ifBlank { fallbackGlyph },
name = o.optString("name").ifBlank { id },
pack = id,
id = sid,
glyph = o.optString("glyph").ifBlank { fallback },
name = o.optString("name").ifBlank { sid },
// Only a plain file name: a manifest must not point the
// loader anywhere outside its own pack's folder.
image =
o
.optString("image")
.takeIf { it.isNotBlank() && '/' !in it && '\\' !in it && it != ".." }
?.let { "stickers/$id/$it" },
)
}
return Pack(id, json.optString("name").ifBlank { id }, fallback, stickers)
}
/** Core first, then the art, then any other pack by id; one copy of each. */
fun ordered(packs: List<Pack>): List<Pack> {
val unique = packs.distinctBy { it.id }
val known = PICKER_ORDER.mapNotNull { id -> unique.firstOrNull { it.id == id } }
return known + unique.filter { it.id !in PICKER_ORDER }.sortedBy { it.id }
}
fun resolve(
packs: List<Pack>,
pack: String,
id: String,
): Resolved {
val p = packs.firstOrNull { it.id == pack }
val s = p?.stickers?.firstOrNull { it.id == id }
return when {
s != null -> Resolved(s.image, s.glyph, s.name)
p != null -> Resolved(null, p.fallback, "Sticker")
else -> Resolved(null, packs.firstOrNull { it.id == CORE }?.fallback ?: DEFAULT_FALLBACK, "Sticker")
}
order = list
loaded = list.associateBy { it.id }
}
}
@@ -34,12 +34,15 @@ 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.res.pluralStringResource
import androidx.compose.ui.res.stringResource
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
import io.flamenet.messenger.R
/**
* Stories as a feed you scroll, which is the kit's Timeline.
@@ -76,17 +79,17 @@ fun ColumnScope.StoriesFeed(
// A menu, not a picker.
var choosing by remember { mutableStateOf(false) }
PortalHeader(title = "Stories") {
PortalHeader(title = stringResource(R.string.social_stories_title)) {
Box {
PortalHeaderIcon(
icon = KitIcon.Add,
contentDescription = "New story",
contentDescription = stringResource(R.string.social_new_story),
modifier = Modifier.testTag("new-story"),
onClick = { choosing = true },
)
DropdownMenu(expanded = choosing, onDismissRequest = { choosing = false }) {
DropdownMenuItem(
text = { Text("Photo or video") },
text = { Text(stringResource(R.string.social_story_photo_or_video)) },
onClick = {
choosing = false
pickStory.launch("*/*")
@@ -94,7 +97,7 @@ fun ColumnScope.StoriesFeed(
modifier = Modifier.testTag("new-story-photo"),
)
DropdownMenuItem(
text = { Text("Several photos") },
text = { Text(stringResource(R.string.social_story_several_photos)) },
onClick = {
choosing = false
pickStorySet.launch(
@@ -107,7 +110,7 @@ fun ColumnScope.StoriesFeed(
modifier = Modifier.testTag("new-story-set"),
)
DropdownMenuItem(
text = { Text("Just words") },
text = { Text(stringResource(R.string.social_story_just_words)) },
onClick = {
choosing = false
writingWords = true
@@ -164,28 +167,27 @@ private fun StorySetComposerDialog(
androidx.compose.material3.AlertDialog(
onDismissRequest = { model.discardStagedStorySet() },
title = { Text("New story") },
title = { Text(stringResource(R.string.social_new_story)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) {
Text(
"${items.size} pictures, posted as one story. The caption goes " +
"on the first.",
pluralStringResource(R.plurals.social_story_set_summary, items.size, items.size),
fontSize = 12.sp,
color = Palette.SecondaryText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag("story-set-summary"),
)
androidx.compose.material3.OutlinedTextField(
value = caption.value,
onValueChange = { caption.value = it },
label = { Text("Say something (optional)") },
label = { Text(stringResource(R.string.social_story_say_something_optional)) },
singleLine = false,
modifier = Modifier.fillMaxWidth().testTag("story-set-caption"),
)
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs)) {
listOf(
Session.AudienceMode.EVERYONE to "Everyone",
Session.AudienceMode.ONLY to "Only…",
Session.AudienceMode.EXCEPT to "All except…",
Session.AudienceMode.EVERYONE to stringResource(R.string.social_audience_everyone),
Session.AudienceMode.ONLY to stringResource(R.string.social_audience_only),
Session.AudienceMode.EXCEPT to stringResource(R.string.social_audience_all_except),
).forEach { (m, label) ->
androidx.compose.material3.FilterChip(
selected = mode.value == m,
@@ -228,12 +230,12 @@ private fun StorySetComposerDialog(
)
},
modifier = Modifier.testTag("story-set-post"),
) { Text("Post") }
) { Text(stringResource(R.string.social_post)) }
},
dismissButton = {
androidx.compose.material3.TextButton(
onClick = { model.discardStagedStorySet() },
) { Text("Cancel") }
) { Text(stringResource(R.string.common_cancel)) }
},
)
}
@@ -253,21 +255,24 @@ private fun StoryCard(
*/
setCount: Int = 1,
) {
val youLabel = stringResource(R.string.common_you)
val unknownAuthor = stringResource(R.string.social_user_fallback, story.authorId)
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}"
?: if (story.mine) youLabel else unknownAuthor
}
val context = androidx.compose.ui.platform.LocalContext.current
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"
story.expires <= 0L -> context.getString(R.string.social_story_no_expiry)
seconds <= 0 -> context.getString(R.string.social_story_expired)
seconds < 3600 -> context.getString(R.string.social_story_minutes_left, (seconds / 60).toInt())
else -> context.getString(R.string.social_story_hours_left, (seconds / 3600).toInt())
}
}
@@ -298,10 +303,23 @@ private fun StoryCard(
// The deadline, in the slot the kit gives a description, and
// §6.23's count beside it: a card that silently stands for
// five pictures is a card that loses four.
// §6.15: a story whose words changed says so.
val edited = if (story.editedAt != null) " · Edited" else ""
Text(
if (setCount > 1) "$remaining · $setCount pictures" else remaining,
(
if (setCount > 1) {
pluralStringResource(
R.plurals.social_story_remaining_with_count,
setCount,
remaining,
setCount,
)
} else {
remaining
}
) + edited,
fontSize = 12.sp,
color = Palette.SecondaryText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag("story-set-count-${story.id}"),
)
}
@@ -321,39 +339,32 @@ private fun StoryCard(
Spacer(Modifier.height(Space.lg))
// 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),
contentAlignment = Alignment.Center,
) {
// 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.
val stream = story.streamUrl
val streamHost = story.streamHost
if (stream != null && streamHost != null &&
// DESIGN item 27: the whole picture, never cut. The card takes the
// picture's own shape between a floor and a ceiling (`StoryFrame`),
// with plain bars past either. §6.13, an embedded live stream, sits in
// the frame of its poster.
val stream = story.streamUrl
val streamHost = story.streamHost
val ref = story.ref
when {
stream != null && streamHost != null &&
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.
ref == null -> TextStoryCard(story.caption)
.acceptable(stream) -> {
StoryPhotoFrame(null, ref?.width ?: 0, ref?.height ?: 0) { StoryStreamGate(stream, streamHost) }
}
ref.mime.startsWith("video/") -> StoryVideoCard(ref, model)
// Words alone, set large on a tinted band, the way every other
// messenger draws one.
ref == null -> {
TextStoryCard(story.caption)
}
else -> StoryImage(ref, model)
}
ref.mime.startsWith("video/") -> {
StoryPhotoFrame(null, ref.width, ref.height) { StoryVideoCard(ref, model) }
}
else -> {
StoryImage(ref, model)
}
}
@@ -393,10 +404,12 @@ private fun StoryActions(
modifier = Modifier.testTag("story-tally"),
) {
tally.entries.sortedBy { it.key }.forEach { (emoji, count) ->
Text("$emoji $count", fontSize = 13.sp, color = Palette.SecondaryText)
Text("$emoji $count", fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Spacer(Modifier.height(Space.sm))
}
MyStoryControls(story, model)
return
}
@@ -431,7 +444,7 @@ private fun StoryActions(
}
Spacer(Modifier.size(12.dp))
Text(
"Reply",
stringResource(R.string.social_story_reply),
fontSize = 13.sp,
maxLines = 1,
color = Palette.Accent,
@@ -439,10 +452,10 @@ private fun StoryActions(
)
Spacer(Modifier.size(12.dp))
Text(
"Mute",
stringResource(R.string.common_mute),
fontSize = 13.sp,
maxLines = 1,
color = Palette.SecondaryText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable { muting = true }.testTag("story-mute"),
)
// Reporting a story, which Android could not do. iOS has had it since
@@ -451,10 +464,10 @@ private fun StoryActions(
if (!story.mine) {
Spacer(Modifier.size(12.dp))
Text(
"Report",
stringResource(R.string.social_story_report),
fontSize = 13.sp,
maxLines = 1,
color = Palette.SecondaryText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier
.clickable { reporting = true }
@@ -464,9 +477,10 @@ private fun StoryActions(
}
if (reporting) {
val thisPerson = stringResource(R.string.social_this_person)
// The SAME sheet a message report uses, deliberately.
ReportSheet(
authorName = authorName.ifBlank { "this person" },
authorName = authorName.ifBlank { thisPerson },
preview = story.caption,
onDismiss = { reporting = false },
onSend = { reason, includeContent, note ->
@@ -484,20 +498,19 @@ private fun StoryActions(
var draft by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { replying = false },
title = { Text("Reply privately") },
title = { Text(stringResource(R.string.social_reply_privately)) },
// Says where it goes.
text = {
Column {
Text(
"Goes to them in your conversation. Nobody else who " +
"saw this story can read it.",
stringResource(R.string.social_reply_privately_note),
fontSize = 13.sp,
)
Spacer(Modifier.height(Space.sm))
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
placeholder = { Text("Message") },
placeholder = { Text(stringResource(R.string.social_story_reply_placeholder)) },
modifier = Modifier.fillMaxWidth().testTag("story-reply-field"),
)
}
@@ -510,22 +523,20 @@ private fun StoryActions(
},
enabled = draft.isNotBlank(),
modifier = Modifier.testTag("story-reply-send"),
) { Text("Send") }
) { Text(stringResource(R.string.common_send)) }
},
dismissButton = { TextButton(onClick = { replying = false }) { Text("Cancel") } },
dismissButton = { TextButton(onClick = { replying = false }) { Text(stringResource(R.string.common_cancel)) } },
)
}
if (muting) {
AlertDialog(
onDismissRequest = { muting = false },
title = { Text("Mute their stories?") },
title = { Text(stringResource(R.string.social_mute_stories_title)) },
// 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.",
stringResource(R.string.social_mute_stories_body),
fontSize = 13.sp,
)
},
@@ -536,13 +547,168 @@ private fun StoryActions(
model.setStoriesMuted(story.authorId, true)
},
modifier = Modifier.testTag("story-mute-confirm"),
) { Text("Mute") }
) { Text(stringResource(R.string.common_mute)) }
},
dismissButton = { TextButton(onClick = { muting = false }) { Text("Cancel") } },
dismissButton = { TextButton(onClick = { muting = false }) { Text(stringResource(R.string.common_cancel)) } },
)
}
}
/** What the stories screens say about taking one down or changing its words. */
object StoryWords {
/** DESIGN item 25, word for word, before a story comes down. */
const val TAKE_DOWN_WARNING =
"This removes it from your friends' phones when their app gets the message. " +
"Anyone who already saw it may have kept a copy, and older versions of the app " +
"keep it until it expires."
private const val TODAYS_AUDIENCE =
"It went to your story audience as it is now, since this story was posted before " +
"the app kept who it went to."
/** After a take-down: done, anyone the message has not reached, and which audience was used. */
fun takenDown(change: Session.StoryChange): String {
val total = change.reached + change.missed
val reach =
if (change.missed == 0) {
"Taken down."
} else {
"Taken down here. ${change.missed} of $total could not be reached right now, so " +
"their copy stays until it expires."
}
return if (change.asPosted) reach else "$reach $TODAYS_AUDIENCE"
}
/** Whether [draft] may be sent as a story's new words: never empty, and not what it says already. */
fun canSave(
draft: String,
current: String,
): Boolean = draft.isNotBlank() && draft.trim() != current
/** After an edit: nothing to say unless somebody still sees the old words. */
fun edited(change: Session.StoryChange): String? {
val total = change.reached + change.missed
val reach =
if (change.missed == 0) {
null
} else {
"Changed here. ${change.missed} of $total could not be reached right now, so they " +
"still see the old words."
}
return if (change.asPosted) reach else listOfNotNull(reach, TODAYS_AUDIENCE).joinToString(" ")
}
}
/**
* My own story: change its words, or take it down for everyone it went to
* (§6.15 for stories, §6.35), in one row.
*/
@Composable
private fun MyStoryControls(
story: Vault.Story,
model: MessengerViewModel,
) {
var editing by remember(story.id) { mutableStateOf(false) }
var takingDown by remember(story.id) { mutableStateOf(false) }
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs), modifier = Modifier.fillMaxWidth()) {
KitSecondaryButton(
text = if (story.textOnly) "Edit words" else "Edit caption",
onClick = { editing = true },
modifier = Modifier.weight(1f),
tag = "story-edit",
)
KitSecondaryButton(
text = "Take down",
onClick = { takingDown = true },
modifier = Modifier.weight(1f),
tag = "story-takedown",
danger = true,
)
}
if (takingDown) {
AlertDialog(
onDismissRequest = { takingDown = false },
title = { Text(if (story.set != null) "Take down this story and all its pictures?" else "Take down this story?") },
text = { Text(StoryWords.TAKE_DOWN_WARNING, fontSize = 14.sp, modifier = Modifier.testTag("story-takedown-warning")) },
confirmButton = {
TextButton(
onClick = {
takingDown = false
model.takeDownStory(story)
},
modifier = Modifier.testTag("story-takedown-confirm"),
) { Text("Take down", color = MaterialTheme.colorScheme.error) }
},
dismissButton = { TextButton(onClick = { takingDown = false }) { Text("Cancel") } },
)
}
if (editing) {
var draft by remember(story.id) { mutableStateOf(story.caption) }
AlertDialog(
onDismissRequest = { editing = false },
title = { Text(if (story.textOnly) "Edit your story" else "Edit the caption") },
text = {
StoryCaptionEditor(
draft = draft,
onDraft = { draft = it },
onTakeDownInstead = {
editing = false
takingDown = true
},
)
},
confirmButton = {
TextButton(
onClick = {
editing = false
model.editStoryCaption(story, draft.trim())
},
enabled = StoryWords.canSave(draft, story.caption),
modifier = Modifier.testTag("story-edit-save"),
) { Text("Save") }
},
dismissButton = { TextButton(onClick = { editing = false }) { Text("Cancel") } },
)
}
}
/**
* The words being edited, and what an edit cannot do: leave a story empty
* (§6.15). Taking it down is offered instead.
*/
@Composable
internal fun StoryCaptionEditor(
draft: String,
onDraft: (String) -> Unit,
onTakeDownInstead: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) {
OutlinedTextField(
value = draft,
onValueChange = onDraft,
modifier = Modifier.fillMaxWidth().testTag("story-edit-field"),
)
Text(
"Everyone who has it sees that it was edited.",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (draft.isBlank()) {
Text(
"An edit cannot leave it empty. To remove it, take it down.",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag("story-edit-empty"),
)
TextButton(onClick = onTakeDownInstead, modifier = Modifier.testTag("story-edit-takedown")) {
Text("Take down instead")
}
}
}
}
/**
* 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
@@ -588,39 +754,21 @@ fun StoryComposerDialog(
}
androidx.compose.material3.AlertDialog(
onDismissRequest = { model.discardStagedStory() },
title = { Text("New story") },
title = { Text(stringResource(R.string.social_new_story)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) {
image?.let {
androidx.compose.foundation.layout.Box(contentAlignment = Alignment.Center) {
androidx.compose.foundation.Image(
bitmap = it,
contentDescription = "Story preview",
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(Radius.small)),
)
if (draft.duration > 0) {
Text(
"▶ ${draft.duration.toInt()}s",
fontSize = 22.sp,
color = androidx.compose.ui.graphics.Color.White,
)
}
}
}
image?.let { StoryComposerPreview(it, draft.width, draft.height, draft.duration) }
androidx.compose.material3.OutlinedTextField(
value = caption.value,
onValueChange = { caption.value = it },
placeholder = { Text("Add a caption…") },
placeholder = { Text(stringResource(R.string.social_story_add_caption)) },
modifier = Modifier.fillMaxWidth().testTag("story-caption"),
)
// Who THIS story goes to.
androidx.compose.material3.OutlinedTextField(
value = streamUrl.value,
onValueChange = { streamUrl.value = it },
label = { Text("Live stream link (optional)", fontSize = 12.sp) },
label = { Text(stringResource(R.string.social_story_stream_link_label), fontSize = 12.sp) },
singleLine = true,
isError = !streamOk,
modifier = Modifier.fillMaxWidth().testTag("story-stream-field"),
@@ -630,17 +778,14 @@ fun StoryComposerDialog(
// 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 " +
"told before it happens."
stringResource(R.string.social_story_stream_ok_note)
} else {
"That needs to be a full http:// or https:// link with a site " +
"name in it."
stringResource(R.string.social_story_stream_bad_link)
},
fontSize = 12.sp,
color =
if (streamOk) {
Palette.SecondaryText
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.error
},
@@ -657,7 +802,7 @@ fun StoryComposerDialog(
androidx.compose.material3.FilterChip(
selected = toGroup.value == null,
onClick = { toGroup.value = null },
label = { Text("My contacts", fontSize = 12.sp) },
label = { Text(stringResource(R.string.social_story_dest_my_contacts), fontSize = 12.sp) },
modifier = Modifier.testTag("story-dest-contacts"),
)
phase.groups.forEach { group ->
@@ -672,18 +817,16 @@ fun StoryComposerDialog(
}
if (toGroup.value != null) {
Text(
"Everyone in that group receives it and it disappears after " +
"a day. Your story audience does not apply, and people " +
"outside the group are not sent it.",
stringResource(R.string.social_story_group_note),
fontSize = 12.sp,
color = Palette.SecondaryText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs)) {
listOf(
Session.AudienceMode.EVERYONE to "Everyone",
Session.AudienceMode.ONLY to "Only…",
Session.AudienceMode.EXCEPT to "All except…",
Session.AudienceMode.EVERYONE to stringResource(R.string.social_audience_everyone),
Session.AudienceMode.ONLY to stringResource(R.string.social_audience_only),
Session.AudienceMode.EXCEPT to stringResource(R.string.social_audience_all_except),
).forEach { (m, label) ->
androidx.compose.material3.FilterChip(
selected = mode.value == m,
@@ -719,19 +862,19 @@ fun StoryComposerDialog(
Text(
when (mode.value) {
Session.AudienceMode.EVERYONE -> {
"Goes to all your contacts and disappears after a day."
stringResource(R.string.social_story_audience_everyone_note)
}
Session.AudienceMode.ONLY -> {
"Goes only to the ${chosen.value.size} chosen, and disappears after a day."
pluralStringResource(R.plurals.social_story_audience_only_note, chosen.value.size, chosen.value.size)
}
Session.AudienceMode.EXCEPT -> {
"Goes to everyone but the ${chosen.value.size} chosen, and disappears after a day."
pluralStringResource(R.plurals.social_story_audience_except_note, chosen.value.size, chosen.value.size)
}
},
fontSize = 12.sp,
color = Palette.SecondaryText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -749,16 +892,39 @@ fun StoryComposerDialog(
// Refuses to post a stream URL it could not disclose.
enabled = streamOk,
modifier = Modifier.testTag("story-post"),
) { Text("Post") }
) { Text(stringResource(R.string.social_post)) }
},
dismissButton = {
androidx.compose.material3.TextButton(
onClick = { model.discardStagedStory() },
) { Text("Cancel") }
) { Text(stringResource(R.string.common_cancel)) }
},
)
}
/**
* The picked picture as friends will see it in their feed (DESIGN item 27):
* the same shape and the same bars as the feed card, scaled to the composer.
*/
@Composable
internal fun StoryComposerPreview(
image: androidx.compose.ui.graphics.ImageBitmap,
width: Int,
height: Int,
durationSeconds: Double,
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.testTag("story-preview")) {
StoryPhotoFrame(image, width, height)
if (durationSeconds > 0) {
Text(
"▶ ${durationSeconds.toInt()}s",
fontSize = 22.sp,
color = androidx.compose.ui.graphics.Color.White,
)
}
}
}
/**
* The story cards themselves, everyone's stories, or one honest message when
* there are none.
@@ -782,16 +948,14 @@ fun ColumnScope.StoriesCards(
Icon(
KitIcon.Stories,
contentDescription = null,
tint = Palette.SecondaryText,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(Space.xxl),
)
Text("Nothing here yet", fontSize = 16.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.social_stories_empty_title), 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.",
stringResource(R.string.social_stories_empty_body),
fontSize = 14.sp,
color = Palette.SecondaryText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
@@ -844,14 +1008,18 @@ private fun StoryVideoCard(
failed.value -> {
Text(
"Video unavailable",
stringResource(R.string.social_video_unavailable),
fontSize = 14.sp,
color = Palette.SecondaryText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> {
Text("Loading…", fontSize = 14.sp, color = Palette.SecondaryText)
Text(
stringResource(R.string.common_loading),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -877,27 +1045,22 @@ private fun StoryImage(
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)
}
// The decoded picture's own shape when there is one: the envelope's size
// is the sender's claim, the pixels are not.
StoryPhotoFrame(
bitmap = loaded?.asImageBitmap(),
photoWidth = loaded?.width ?: ref.width,
photoHeight = loaded?.height ?: ref.height,
) {
Text(
if (failed.value) {
stringResource(R.string.social_image_unavailable)
} else {
stringResource(R.string.common_loading)
},
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -938,12 +1101,12 @@ private fun TextStoryComposer(
var text by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("New story") },
title = { Text(stringResource(R.string.social_new_story)) },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
placeholder = { Text("Say something") },
placeholder = { Text(stringResource(R.string.social_story_say_something)) },
modifier = Modifier.fillMaxWidth().testTag("story-text-field"),
)
},
@@ -952,8 +1115,8 @@ private fun TextStoryComposer(
onClick = { onPost(text.trim()) },
enabled = text.isNotBlank(),
modifier = Modifier.testTag("story-text-post"),
) { Text("Post") }
) { Text(stringResource(R.string.social_post)) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } },
)
}

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