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>
This commit is contained in:
Paul Hitt
2026-10-14 09:51:19 -04:00
co-authored by Claude Opus 5
160 changed files with 24779 additions and 538 deletions
+1 -1
View File
@@ -1 +1 @@
4b850007a748be7d0716c0da3455a424cf58ddcc
4b4042f72880758ee2dea7713f7a2cb63fa12f73
+20 -21
View File
@@ -82,11 +82,9 @@ unit-tests:
- 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"
- export FLAMENET_PADDING_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/padding-v1.json"
- export FLAMENET_SYNC_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/sync-merge-v1.json"
- export FLAMENET_HANDLE_CERT_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/handle-cert-v1.json"
- export FLAMENET_LINK_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/links-v1.json"
- export FLAMENET_BRIDGE_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/bridge-offer-v1.json"
- export FLAMENET_CONTENT_KINDS_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/content-kinds-v1.json"
- export FLAMENET_TODAY_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/today-v1.json"
- export FLAMENET_HELLO_LINK_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/hello-link-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.
@@ -94,8 +92,8 @@ unit-tests:
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"; do
"$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 before running, or the gate counts ghosts. The
@@ -115,7 +113,7 @@ unit-tests:
# Written here rather than committed, because the directories these belong
# in are created by the build.
- |
for m in app engine client obfs4; do
for m in app engine client obfs4 games; do
mkdir -p "$m/build" && touch "$m/build/.metadata_never_index"
done
# `:obfs4:test` was missing from this list. The module shipped in !77
@@ -133,7 +131,7 @@ unit-tests:
# 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
- ./gradlew --no-daemon --rerun-tasks :engine:test :client:test :obfs4:test :games: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
@@ -144,13 +142,13 @@ unit-tests:
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
# (663 / 453 / 75 / 572, games 15) 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}
MINIMUMS = {"engine": 595, "client": 405, "obfs4": 68, "games": 13, "app": 515}
totals = {}
for module in MINIMUMS:
tests = failures = skipped = 0
@@ -213,14 +211,15 @@ vectors-head:
- 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"
- export FLAMENET_PADDING_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/padding-v1.json"
- export FLAMENET_SYNC_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/sync-merge-v1.json"
- export FLAMENET_HANDLE_CERT_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/handle-cert-v1.json"
- export FLAMENET_LINK_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/links-v1.json"
- export FLAMENET_BRIDGE_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/bridge-offer-v1.json"
# Only the modules that read 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. `:obfs4` reads
# the bridge-offer vectors (SPEC section 24).
- ./gradlew --no-daemon --rerun-tasks :engine:test :obfs4:test
- export FLAMENET_CONTENT_KINDS_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/content-kinds-v1.json"
- export FLAMENET_TODAY_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/today-v1.json"
- export FLAMENET_HELLO_LINK_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/hello-link-v1.json"
# Only the tests that read vectors: the engine, and the three 0.32 loaders
# in :client and :app. 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
:client:test --tests io.flamenet.client.SharedVectorsTodayTest
:app:testDebugUnitTest --tests io.flamenet.messenger.app.SharedVectorsContentKindsTest
--tests io.flamenet.messenger.app.SharedVectorsHelloLinkTest
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` |
+6
View File
@@ -51,6 +51,10 @@ android {
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,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=")
}
+10 -1
View File
@@ -104,17 +104,26 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!--
Contact links (spec 8.12) and nothing else: the QR form,
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" />
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

@@ -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
@@ -34,13 +35,13 @@ 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
@@ -59,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(),
@@ -74,7 +73,12 @@ fun AddressCard(
if (bitmap != null) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = stringResource(R.string.social_address_qr_desc),
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,
@@ -114,9 +118,66 @@ fun AddressCard(
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,
@@ -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)
}
@@ -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),
)
}
}
@@ -779,7 +779,7 @@ fun ColumnScope.AccountScreen(
SettingsSection {
// Beside the address it replaced (audit F5).
phase.addressChangedFrom?.let { AddressChangedNotice(it) }
AddressCard(fnid = phase.fnid, relay = phase.relay, handle = phase.handle)
AddressCard(fnid = phase.fnid, relay = phase.relay)
}
PortalBand()
@@ -1694,6 +1694,8 @@ fun ColumnScope.ChatScreen(
heldMessages: Map<Long, Int> = emptyMap(),
/** Conversations where a send could not go sealed (§14.5). */
unsealedSendAt: Map<Long, Long> = emptyMap(),
/** How this account signs a card unless the person changes it. */
myName: String = "",
) {
// Keyed on the thread, so switching conversations re-reads rather than
// carrying one person's half-written message into somebody else's composer.
@@ -1848,6 +1850,24 @@ fun ColumnScope.ChatScreen(
/** The message the next send answers (android#16). */
var replying by remember(chat.threadId) { mutableStateOf<Session.Line?>(null) }
// §6.31 and §6.32: what is being made, opened, changed or cancelled.
var composingCard by remember(chat.threadId) { mutableStateOf(false) }
var composingEvent by remember(chat.threadId) { mutableStateOf(false) }
var composingGame by remember(chat.threadId) { mutableStateOf(false) }
var openCard by remember(chat.threadId) { mutableStateOf<Content.Card?>(null) }
var sealedCardOpensOn by remember(chat.threadId) { mutableStateOf<String?>(null) }
var editingEvent by remember(chat.threadId) { mutableStateOf<io.flamenet.e2e.ChatEvents.State?>(null) }
var cancellingEvent by remember(chat.threadId) { mutableStateOf<io.flamenet.e2e.ChatEvents.State?>(null) }
val nameOf: (Long) -> String = { id ->
when {
id == myUserId -> "you"
chat.group == null && id == chat.peerId -> chat.title
else ->
buddies.firstOrNull { it.userId == id }?.let { it.displayName.ifBlank { it.handle } } ?: "user $id"
}
}
val screenContext = LocalContext.current
// §6.29. Above the messages rather than in them: a poll is not a bubble,
// it changes as pieces arrive, and it has to stay reachable while people
// are still voting.
@@ -1880,7 +1900,7 @@ fun ColumnScope.ChatScreen(
) {
Text(stringResource(R.string.chat_pinned), fontSize = 11.sp, color = Palette.SecondaryText)
Text(
pinned.text.take(80),
pinned.summary.take(80),
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -1908,12 +1928,81 @@ fun ColumnScope.ChatScreen(
groups = groups,
onDismiss = { forwarding = null },
onPick = { peerId, group ->
model.forwardMessage(line.text, peerId, group)
if (line.card != null || line.event != null) {
model.forwardCardOrEvent(line, peerId, group)
} else {
model.forwardMessage(line.text, peerId, group)
}
forwarding = null
},
)
}
openCard?.let { card -> CardInsideDialog(card) { openCard = null } }
sealedCardOpensOn?.let { day -> SealedCardDialog(day) { sealedCardOpensOn = null } }
if (composingCard) {
NewCardDialog(
defaultTo = if (chat.group == null) chat.title else "",
defaultFrom = myName,
onDismiss = { composingCard = false },
onSend = { card ->
model.sendCard(chat, card)
composingCard = false
},
)
}
if (composingGame) {
NewGameDialog(
onDismiss = { composingGame = false },
onStart = { type, iGoFirst ->
model.startGame(chat, type, iGoFirst)
composingGame = false
},
)
}
if (composingEvent) {
EventEditorDialog(
initial = null,
onDismiss = { composingEvent = false },
onSave = { event ->
model.sendEvent(chat, event)
composingEvent = false
},
)
}
editingEvent?.let { state ->
EventEditorDialog(
initial = state.event,
onDismiss = { editingEvent = null },
onSave = { event ->
model.reviseEvent(chat, state.event.id, event)
editingEvent = null
},
)
}
cancellingEvent?.let { state ->
androidx.compose.material3.AlertDialog(
onDismissRequest = { cancellingEvent = null },
title = { Text("Cancel ${state.event.shownTitle}?") },
text = {
Text(
"Everyone in this chat is told it is cancelled. Their answers stay on the card.",
fontSize = 14.sp,
)
},
confirmButton = {
TextButton(
onClick = {
model.cancelEvent(chat, state.event.id)
cancellingEvent = null
},
modifier = Modifier.testTag("event-cancel-confirm"),
) { Text("Cancel event", color = MaterialTheme.colorScheme.error) }
},
dismissButton = { TextButton(onClick = { cancellingEvent = null }) { Text("Keep it") } },
)
}
reporting?.let { line ->
// WHO wrote it, which in a group is not who the conversation is with.
//
@@ -1948,12 +2037,13 @@ fun ColumnScope.ChatScreen(
model.reportStoryProblem(stringResource(R.string.chat_report_nobody))
reporting = null
} else {
val words = reportableWords(line)
ReportSheet(
authorName = authorName,
preview = line.text,
preview = words,
onDismiss = { reporting = null },
onSend = { reason, includeContent, note ->
model.reportMessage(line.text, author, reason, includeContent, note)
model.reportMessage(words, author, reason, includeContent, note)
reporting = null
},
onBlock = {
@@ -2014,6 +2104,20 @@ fun ColumnScope.ChatScreen(
},
// Only for a message you did not send.
picture = authorId?.let { avatars[it] },
chatTitle = chat.title,
nameOf = nameOf,
onOpenCard = { openCard = it },
onSealedCard = { sealedCardOpensOn = it },
onAnswer = { eventId, answer -> model.answerEvent(chat, eventId, answer) },
onCalendar = { event ->
if (!EventCalendar.open(screenContext, event)) {
model.say("No calendar app on this phone would take the event.")
}
},
onGameMove = { gameId, move -> model.playGameMove(chat, gameId, move) },
onGameEnd = { gameId, ending -> model.endGame(chat, gameId, ending) },
onNewGameLike = { state -> model.startGameLike(chat, state) },
onLongPress = { menuOpen = true },
)
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
// The reactions are IN the menu, as its first row, rather
@@ -2077,15 +2181,37 @@ fun ColumnScope.ChatScreen(
}
}
}
if (line.text.isNotBlank()) {
val copyable = copyableWords(line)
if (copyable.isNotBlank()) {
DropdownMenuItem(
text = { Text(stringResource(R.string.common_copy)) },
onClick = {
clipboard.setText(AnnotatedString(line.text))
clipboard.setText(AnnotatedString(copyable))
menuOpen = false
},
)
}
// §6.32: only the organizer changes or cancels an event.
line.event?.takeIf { it.organizer == myUserId && !it.organizerProvisional }?.let { state ->
DropdownMenuItem(
text = { Text("Change event") },
onClick = {
editingEvent = state
menuOpen = false
},
modifier = Modifier.testTag("edit-event"),
)
if (!state.event.cancelled) {
DropdownMenuItem(
text = { Text("Cancel event") },
onClick = {
cancellingEvent = state
menuOpen = false
},
modifier = Modifier.testTag("cancel-event"),
)
}
}
// Every control below needs an id to point at, so all of
// them are absent, not disabled, on a message written
// before ids existed.
@@ -2113,7 +2239,7 @@ fun ColumnScope.ChatScreen(
// Needs an id to point at, like Edit and Pin, and a 1:1
// thread, because the group send path does not carry a
// reply yet.
if (line.id != null && line.text.isNotBlank() && chat.peerId != null) {
if (line.id != null && line.summary.isNotBlank() && chat.peerId != null) {
DropdownMenuItem(
text = { Text(stringResource(R.string.chat_menu_reply)) },
onClick = {
@@ -2123,7 +2249,7 @@ fun ColumnScope.ChatScreen(
modifier = Modifier.testTag("reply-message"),
)
}
if (line.text.isNotBlank()) {
if (canForward(line)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.chat_menu_forward)) },
onClick = {
@@ -2134,7 +2260,7 @@ fun ColumnScope.ChatScreen(
)
}
// Only on messages somebody else sent (android#20).
if (!line.mine && line.text.isNotBlank()) {
if (!line.mine && line.summary.isNotBlank()) {
DropdownMenuItem(
text = { Text(stringResource(R.string.chat_menu_report)) },
onClick = {
@@ -2278,7 +2404,7 @@ fun ColumnScope.ChatScreen(
color = Palette.SecondaryText,
)
Text(
line.text,
line.summary,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -2306,7 +2432,7 @@ fun ColumnScope.ChatScreen(
val answering =
replying?.let { line ->
line.id?.let { id ->
Content.Reference(id, line.author, line.text.take(140))
Content.Reference(id, line.author, line.summary.take(140))
}
}
model.sendMessage(draft, named, replyTo = answering)
@@ -2483,6 +2609,24 @@ fun ColumnScope.ChatScreen(
showAttach = false
pickingFile = true
},
onCard = {
showAttach = false
composingCard = true
},
onEvent = {
showAttach = false
composingEvent = true
},
// §6.33: games are one-to-one only.
onGame =
if (chat.peerId != null && chat.group == null) {
{
showAttach = false
composingGame = true
}
} else {
null
},
)
}
@@ -2500,7 +2644,7 @@ fun ColumnScope.ChatScreen(
if (showStickers) {
StickerPicker { sticker ->
// A sticker IS the message.
model.sendSticker(chat, sticker.id)
model.sendSticker(chat, sticker.pack, sticker.id)
showStickers = false
}
}
@@ -2609,6 +2753,19 @@ private fun Bubble(
myUserId: Long = 0L,
authorName: String = "",
picture: io.flamenet.e2e.Attachment.Ref? = null,
/** Who a sealed card of ours is for, when the card names nobody. */
chatTitle: String = "",
/** A member's name, for who answered an event. */
nameOf: (Long) -> String = { "user $it" },
onOpenCard: (Content.Card) -> Unit = {},
onSealedCard: (opensOn: String) -> Unit = {},
onAnswer: (eventId: String, answer: String) -> Unit = { _, _ -> },
onCalendar: (Content.Event) -> Unit = {},
onGameMove: (gameId: String, move: String) -> Unit = { _, _ -> },
onGameEnd: (gameId: String, ending: String) -> Unit = { _, _ -> },
onNewGameLike: (ChatGames.State) -> Unit = {},
/** A card, an event or a game takes taps itself, so it passes the long press on. */
onLongPress: () -> Unit = {},
) {
val displayContext = LocalContext.current
val display = remember { DisplaySettings(displayContext) }
@@ -2715,21 +2872,63 @@ private fun Bubble(
val stk = line.sticker
if (stk != null) {
val context = LocalContext.current
val stickerLabel =
stringResource(R.string.chat_sticker_description, StickerPack.name(context, stk.pack, stk.id))
Text(
StickerPack.glyph(context, stk.pack, stk.id),
fontSize = 64.sp,
val drawn = remember(stk) { StickerPack.resolve(context, stk.pack, stk.id) }
val stickerLabel = stringResource(R.string.chat_sticker_description, drawn.name)
StickerArt(
drawn,
size = 96.dp,
modifier =
Modifier
.padding(horizontal = Kit.bubblePadding)
.semantics {
contentDescription = stickerLabel
}.testTag("sticker-${stk.id}"),
.semantics { contentDescription = stickerLabel }
.testTag("sticker-${stk.id}"),
)
return@Column
}
// §6.31 and §6.32. A card and an event draw as themselves rather
// than inside a bubble, and keep their reactions underneath.
val card = line.card
val event = line.event
if (card != null) {
CardBubble(
line = line,
card = card,
forName = card.shownTo ?: chatTitle,
onOpen = { onOpenCard(card) },
onSealed = onSealedCard,
onLongPress = onLongPress,
)
Reactions(line, myUserId)
return@Column
}
val game = line.game
if (game != null) {
GameBubble(
state = game,
peerName = chatTitle,
onMove = { move -> onGameMove(game.id, move) },
onEnd = { ending -> onGameEnd(game.id, ending) },
onNewGame = { onNewGameLike(game) },
onLongPress = onLongPress,
)
Reactions(line, myUserId)
return@Column
}
if (event != null) {
EventBubble(
line = line,
state = event,
myUserId = myUserId,
nameOf = nameOf,
onAnswer = { answer -> onAnswer(event.event.id, answer) },
onAddToCalendar = { onCalendar(event.event) },
onLongPress = onLongPress,
)
Reactions(line, myUserId)
return@Column
}
// A sent bubble is the chrome gradient with a highlight along its
// top; a received one is plain surface inside a rule. Flat fills
// on both sides is what a generic messenger looks like, and it is
@@ -2798,58 +2997,66 @@ private fun Bubble(
}
line.link?.let { LinkPreviewCard(it, mine = line.mine) }
}
// Under the bubble, not inside it.
if (line.reactions.isNotEmpty() || line.privateReactions.isNotEmpty()) {
Row(
horizontalArrangement = Arrangement.spacedBy(Space.xs),
modifier =
Modifier
.padding(horizontal = Kit.bubblePadding, vertical = 2.dp)
.testTag("reactions"),
) {
line.reactions.entries.sortedBy { it.key }.forEach { (emoji, who) ->
Text(
if (who.size == 1) emoji else "$emoji ${who.size}",
fontSize = 13.sp,
color = Palette.SecondaryText,
modifier =
Modifier
.clip(RoundedCornerShape(Radius.small))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
// Labelled, because a reaction only two people can see
// should not be mistaken for one the group watched arrive.
line.privateReactions.entries.sortedBy { it.key }.forEach { (emoji, who) ->
Text(
run {
// The person who sent it is told who saw it;
// the author is told that nobody else did.
val side =
if (myUserId in who) {
stringResource(R.string.chat_reaction_only_them)
} else {
stringResource(R.string.chat_reaction_only_you)
}
if (who.size == 1) "$emoji $side" else "$emoji ${who.size} $side"
},
fontSize = 13.sp,
color = Palette.SecondaryText,
modifier =
Modifier
.clip(RoundedCornerShape(Radius.small))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 6.dp, vertical = 2.dp)
.testTag("private-reactions"),
)
}
}
}
Reactions(line, myUserId)
}
}
}
/** The reactions under a bubble, a card or an event. Under it, not inside it. */
@Composable
private fun Reactions(
line: Session.Line,
myUserId: Long,
) {
if (line.reactions.isEmpty() && line.privateReactions.isEmpty()) return
Row(
horizontalArrangement = Arrangement.spacedBy(Space.xs),
modifier =
Modifier
.padding(horizontal = Kit.bubblePadding, vertical = 2.dp)
.testTag("reactions"),
) {
line.reactions.entries.sortedBy { it.key }.forEach { (emoji, who) ->
Text(
if (who.size == 1) emoji else "$emoji ${who.size}",
fontSize = 13.sp,
color = Palette.SecondaryText,
modifier =
Modifier
.clip(RoundedCornerShape(Radius.small))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
// Labelled, because a reaction only two people can see
// should not be mistaken for one the group watched arrive.
line.privateReactions.entries.sortedBy { it.key }.forEach { (emoji, who) ->
Text(
run {
// The person who sent it is told who saw it;
// the author is told that nobody else did.
val side =
if (myUserId in who) {
stringResource(R.string.chat_reaction_only_them)
} else {
stringResource(R.string.chat_reaction_only_you)
}
if (who.size == 1) "$emoji $side" else "$emoji ${who.size} $side"
},
fontSize = 13.sp,
color = Palette.SecondaryText,
modifier =
Modifier
.clip(RoundedCornerShape(Radius.small))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 6.dp, vertical = 2.dp)
.testTag("private-reactions"),
)
}
}
}
/**
* Section 23.7: nothing until the last thousand, then how much is left, then
* the reason the send button stopped working. The same words as iOS.
@@ -4645,7 +4852,11 @@ private fun ForwardSheet(
Column(modifier = Modifier.testTag("forward-list")) {
Text(
// States the cost plainly.
stringResource(R.string.chat_forward_note),
when {
line.card != null -> stringResource(R.string.chat_forward_note_card)
line.event != null -> stringResource(R.string.chat_forward_note_event)
else -> stringResource(R.string.chat_forward_note)
},
fontSize = 12.sp,
color = Palette.SecondaryText,
)
@@ -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)
}
@@ -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
}
}
@@ -699,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,
modifier = Modifier.fillMaxWidth(),
tag = "attach-photo",
)
KitSecondaryButton(text = stringResource(R.string.main_files_photo), onClick = onPhoto, tag = "attach-photo")
KitSecondaryButton(
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") }
}
}
@@ -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
}
}
@@ -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()
}
}
@@ -107,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
@@ -196,10 +200,23 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
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)
}
@@ -220,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()
@@ -320,6 +348,7 @@ private fun Root(model: MessengerViewModel = viewModel()) {
groups = current.groups,
heldMessages = current.heldMessages,
unsealedSendAt = current.unsealedSendAt,
myName = current.handle,
)
} else {
// The content area is weighted, and the tab bar is not.
@@ -342,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)
}
@@ -678,10 +717,15 @@ object NavDestinations {
/**
* 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", 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),
@@ -726,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.
@@ -746,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, stringResource(tab.labelRes), badge, tab.tag)
NavButton(
model,
phase,
tab.screen,
icon,
active,
stringResource(tab.labelRes),
badge,
tab.tag,
compact = tabs.size > 7,
)
}
}
}
@@ -772,6 +831,11 @@ 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)
@@ -815,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) {
@@ -862,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 } }
@@ -905,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
}
},
@@ -103,15 +103,13 @@ fun ColumnScope.MessagesScreen(
model.openContactLink(raw)
return@ScanAddressDialog
}
parseScannedAddress(raw)?.let { card ->
scannedPrefill =
if (card.handle != null &&
card.relay.equals(phase.relay, ignoreCase = true)
) {
card.handle
} else {
"${card.fnid}@${card.relay}"
}
// 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
}
},
@@ -213,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() {
@@ -316,10 +319,24 @@ class MessengerService : Service() {
"call=$onACall touched=${touched.size} " +
(outcome.exceptionOrNull()?.let { "failed=${it::class.simpleName}" } ?: "ok"),
)
// Requests and copies from this account's own devices are quiet
// (spec 14.5, 17.2): they count as unread, never as an alert.
val loud = touched - s.quietThreads
if (loud.isNotEmpty()) notifyMessages(loud.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
@@ -362,6 +379,8 @@ class MessengerService : Service() {
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
@@ -376,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,
)
}
@@ -499,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
@@ -507,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) } }
@@ -238,9 +238,10 @@ class MessengerViewModel(
/**
* STORIES is new, and it is a tab rather than a strip because a feed
* needs a screen.
* needs a screen. TODAY is the public Today file and the directory
* behind it (SPEC §8.12).
*/
enum class Screen { CONTACTS, MESSAGES, CALLS, STORIES, NOTIFICATIONS, ACCOUNT, WALLET, FILES, SEARCH }
enum class Screen { CONTACTS, MESSAGES, TODAY, CALLS, STORIES, NOTIFICATIONS, ACCOUNT, WALLET, FILES, SEARCH }
/** What the call UI needs to draw itself. */
data class CallState(
@@ -412,9 +413,19 @@ class MessengerViewModel(
api =
MessengerApi(credential.relayBaseUrl, credential.username, credential.appPassword),
)
session?.let { wireSealingPolicy(it); retireLeftoverDevice(it) }
val fetchedBuddies = runCatching { session!!.roster() }
val fetchedGroups = runCatching { session!!.namedGroups() }
session?.let {
wireSealingPolicy(it)
retireLeftoverDevice(it)
forgetTodayOnWipe(it)
// So a story that comes down takes its decrypted bytes with it.
it.attachmentCacheDir = getApplication<Application>().cacheDir
}
// DESIGN 47: from what is on this phone, so a relay
// out of reach never keeps the chats waiting; the
// network catches up after (catchUpAfterStart).
val storedBuddies = session!!.storedRoster()
val storedGroups = session!!.storedGroups()
val ids = storedBuddies.orEmpty().map { b -> b.userId }
stored to
Phase.SignedIn(
handle = credential.username,
@@ -425,26 +436,20 @@ class MessengerViewModel(
// passphrase to derive it from.
fnid = credential.fnid,
status = str(R.string.core_signed_in_on_this_device),
buddies = fetchedBuddies.getOrDefault(emptyList()),
buddiesLoaded = fetchedBuddies.isSuccess,
buddies = storedBuddies.orEmpty(),
buddiesLoaded = storedBuddies != null,
// From the vault, or every badge is gone at
// each launch whether or not the messages
// were read. The markers were UI state
// alone until now.
unread = session?.vault?.unreadThreads() ?: emptySet(),
avatars =
session?.avatars(
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId },
) ?: emptyMap(),
previews =
session?.previews(
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId },
) ?: emptyMap(),
avatars = session?.avatars(ids) ?: emptyMap(),
previews = session?.previews(ids) ?: emptyMap(),
calls = session?.callLog() ?: emptyList(),
storyAudience = session?.storyAudience() ?: Session.StoryAudience(),
myAvatar = session?.myAvatar(),
groups = fetchedGroups.getOrDefault(emptyList()),
groupsLoaded = fetchedGroups.isSuccess,
groups = storedGroups.orEmpty(),
groupsLoaded = storedGroups != null,
)
}
}
@@ -455,6 +460,7 @@ class MessengerViewModel(
_phase.value = restored.second ?: Phase.SignedOut()
if (restored.second != null) {
startPolling()
catchUpAfterStart()
republishIfMissingFromTheLog()
}
},
@@ -465,6 +471,24 @@ class MessengerViewModel(
)
}
/**
* The network's half of a start from what is on the phone (DESIGN 47):
* the contact list and the groups as the relay has them now, whenever it
* answers. A relay still out of reach leaves the stored ones showing.
*/
private fun catchUpAfterStart() =
viewModelScope.launch {
val current = session ?: return@launch
val buddies = withContext(Dispatchers.IO) { runCatching { current.roster() } }
val groups = withContext(Dispatchers.IO) { runCatching { current.namedGroups() } }
buddies.getOrNull()?.let { list ->
val ids = list.map { it.userId }
val (avatars, previews) = withContext(Dispatchers.IO) { current.avatars(ids) to current.previews(ids) }
update { it.copy(buddies = list, buddiesLoaded = true, avatars = it.avatars + avatars, previews = it.previews + previews) }
}
groups.getOrNull()?.let { list -> update { it.copy(groups = list, groupsLoaded = true) } }
}
/**
* Put this device back in the transparency log if it is not in it
* (android#6).
@@ -696,7 +720,13 @@ class MessengerViewModel(
relay = relay,
api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential),
)
session?.let { wireSealingPolicy(it); retireLeftoverDevice(it) }
session?.let {
wireSealingPolicy(it)
retireLeftoverDevice(it)
forgetTodayOnWipe(it)
// So a story that comes down takes its decrypted bytes with it.
it.attachmentCacheDir = getApplication<Application>().cacheDir
}
// Our return address, before the first federated send.
this.accountKeySeed = accountKeySeed
@@ -2094,6 +2124,85 @@ class MessengerViewModel(
}
}
// -- Today, the directory and hello links (§8.12, §6.12, §6.34) ----------.
/**
* The home relay with its scheme and port, which is where the Today file
* is read from. Null when signed out.
*/
fun homeRelayBaseUrl(): String? = session?.relay?.baseUrl
/**
* Send a directory site to one person: the address as the words, and a
* preview made from the entry unless link previews are off, through the
* same path a typed message takes. Nothing is fetched. [done] gets the
* sentence to show.
*/
fun shareSite(
site: Directory.Site,
peerUserId: Long,
peerName: String,
done: (String) -> Unit,
) = viewModelScope.launch {
val current = session ?: return@launch done("You're signed out, so nothing was sent.")
val withPreview = mediaSettings.linkPreviews
val result =
withContext(Dispatchers.IO) {
runCatching { DirectoryShare.send(current, peerUserId, site, withPreview) }
}
result
.onSuccess { sent ->
done(
when {
// Session.send keeps it in the outbox in this case.
!sent.reachedSomeone -> "Waiting to send to $peerName. It goes when one of their devices is back."
else -> describeDelivery(sent) ?: "Sent ${site.title} to $peerName."
},
)
}.onFailure { failure -> done(describe(failure)) }
}
private val _helloAddress = MutableStateFlow<String?>(null)
/**
* An address a hello link brought in, waiting for Home's add screen. In
* memory only, and taken as soon as Home shows it.
*/
val helloAddress: StateFlow<String?> = _helloAddress.asStateFlow()
/**
* A hello link was opened. It fills in Home's add screen with the address
* it names and does nothing more: nothing is looked up or sent, and nobody
* is added, until the person chooses to. Signed out, the address waits for
* sign-in.
*/
fun openHelloLink(link: String) {
val address = HelloLink.fromLink(link)
if (address == null) {
tell("That link doesn't hold a Flamenet address, so there is nobody to add.")
return
}
_helloAddress.value = address
update { it.copy(screen = Phase.Screen.CONTACTS, openChat = null, error = null) }
}
/** Home has put the address into its add screen. */
fun helloAddressTaken() {
_helloAddress.value = null
}
/** A scanned code that holds no address in any form. */
fun unreadableCode() = tell("That code doesn't hold a Flamenet address.")
/** A sentence for whoever is looking, signed in or not. */
private fun tell(message: String) {
when (val now = _phase.value) {
is Phase.SignedIn -> update { it.copy(error = message) }
is Phase.SignedOut -> _phase.value = now.copy(error = message)
else -> Unit
}
}
// -- Contacts: the rest of the CRUD --------------------------------------.
fun removeBuddy(userId: Long) =
@@ -3730,6 +3839,9 @@ class MessengerViewModel(
/** A story pick that cannot be staged, said in the same place other errors are. */
fun reportStoryProblem(message: String) = update { it.copy(error = message) }
/** Say something on the screen that is not about any one send. */
fun say(message: String) = update { it.copy(error = message) }
fun discardStagedStory() = update { it.copy(storyDraft = null) }
/** The deliberate Post from the composer sheet. */
@@ -3956,6 +4068,12 @@ class MessengerViewModel(
}
}
/** A wipe clears the Today tab's file and city picks with the vault (DESIGN 32). */
private fun forgetTodayOnWipe(session: Session) {
val app = getApplication<Application>()
session.onWiped = { TodayData.forget(app) }
}
private fun wireSealingPolicy(session: Session) {
val settings = MediaSettings(getApplication())
session.conversations.requireSealed = { settings.requireSealedSending }
@@ -4237,6 +4355,56 @@ class MessengerViewModel(
fun closeStory() = update { it.copy(openStory = null) }
/**
* The seam for drawing a screen from a vault in a test, with no relay to
* sign in to: pictures already decrypted into the cache load as they do
* for a signed-in person.
*/
internal fun useSessionForTesting(s: Session) {
session = s
}
/**
* Take one of my stories down for everyone it went to (§6.35). It leaves
* this phone at once; what the sends reached is said, not hidden.
*/
fun takeDownStory(story: Vault.Story) =
viewModelScope.launch {
val current = session ?: return@launch
val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty()
update { it.copy(busy = true, error = null) }
val done = withContext(Dispatchers.IO) { runCatching { current.takeDownStory(story, contacts) } }
val live = withContext(Dispatchers.IO) { runCatching { current.stories() } }.getOrNull()
update { state ->
state.copy(
busy = false,
openStory = state.openStory?.takeIf { it.id != story.id },
stories = live ?: state.stories,
error = done.fold(onSuccess = StoryWords::takenDown, onFailure = ::describe),
)
}
}
/** New words for one of my stories (§6.15 for stories). Never empty: that is a take-down. */
fun editStoryCaption(
story: Vault.Story,
caption: String,
) = viewModelScope.launch {
val current = session ?: return@launch
if (caption.isBlank()) return@launch
val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty()
update { it.copy(busy = true, error = null) }
val done = withContext(Dispatchers.IO) { runCatching { current.editStoryCaption(story, caption, contacts) } }
val live = withContext(Dispatchers.IO) { runCatching { current.stories() } }.getOrNull()
update { state ->
state.copy(
busy = false,
stories = live ?: state.stories,
error = done.fold(onSuccess = StoryWords::edited, onFailure = ::describe),
)
}
}
// -- Voice notes (§6.6) --------------------------------------------------.
private var recording: VoiceNote.Recording? = null
@@ -4374,6 +4542,7 @@ class MessengerViewModel(
*/
fun sendSticker(
chat: Phase.Chat,
pack: String,
stickerId: String,
) = viewModelScope.launch {
val current = session ?: return@launch
@@ -4382,11 +4551,11 @@ class MessengerViewModel(
runCatching {
val group = chat.group
if (group != null) {
current.sendGroupSticker(group, StickerPack.CORE, stickerId)
current.sendGroupSticker(group, pack, stickerId)
} else {
current.sendSticker(
chat.peerId ?: throw OnlyTextToAnotherRelay(),
StickerPack.CORE,
pack,
stickerId,
)
}
@@ -4403,6 +4572,197 @@ class MessengerViewModel(
}.onFailure { failure -> update { it.copy(error = describe(failure)) } }
}
// -- Cards and events (§6.31 and §6.32) ----------------------------------.
/** Redraw [threadId] if it is the conversation on screen, and only then. */
private fun redraw(
current: Session,
threadId: String,
error: String? = null,
) = update { state ->
state.copy(
openChat =
state.openChat?.let { open ->
if (open.threadId == threadId) open.copy(lines = current.history(threadId)) else open
},
error = error,
)
}
/** Send a card into whatever this conversation is. */
fun sendCard(
chat: Phase.Chat,
card: Content.Card,
) = viewModelScope.launch {
val current = session ?: return@launch
val done =
withContext(Dispatchers.IO) {
runCatching {
val group = chat.group
if (group != null) {
current.sendGroupCard(group, card)
} else {
current.sendCard(chat.peerId ?: throw OnlyTextToAnotherRelay(), card)
}
}
}
done
.onSuccess { sent -> redraw(current, chat.threadId, sent.let(::describeDelivery)) }
.onFailure { failure -> update { it.copy(error = describe(failure)) } }
}
/** Put a new event, organized by this account, into this conversation. */
fun sendEvent(
chat: Phase.Chat,
event: Content.Event,
) = viewModelScope.launch {
val current = session ?: return@launch
val done =
withContext(Dispatchers.IO) {
runCatching {
if (chat.group == null && chat.peerId == null) throw OnlyTextToAnotherRelay()
current.sendEvent(chat.peerId, chat.group, event)
}
}
done
.onSuccess { sent -> redraw(current, chat.threadId, sent.let(::describeDelivery)) }
.onFailure { failure -> update { it.copy(error = describe(failure)) } }
}
/** Change an event this account organized. Everyone in the chat gets the new version. */
fun reviseEvent(
chat: Phase.Chat,
eventId: String,
edited: Content.Event,
) = changeEvent(chat, eventId, "The change reached nobody, so the event is as it was.") { edited }
/** Cancel an event this account organized. */
fun cancelEvent(
chat: Phase.Chat,
eventId: String,
) = changeEvent(chat, eventId, "The cancel reached nobody, so the event still stands.") {
it.copy(cancelled = true)
}
private fun changeEvent(
chat: Phase.Chat,
eventId: String,
failed: String,
change: (Content.Event) -> Content.Event,
) = viewModelScope.launch {
val current = session ?: return@launch
val ok =
withContext(Dispatchers.IO) {
runCatching { current.reviseEvent(chat.threadId, eventId, chat.peerId, chat.group, change) }
.getOrDefault(false)
}
redraw(current, chat.threadId, if (ok) null else failed)
}
/** Answer an event: yes, maybe or no. */
fun answerEvent(
chat: Phase.Chat,
eventId: String,
answer: String,
) = viewModelScope.launch {
val current = session ?: return@launch
val ok =
withContext(Dispatchers.IO) {
runCatching { current.answerEvent(chat.threadId, eventId, answer, chat.peerId, chat.group) }
.getOrDefault(false)
}
redraw(current, chat.threadId, if (ok) null else "Your answer reached nobody. Nothing changed; try again.")
}
/**
* Forward a card or an event (§6.31, §6.32). The screen offers
* neither for a card still sealed here or a cancelled event.
*/
fun forwardCardOrEvent(
line: Session.Line,
toPeerUserId: Long?,
toGroup: RelayClient.Group?,
) = viewModelScope.launch {
val current = session ?: return@launch
val ok =
withContext(Dispatchers.IO) {
runCatching {
val card = line.card
val event = line.event
when {
card != null -> current.forwardCard(card, toPeerUserId, toGroup)
event != null -> current.forwardEvent(event.event, toPeerUserId, toGroup)
else -> false
}
}.getOrDefault(false)
}
// The forward lands in another thread; this one is redrawn in case it
// was forwarded to itself.
update { state ->
state.copy(
openChat = state.openChat?.let { chat -> chat.copy(lines = current.history(chat.threadId)) },
error = if (ok) null else "That could not be forwarded. Nothing was sent.",
)
}
}
// -- Games (§6.33) ---------------------------------------------------------.
/** Start a game in a one-to-one chat. The screen offers it nowhere else. */
fun startGame(
chat: Phase.Chat,
type: io.flamenet.games.GameType,
iGoFirst: Boolean,
) = viewModelScope.launch {
val current = session ?: return@launch
val done =
withContext(Dispatchers.IO) {
runCatching {
if (chat.group != null) throw IllegalStateException("Games are for one-to-one chats.")
current.startGame(chat.peerId ?: throw OnlyTextToAnotherRelay(), type, iGoFirst)
}
}
done
.onSuccess { sent -> redraw(current, chat.threadId, describeDelivery(sent)) }
.onFailure { failure -> update { it.copy(error = describe(failure)) } }
}
/** "Start a new game" from a game whose boards disagree: same game, same sides. */
fun startGameLike(
chat: Phase.Chat,
state: ChatGames.State,
) = startGame(chat, state.type, iGoFirst = state.me == io.flamenet.games.Side.FIRST)
/** Play one move. Taken back, and said so, when it reached nobody. */
fun playGameMove(
chat: Phase.Chat,
gameId: String,
move: String,
) = viewModelScope.launch {
val current = session ?: return@launch
val peer = chat.peerId ?: return@launch
val ok =
withContext(Dispatchers.IO) {
runCatching { current.playGameMove(chat.threadId, gameId, move, peer) }.getOrDefault(false)
}
redraw(current, chat.threadId, if (ok) null else "That move did not reach them, so it was not played. Try again.")
}
/** Resign, offer or answer a draw, or decline or cancel before play starts. */
fun endGame(
chat: Phase.Chat,
gameId: String,
ending: String,
) = viewModelScope.launch {
val current = session ?: return@launch
val peer = chat.peerId ?: return@launch
val ok =
withContext(Dispatchers.IO) {
runCatching { current.endGame(chat.threadId, gameId, ending, peer) }.getOrDefault(false)
}
redraw(current, chat.threadId, if (ok) null else "That did not reach them, so nothing changed. Try again.")
}
/**
* How the open conversation is named in the vault, the same ids the
* transcript uses, so a group, a local contact and somebody on another
@@ -4697,6 +5057,28 @@ class MessengerViewModel(
return@launch
}
val threads = received.getOrDefault(emptySet())
// §6.32. An answer redraws its event's card and is
// not news: the open chat catches up, nothing is
// marked unread.
val quiet = current.takeQuietlyChanged()
if (quiet.isNotEmpty()) {
update { state ->
state.copy(
openChat =
state.openChat?.let {
if (it.threadId in quiet) {
// A pin is quiet too (§6.19), and moves the banner.
it.copy(
lines = current.history(it.threadId),
pinnedMessageId = current.pinnedMessage(it.threadId),
)
} else {
it
}
},
)
}
}
// What ingest set aside this cycle, so the banner can
// say what is waiting rather than only that keys
// changed. Cheap: a sealed read of one small file, and
@@ -4736,7 +5118,11 @@ class MessengerViewModel(
// leaving the loop, the next poll may well succeed.
polled.getOrNull()?.let { live ->
update { state ->
if (state.stories.map { it.id } == live.map { it.id }) {
// A new or departed id, or new words: an edited
// caption keeps its id (§6.15). Not the whole
// story, whose picture key compares by identity.
fun shown(list: List<Vault.Story>) = list.map { Triple(it.id, it.caption, it.editedAt) }
if (shown(state.stories) == shown(live)) {
state
} else {
state.copy(stories = live)
@@ -4886,7 +5272,10 @@ class MessengerViewModel(
polling?.cancel()
polling = null
session = null
withContext(Dispatchers.IO) { runCatching { KeystoreVault.destroy(getApplication()) } }
withContext(Dispatchers.IO) {
runCatching { KeystoreVault.destroy(getApplication()) }
runCatching { TodayData.forget(getApplication()) }
}
identity = null
vault = KeystoreVault.open(getApplication())
_phase.value =
@@ -4907,7 +5296,10 @@ class MessengerViewModel(
polling?.cancel()
polling = null
session = null
withContext(Dispatchers.IO) { runCatching { KeystoreVault.destroy(getApplication()) } }
withContext(Dispatchers.IO) {
runCatching { KeystoreVault.destroy(getApplication()) }
runCatching { TodayData.forget(getApplication()) }
}
identity = null
vault = KeystoreVault.open(getApplication())
_phase.value =
@@ -4941,6 +5333,9 @@ class MessengerViewModel(
} else {
identity = null
}
// Signing out, erasing or not, takes the Today tab's file and city
// picks with it (DESIGN 32).
withContext(Dispatchers.IO) { runCatching { TodayData.forget(getApplication()) } }
_phase.value = Phase.SignedOut()
}
@@ -5248,7 +5643,7 @@ private fun describeFailureFromTheServer(
internal class OnlyTextToAnotherRelay :
Exception(
"Only text can be sent to somebody on another server for now. " +
"Pictures, files and stickers cannot go there yet.",
"Pictures, files, stickers, cards, events and games cannot go there yet.",
)
/**
@@ -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.
@@ -51,6 +52,13 @@ class NotificationSettings(
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,
@@ -63,6 +71,12 @@ class NotificationSettings(
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 {
/**
* Pure, and takes the preference as an argument, so a test can call
@@ -79,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,
@@ -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,
@@ -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
@@ -37,15 +39,15 @@ 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(
stringResource(R.string.main_stickers_failed),
fontSize = 14.sp,
@@ -60,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"),
@@ -68,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.
*/
@@ -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
@@ -67,7 +68,7 @@ fun ColumnScope.PollCard(
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)
@@ -93,13 +94,13 @@ fun ColumnScope.PollCard(
Text(
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,
)
}
}
@@ -183,7 +184,7 @@ private fun ColumnScope.Results(
stringResource(R.string.social_poll_guarantee_pair)
},
fontSize = 12.sp,
color = Palette.SecondaryText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -241,7 +242,7 @@ fun NewPollDialog(
stringResource(R.string.social_poll_group_note)
},
fontSize = 12.sp,
color = Palette.SecondaryText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
@@ -25,38 +25,40 @@ 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(
File diff suppressed because it is too large Load Diff
@@ -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 context.getString(io.flamenet.messenger.R.string.main_sticker)
return loaded?.get(id)?.name ?: context.getString(io.flamenet.messenger.R.string.main_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 }
}
}
@@ -173,7 +173,7 @@ private fun StorySetComposerDialog(
Text(
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(
@@ -303,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) pluralStringResource(R.plurals.social_story_remaining_with_count, setCount, remaining, setCount) 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}"),
)
}
@@ -326,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)
}
}
@@ -398,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
}
@@ -447,7 +455,7 @@ private fun StoryActions(
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
@@ -459,7 +467,7 @@ private fun StoryActions(
stringResource(R.string.social_story_report),
fontSize = 13.sp,
maxLines = 1,
color = Palette.SecondaryText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier
.clickable { reporting = true }
@@ -546,6 +554,161 @@ private fun StoryActions(
}
}
/** 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
@@ -594,25 +757,7 @@ fun StoryComposerDialog(
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 = stringResource(R.string.social_story_preview_desc),
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(Radius.small)),
)
if (draft.duration > 0) {
Text(
stringResource(R.string.social_story_video_seconds, draft.duration.toInt()),
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 },
@@ -640,7 +785,7 @@ fun StoryComposerDialog(
fontSize = 12.sp,
color =
if (streamOk) {
Palette.SecondaryText
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.error
},
@@ -674,7 +819,7 @@ fun StoryComposerDialog(
Text(
stringResource(R.string.social_story_group_note),
fontSize = 12.sp,
color = Palette.SecondaryText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs)) {
@@ -729,7 +874,7 @@ fun StoryComposerDialog(
}
},
fontSize = 12.sp,
color = Palette.SecondaryText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -757,6 +902,29 @@ fun StoryComposerDialog(
)
}
/**
* 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.
@@ -780,14 +948,14 @@ fun ColumnScope.StoriesCards(
Icon(
KitIcon.Stories,
contentDescription = null,
tint = Palette.SecondaryText,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(Space.xxl),
)
Text(stringResource(R.string.social_stories_empty_title), fontSize = 16.sp, fontWeight = FontWeight.Medium)
Text(
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,
)
}
@@ -842,12 +1010,16 @@ private fun StoryVideoCard(
Text(
stringResource(R.string.social_video_unavailable),
fontSize = 14.sp,
color = Palette.SecondaryText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> {
Text(stringResource(R.string.common_loading), fontSize = 14.sp, color = Palette.SecondaryText)
Text(
stringResource(R.string.common_loading),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -873,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 = stringResource(R.string.social_story_image_desc),
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
failed.value -> {
Text(
stringResource(R.string.social_image_unavailable),
fontSize = 14.sp,
color = Palette.SecondaryText,
)
}
else -> {
Text(stringResource(R.string.common_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,
)
}
}
@@ -0,0 +1,117 @@
package io.flamenet.messenger.app
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.testTag
/**
* How a story's photo sits in its feed card (DESIGN item 27): all of it,
* never cut. The card takes the photo's own shape between two wide and one
* high, and four wide and five high, and is never taller than 560dp; past
* either, the photo is fitted inside and plain bars fill the rest, beside a
* tall photo or above and below a wide one. The iPhone uses the same numbers.
*/
object StoryFrame {
/** The widest a card gets: twice as wide as it is tall. */
const val MIN_TALL = 0.5f
/** The tallest: four wide by five high. */
const val MAX_TALL = 1.25f
/** And never taller than this, for a tablet or a phone held sideways. */
const val MAX_HEIGHT_DP = 560f
/** A card, and the photo fitted inside it, in dp. */
data class Frame(
val width: Float,
val height: Float,
val photoWidth: Float,
val photoHeight: Float,
) {
/** Bars to the left and right: the photo is narrower than the card. */
val barsBeside: Boolean get() = width - photoWidth > 0.5f
/** Bars above and below: the photo is wider than the card's shape. */
val barsAbove: Boolean get() = height - photoHeight > 0.5f
}
/**
* The card for a photo of [photoWidth] by [photoHeight] pixels shown
* [width] dp wide. Only the photo's shape matters, never its pixel count,
* and a photo whose size is not known is treated as square.
*/
fun frame(
width: Float,
photoWidth: Int,
photoHeight: Int,
): Frame {
val tall = if (photoWidth > 0 && photoHeight > 0) photoHeight.toFloat() / photoWidth else 1f
val height = minOf(width * tall.coerceIn(MIN_TALL, MAX_TALL), MAX_HEIGHT_DP)
// As large as fits in width by height at the photo's own shape.
val fitted = minOf(width, height / tall)
return Frame(width, height, fitted, fitted * tall)
}
/** The width of a card in the stories feed, which pads the screen on both sides. */
@Composable
fun feedCardWidthDp(): Float = LocalConfiguration.current.screenWidthDp - 2 * Space.md.value
/** The card for [photoWidth] by [photoHeight] as the feed lays it out on this phone. */
@Composable
fun inFeed(
photoWidth: Int,
photoHeight: Int,
): Frame = frame(feedCardWidthDp(), photoWidth, photoHeight)
}
/**
* A story's picture in the shape its feed card has on this phone, scaled to
* whatever width it is given, so the composer shows exactly what friends will
* see. [bitmap] is fitted whole; the bars take the theme's quiet surface.
*/
@Composable
fun StoryPhotoFrame(
bitmap: ImageBitmap?,
photoWidth: Int,
photoHeight: Int,
modifier: Modifier = Modifier,
/** Drawn in place of the picture while it loads, fails, or is a video. */
instead: (@Composable BoxScope.() -> Unit)? = null,
) {
val frame = StoryFrame.inFeed(photoWidth, photoHeight)
Box(
modifier =
modifier
.fillMaxWidth()
.aspectRatio(frame.width / frame.height)
.clip(RoundedCornerShape(Radius.small))
.background(MaterialTheme.colorScheme.surfaceVariant)
.testTag("story-photo-frame"),
contentAlignment = Alignment.Center,
) {
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = stringResource(R.string.social_story_image_desc),
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize().testTag("story-photo"),
)
} else {
instead?.invoke(this)
}
}
}
@@ -0,0 +1,38 @@
package io.flamenet.messenger.app
import android.content.Context
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import java.io.File
/**
* What the Today tab keeps on this phone: the last good Today file and the
* cities picked for the weather. Public data and a preference, both kept out
* of backups like everything else, and both cleared on sign-out and by a wipe
* (DESIGN 32).
*/
object TodayData {
const val PREFS = "today"
const val KEY_CITIES = "weather.cities"
/** Under `filesDir`. */
const val CACHE_DIR = "today"
private val _cleared = MutableStateFlow(0)
/** Moves on every clear, so a screen holding the data in memory lets go of it too. */
val cleared: StateFlow<Int> = _cleared.asStateFlow()
/**
* Delete the cached file and the city picks. The count moves first, so a
* fetch still on its way sees the clear before the files go, and drops
* what it brings back.
*/
fun forget(context: Context) {
_cleared.update { it + 1 }
File(context.filesDir, CACHE_DIR).deleteRecursively()
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().commit()
}
}
@@ -0,0 +1,18 @@
package io.flamenet.messenger.app
import io.flamenet.client.TodayFile
/**
* The keys a Today file (SPEC §8.12) may be signed with, as base64 public keys.
*/
object TodayKeys {
/**
* FlameNet's production signing key, added at release when that key is
* made. Empty until then, so a release build trusts no Today file at all
* and the tab says the relay's page could not be checked.
*/
val PRODUCTION: List<String> = emptyList()
/** Every key this build accepts: production, plus the dev key in a debug build only. */
val trusted: TodayFile.Keys get() = TodayFile.Keys(PRODUCTION + TodayDevKey.KEYS)
}
@@ -0,0 +1,150 @@
package io.flamenet.messenger.app
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import io.flamenet.client.RelayClient
import io.flamenet.client.TodayFeed
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
/**
* What the Today tab and the directory behind it draw (SPEC §8.12, DESIGN 5).
*
* The Today file comes from the home relay through [TodayFeed]. Everything
* the person chooses here, the cities and where they are in the directory,
* stays on the phone and is never sent anywhere.
*/
class TodayModel(
app: Application,
) : AndroidViewModel(app) {
data class Ui(
/** What the feed holds and how the last answer went; null until read. */
val snapshot: TodayFeed.Snapshot? = null,
/** Asking the relay right now, with nothing held to show meanwhile. */
val loading: Boolean = false,
/** The cities picked on this phone, by `TodayFile.City.key`. Never sent. */
val cities: Set<String> = emptySet(),
/** The city picker is showing in place of the page. */
val choosingCities: Boolean = false,
val directory: Directory? = null,
val directoryOpen: Boolean = false,
/** The category being browsed, or null for the top of the tree. */
val category: String? = null,
val query: String = "",
)
private val prefs = app.getSharedPreferences(TodayData.PREFS, Context.MODE_PRIVATE)
private val _ui = MutableStateFlow(Ui(cities = savedCities()))
val ui: StateFlow<Ui> = _ui.asStateFlow()
private var feed: TodayFeed? = null
init {
// Sign-out and a wipe clear what is on disk (DESIGN 32); this lets go
// of what is in memory, so the next person to sign in starts empty.
val seen = TodayData.cleared.value
viewModelScope.launch {
TodayData.cleared.collect { now ->
if (now != seen) {
feed = null
// The picks went with it.
_ui.value = Ui(directory = _ui.value.directory)
}
}
}
}
private fun savedCities(): Set<String> = prefs.getStringSet(TodayData.KEY_CITIES, null).orEmpty().toSet()
/**
* The tab was opened, or the app came back to it. Shows what is held at
* once, and asks the relay when the hour is up.
*/
fun opened(baseUrl: String?) {
loadDirectory()
val f = baseUrl?.let(::feedFor) ?: return
// Sign-out or a wipe while this is out: nothing it brings back is
// shown (DESIGN 32), and the feed writes nothing back.
val asked = TodayData.cleared.value
viewModelScope.launch {
val held = withContext(Dispatchers.IO) { f.current() }
val due = withContext(Dispatchers.IO) { f.isDue() }
if (TodayData.cleared.value != asked) return@launch
_ui.update { it.copy(snapshot = held, loading = due && held.bundle == null) }
if (!due) return@launch
val fresh = withContext(Dispatchers.IO) { f.refresh() }
if (TodayData.cleared.value != asked) return@launch
_ui.update { it.copy(snapshot = fresh, loading = false) }
}
}
private fun feedFor(baseUrl: String): TodayFeed {
feed?.takeIf { it.url == TodayFeed.urlFor(baseUrl) }?.let { return it }
// The app's ordinary client, TodayFeed's default, so a bridge in use
// carries this request like every other.
return TodayFeed(
baseUrl = baseUrl,
dir = File(getApplication<Application>().filesDir, TodayData.CACHE_DIR),
keys = TodayKeys.trusted,
userAgent = "FlamenetMessenger/${RelayClient.CLIENT_VERSION} (Android)",
epoch = { TodayData.cleared.value },
).also { feed = it }
}
fun openCityPicker() = _ui.update { it.copy(choosingCities = true) }
fun closeCityPicker() = _ui.update { it.copy(choosingCities = false) }
/** Remember which cities to show. Stored on this phone only. */
fun chooseCities(keys: Set<String>) {
prefs.edit().putStringSet(TodayData.KEY_CITIES, keys.toSet()).apply()
_ui.update { it.copy(cities = keys.toSet(), choosingCities = false) }
}
private fun loadDirectory() {
if (_ui.value.directory != null) return
viewModelScope.launch {
val loaded = withContext(Dispatchers.IO) { runCatching { Directory.load(getApplication()) }.getOrNull() }
_ui.update { it.copy(directory = loaded) }
}
}
fun openDirectory() {
loadDirectory()
_ui.update { it.copy(directoryOpen = true, category = null, query = "") }
}
fun browse(category: String?) = _ui.update { it.copy(category = category, query = "") }
fun search(query: String) = _ui.update { it.copy(query = query) }
/**
* Back, from wherever the directory is: a search clears, a category goes
* to its parent, the top closes the directory. False when there was
* nothing to go back from.
*/
fun back(): Boolean {
val now = _ui.value
if (!now.directoryOpen) return false
when {
now.query.isNotEmpty() -> _ui.update { it.copy(query = "") }
now.category != null -> {
val parent = now.directory?.category(now.category)?.parent?.takeIf { it.isNotEmpty() }
_ui.update { it.copy(category = parent) }
}
else -> _ui.update { it.copy(directoryOpen = false) }
}
return true
}
fun closeDirectory() = _ui.update { it.copy(directoryOpen = false, category = null, query = "") }
}
@@ -0,0 +1,496 @@
package io.flamenet.messenger.app
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import io.flamenet.client.TodayFeed
import io.flamenet.client.TodayFile
import java.text.NumberFormat
/**
* The Today tab (SPEC §8.12): one public file from the home relay, and the
* way into the small-web directory.
*
* The file is asked for when the tab opens and when the app comes back to it,
* at most once an hour, and the copy on disk is what shows in between.
*/
@Composable
internal fun ColumnScope.TodayScreen(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
val today: TodayModel = viewModel()
val ui by today.ui.collectAsStateWithLifecycle()
val context = LocalContext.current
val baseUrl = remember(phase.relay) { model.homeRelayBaseUrl() }
// Opening the tab, and coming back to the app while it shows. The
// observer is told ON_START as soon as it is added, which is the opening.
val owner = androidx.lifecycle.compose.LocalLifecycleOwner.current
DisposableEffect(owner, baseUrl) {
val observer =
androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_START) today.opened(baseUrl)
}
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
if (ui.directoryOpen) {
DirectoryScreen(model, phase, today, ui)
return
}
val cities = ui.snapshot?.bundle?.weather.orEmpty()
if (ui.choosingCities && cities.isNotEmpty()) {
BackHandler { today.closeCityPicker() }
CityPickerPage(
cities = cities,
chosen = ui.cities,
onDone = { today.chooseCities(it) },
onCancel = { today.closeCityPicker() },
)
return
}
var openProblem by remember { mutableStateOf<String?>(null) }
TodayPage(
ui = ui,
relay = phase.relay,
fahrenheit = TodayText.fahrenheit(androidx.core.text.util.LocalePreferences.getTemperatureUnit()),
problem = openProblem,
onOpen = { url -> openProblem = if (openLink(context, url)) null else NO_BROWSER },
onChooseCities = { today.openCityPicker() },
onBrowse = { today.openDirectory() },
)
}
internal const val NO_BROWSER = "There's no browser on this phone to open that link."
/**
* Open [url] in whatever the phone uses for web links. Only http and https,
* and only because somebody tapped it. False when nothing could open it.
*/
internal fun openLink(
context: Context,
url: String,
): Boolean {
if (!Directory.isWebAddress(url)) return false
val view = Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE)
return runCatching { context.startActivity(view) }.isSuccess
}
/**
* The page itself, from plain values, so it can be drawn without a relay.
*/
@Composable
internal fun ColumnScope.TodayPage(
ui: TodayModel.Ui,
relay: String,
fahrenheit: Boolean,
problem: String? = null,
/** For the forecast's "today" in each city; a test pins it. */
now: java.time.Instant = java.time.Instant.now(),
onOpen: (String) -> Unit,
onChooseCities: () -> Unit,
onBrowse: () -> Unit,
) {
val snapshot = ui.snapshot
val bundle = snapshot?.bundle?.takeIf { snapshot.status != TodayFeed.Status.NOT_OFFERED }
PortalHeader(
title = "Today",
subtitle = snapshot?.takeIf { bundle != null }?.let { TodayText.subtitle(it) },
)
val status =
when {
snapshot == null || (ui.loading && bundle == null) -> "Getting today's page from your server."
else -> TodayText.status(snapshot)
}
if (status != null || problem != null) {
PortalBody {
status?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.testTag("today-status"))
}
problem?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
}
}
if (bundle != null) {
if (bundle.headlines.isNotEmpty()) {
PortalBand("Headlines")
for (h in bundle.headlines) {
LinkRow(h.title, h.source.ifEmpty { null }, h.url, onOpen, "today-headline")
}
}
if (bundle.markets.isNotEmpty()) {
PortalBand("Markets")
PortalBody {
Text(
TodayText.MARKETS_CAVEAT,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag("markets-caveat"),
)
}
for (m in bundle.markets) MarketRow(m)
}
if (bundle.weather.isNotEmpty()) {
PortalBand("Weather")
WeatherSection(bundle.weather, ui.cities, fahrenheit, now, onChooseCities)
}
bundle.site?.let { site ->
PortalBand("Site of the day")
LinkRow(
title = site.title,
detail = site.description,
url = site.url,
onOpen = onOpen,
tag = "today-site",
// The file's own word on who wrote the line above it.
label = site.description?.let { bundle.siteNote },
)
}
bundle.post?.let { post ->
PortalBand("From the FlameNet blog")
LinkRow(
title = post.title,
detail = listOfNotNull(post.description, post.by?.let { "By $it" }).joinToString("\n").ifEmpty { null },
url = post.url,
onOpen = onOpen,
tag = "today-post",
)
}
}
PortalBand("The small web")
PortalBody {
val count = ui.directory?.sites?.size
Text(
if (count != null) {
"${NumberFormat.getIntegerInstance().format(count)} sites from the FlameNet directory, " +
"kept on this phone. Browse and search them, even offline."
} else {
"Sites from the FlameNet directory, kept on this phone. Browse and search them, even offline."
},
style = MaterialTheme.typography.bodyMedium,
)
KitPrimaryButton(
text = "Browse the small web",
onClick = onBrowse,
modifier = Modifier.fillMaxWidth(),
tag = "today-directory",
)
}
if (bundle != null) {
PortalBand()
PortalBody(spacing = Space.xs) {
for (note in bundle.notes) {
Text(note, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text(
TodayText.sameForEveryone(relay),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag("today-same-for-everyone"),
)
}
}
}
/**
* A link a person can open: its words, then the host it goes to, always,
* so a title cannot pass for a destination.
*/
@Composable
internal fun LinkRow(
title: String,
detail: String?,
url: String,
onOpen: (String) -> Unit,
tag: String,
label: String? = null,
trailing: (@Composable () -> Unit)? = null,
) {
val host = TodayText.host(url)
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier =
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.clickable(onClickLabel = "Open in your browser") { onOpen(url) }
.semantics { contentDescription = "$title, opens $host" }
.padding(start = Space.lg, end = if (trailing != null) Space.xs else Space.lg, top = Space.md, bottom = Space.md)
.testTag(tag),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
detail?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
}
label?.let {
Text(it, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 3)
}
Text(host, fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
trailing?.invoke()
}
Box(
Modifier
.fillMaxWidth()
.height(Kit.hairline)
.background(MaterialTheme.colorScheme.outlineVariant),
)
}
}
@Composable
private fun MarketRow(m: TodayFile.Market) {
val tint =
when {
m.change.startsWith("+") -> if (LocalPortalDark.current) Palette.NightSuccess else Palette.Teal
m.change.startsWith("-") || m.change.startsWith("\u2212") -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurface
}
PortalRow(height = 52.dp, modifier = Modifier.testTag("today-market")) {
Text(
m.name,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Column(horizontalAlignment = Alignment.End) {
Text(m.last, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
if (m.change.isNotEmpty() || m.percent.isNotEmpty()) {
Text(
listOf(m.change, TodayText.percent(m.percent).let { if (it.isEmpty()) "" else "($it)" })
.filter { it.isNotEmpty() }
.joinToString(" "),
fontSize = 12.sp,
color = tint,
)
}
}
}
}
@Composable
private fun WeatherSection(
cities: List<TodayFile.City>,
chosen: Set<String>,
fahrenheit: Boolean,
now: java.time.Instant,
onChooseCities: () -> Unit,
) {
val shown = cities.filter { it.key in chosen }
if (shown.isEmpty()) {
PortalBody {
Text(
if (chosen.isEmpty()) {
"Pick the cities you'd like to see. Your choice stays on this phone."
} else {
"None of the cities you picked are in today's page."
},
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.testTag("weather-prompt"),
)
KitSecondaryButton(
text = "Choose cities",
onClick = onChooseCities,
modifier = Modifier.fillMaxWidth(),
tag = "weather-choose",
)
}
return
}
for (city in shown) CityBlock(city, fahrenheit, now)
PortalBody {
KitSecondaryButton(
text = "Change cities",
onClick = onChooseCities,
modifier = Modifier.fillMaxWidth(),
tag = "weather-choose",
)
}
}
@Composable
private fun CityBlock(
city: TodayFile.City,
fahrenheit: Boolean,
now: java.time.Instant,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = Space.lg, vertical = Space.md)
.testTag("weather-city"),
verticalArrangement = Arrangement.spacedBy(Space.xs),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
city.name,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
city.now?.let { current ->
Text(
listOfNotNull(TodayText.degrees(current.celsius, fahrenheit), TodayText.sky(current.sky)).joinToString(", "),
style = MaterialTheme.typography.bodyMedium,
)
}
}
val days = TodayText.ahead(city, now)
if (days.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
for (day in days) {
Column(
modifier =
Modifier.weight(1f).semantics {
contentDescription =
"${TodayText.weekday(day.date)}, high ${TodayText.degrees(day.high, fahrenheit)}, " +
"low ${TodayText.degrees(day.low, fahrenheit)}" +
(TodayText.sky(day.sky)?.let { ", ${it.lowercase()}" } ?: "")
},
) {
Text(TodayText.weekday(day.date), fontSize = 12.sp, fontWeight = FontWeight.Bold)
Text(
"${TodayText.bare(day.high, fahrenheit)} ${TodayText.bare(day.low, fahrenheit)}",
fontSize = 12.sp,
)
TodayText.sky(day.sky)?.let {
Text(it, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2)
}
}
}
// Fewer than five days left keeps the columns the same width.
repeat(TodayFile.MAX_DAYS - days.size) { Spacer(Modifier.weight(1f)) }
}
}
}
Box(
Modifier
.fillMaxWidth()
.height(Kit.hairline)
.background(MaterialTheme.colorScheme.outlineVariant),
)
}
/**
* Which cities to show, from the file's own list. A page of the tab rather
* than a dialog, so the filter field takes focus only when it is tapped. The
* choice is kept on this phone and goes nowhere: every phone gets every city.
*/
@Composable
internal fun ColumnScope.CityPickerPage(
cities: List<TodayFile.City>,
chosen: Set<String>,
onDone: (Set<String>) -> Unit,
onCancel: () -> Unit,
) {
var picked by remember { mutableStateOf(chosen) }
var filter by remember { mutableStateOf("") }
PortalHeader(title = "Cities", subtitle = "For the weather on Today") {
PortalHeaderIcon(
icon = KitIcon.Close,
contentDescription = "Cancel",
onClick = onCancel,
modifier = Modifier.testTag("cities-cancel"),
)
}
PortalBody {
Text(
"Your choice stays on this phone. Every phone gets the same list.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = filter,
onValueChange = { filter = it },
placeholder = { Text("Find a city", fontSize = 14.sp) },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("city-filter"),
)
KitPrimaryButton(
text = "Done",
onClick = { onDone(picked) },
modifier = Modifier.fillMaxWidth(),
tag = "cities-done",
)
}
val folded = Directory.fold(filter)
for (city in cities) {
if (folded.isNotEmpty() && folded !in Directory.fold(city.name)) continue
val on = city.key in picked
PortalRow(
modifier =
Modifier
.clickable { picked = if (on) picked - city.key else picked + city.key }
.testTag("city-${city.key}"),
) {
Checkbox(checked = on, onCheckedChange = null)
Text(city.name, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Text(city.country, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@@ -0,0 +1,151 @@
package io.flamenet.messenger.app
import io.flamenet.client.TodayFeed
import io.flamenet.client.TodayFile
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle
import java.util.Locale
/**
* The words and figures on the Today tab, as plain functions of the file.
*/
object TodayText {
const val NOT_OFFERED = "Today's page is not offered by this relay."
const val MARKETS_CAVEAT = "Prices are delayed. Not advice."
/** "Sunday, October 4, 2026", the day a file was made for, in the reader's language. */
fun day(
date: String,
locale: Locale = Locale.getDefault(),
): String =
runCatching { LocalDate.parse(date).format(DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)) }
.getOrDefault(date)
/** "From Sunday, October 4, 2026", for a copy that is not the relay's latest answer. */
fun from(
date: String,
locale: Locale = Locale.getDefault(),
): String = "From " + day(date, locale)
/**
* The date under the page's title: the plain day for what the relay is
* serving now, "From <day>" for the copy kept on the phone, as on the
* iPhone (DESIGN 4). Null with no page to show.
*/
fun subtitle(
snapshot: TodayFeed.Snapshot,
locale: Locale = Locale.getDefault(),
): String? {
val bundle = snapshot.bundle ?: return null
return when (snapshot.status) {
TodayFeed.Status.NEVER, TodayFeed.Status.CURRENT -> day(bundle.date, locale)
else -> from(bundle.date, locale)
}
}
/**
* The line above the sections, or null when what is shown is what the
* relay is serving now. With a kept copy shown, the header says which day
* it is from and this line says only why.
*/
fun status(
snapshot: TodayFeed.Snapshot,
locale: Locale = Locale.getDefault(),
): String? {
val held = snapshot.bundle != null
return when (snapshot.status) {
TodayFeed.Status.NEVER, TodayFeed.Status.CURRENT -> {
null
}
TodayFeed.Status.NOT_OFFERED -> {
NOT_OFFERED
}
TodayFeed.Status.UNREACHABLE -> {
if (held) "Couldn't reach your server just now." else "Couldn't reach your server. Today's page will be here once it can be fetched."
}
TodayFeed.Status.FAILED -> {
if (held) "Your server couldn't send a newer page just now." else "Your server couldn't send today's page just now."
}
TodayFeed.Status.NOT_READY -> {
if (held) "Your server doesn't have a newer page yet." else "Your server doesn't have today's page yet."
}
TodayFeed.Status.REFUSED -> {
if (held) "The newest page from your server could not be checked, so it isn't shown." else "Your server sent a page that could not be checked, so it isn't shown."
}
}
}
/**
* Whether the person reads Fahrenheit: their own regional setting if they
* made one, else what their region uses. [unit] is what
* `LocalePreferences.getTemperatureUnit()` answers.
*/
fun fahrenheit(unit: String): Boolean = unit == "fahrenhe"
/** Whole degrees in the person's unit, with the unit. */
fun degrees(
celsius: Int,
fahrenheit: Boolean,
): String = if (fahrenheit) "${Math.round(celsius * 9.0 / 5.0 + 32.0)}°F" else "$celsius°C"
/** The figure alone, where the unit is already on the line. */
fun bare(
celsius: Int,
fahrenheit: Boolean,
): String = if (fahrenheit) "${Math.round(celsius * 9.0 / 5.0 + 32.0)}°" else "$celsius°"
fun sky(sky: TodayFile.Sky?): String? =
when (sky) {
TodayFile.Sky.CLEAR -> "Clear"
TodayFile.Sky.PARTLY -> "Partly cloudy"
TodayFile.Sky.CLOUDY -> "Cloudy"
TodayFile.Sky.FOG -> "Fog"
TodayFile.Sky.DRIZZLE -> "Drizzle"
TodayFile.Sky.RAIN -> "Rain"
TodayFile.Sky.SNOW -> "Snow"
TodayFile.Sky.STORM -> "Storms"
null -> null
}
/** "Sun", in the reader's language. */
fun weekday(
date: String,
locale: Locale = Locale.getDefault(),
): String =
runCatching { LocalDate.parse(date).dayOfWeek.getDisplayName(TextStyle.SHORT, locale) }
.getOrDefault(date)
/**
* The days still ahead in the city itself, today included. A file made
* in another time zone, or read a day late, starts with days already gone.
* A zone this phone does not know shows the days as given (DESIGN 42).
*/
fun ahead(
city: TodayFile.City,
now: Instant = Instant.now(),
): List<TodayFile.Day> {
val zone = runCatching { ZoneId.of(city.zone) }.getOrNull() ?: return city.days
val today = now.atZone(zone).toLocalDate()
return city.days.filter { day -> runCatching { !LocalDate.parse(day.date).isBefore(today) }.getOrDefault(false) }
}
/** "+0.18" as the file writes it, shown as "+0.18%". No arithmetic. */
fun percent(pct: String): String = if (pct.isEmpty() || pct.endsWith("%")) pct else "$pct%"
/** The host a link goes to, shown beside it. */
fun host(url: String): String = runCatching { java.net.URI(url).host }.getOrNull()?.lowercase(Locale.ROOT) ?: url
/** What the foot of the page says about the file itself. */
fun sameForEveryone(relay: String): String =
"Everyone on $relay gets this same page. Fetching it sends nothing about you, " +
"and the cities you pick stay on this phone."
}
@@ -306,4 +306,6 @@
<string name="chat_forward_to">Reenviar a</string>
<string name="chat_forward_note">Esto envía solo el texto. Quien lo reciba ve que se reenvió, y lo ve como enviado por ti.</string>
<string name="chat_band_parental_controls">Control parental</string>
<string name="chat_forward_note_card">Esto envía la tarjeta sola y abierta, sin importar hasta qué día estaba sellada. Quien la reciba ve que se reenvió, y la ve como enviada por ti.</string>
<string name="chat_forward_note_event">Esto envía una copia del evento contigo como organizador. Las respuestas de hasta ahora se quedan aquí. Quien lo reciba ve que se reenvió.</string>
</resources>
@@ -139,4 +139,8 @@
<string name="main_identity_checking">Comprobando qué usa este servidor…</string>
<string name="main_identity_shared">Una sola cuenta de Flamenet, la misma en todos los servidores de Flamenet.</string>
<string name="main_identity_local">Este servidor tiene sus propias cuentas.</string>
<string name="main_today">Hoy</string>
<string name="main_files_card">Tarjeta</string>
<string name="main_files_event">Evento</string>
<string name="main_files_game">Juego</string>
</resources>
@@ -101,7 +101,7 @@
<string name="set_network_paste_hint">Pega la línea del puente que te dieron. Es una sola línea larga que empieza con obfs4; cópiala completa, incluida la parte después de cert=.</string>
<string name="set_network_explanation">Algunas redes bloquean Flamenet por completo, o lo reconocen y lo hacen más lento. Un puente es una máquina en otra parte con la que tu app se comunica en su lugar, de una forma que no se parece a nada en particular: ni a Flamenet, ni a una videollamada, ni a una página web. El puente pasa tu tráfico a tu servidor.\n\nHay dos cosas que conviene dejar claras. Tu servidor ve exactamente lo mismo que veía antes, así que esto no le oculta nada a quien lo administra. Y quien administra el puente puede ver que te conectas a él, aunque no lo que dices: tus mensajes siguen cifrados de extremo a extremo todo el camino, como siempre.\n\nSi Flamenet ya funciona donde estás, no necesitas esto.</string>
<string name="set_parental_title">Control parental</string>
<string name="set_parental_intro">Un padre o una madre pone un PIN en este teléfono. Mientras la supervisión está activada, agregar contactos pide el PIN, y se pueden desactivar las historias y las llamadas, todo aplicado en este dispositivo. Nada sobre la supervisión se envía a ningún servidor. Úsalo junto con Family Link, que controla el teléfono en sí.</string>
<string name="set_parental_intro">Un padre o una madre pone un PIN en este teléfono. Mientras la supervisión está activada, agregar contactos pide el PIN, se pueden desactivar las historias y las llamadas, y la pestaña Hoy y su directorio de sitios web quedan ocultos, todo aplicado en este dispositivo. Nada sobre la supervisión se envía a ningún servidor. Úsalo junto con Family Link, que controla el teléfono en sí.</string>
<string name="set_parental_pin_placeholder">PIN parental (al menos 4 dígitos)</string>
<string name="set_parental_repeat_pin">Repite el PIN</string>
<string name="set_parental_turn_on">Activar supervisión</string>
@@ -111,7 +111,7 @@
<string name="set_parental_gate_contacts">Pedir el PIN para agregar un contacto</string>
<string name="set_parental_gate_stories">Ocultar historias</string>
<string name="set_parental_gate_calls">Desactivar llamadas</string>
<string name="set_parental_calls_off_note">Con las llamadas desactivadas, este teléfono no hace llamadas ni suena, y quien llama recibe la señal de ocupado.</string>
<string name="set_parental_calls_off_note">Con las llamadas desactivadas, este teléfono no hace llamadas ni suena, y quien llama recibe la señal de ocupado. La pestaña Hoy y su directorio de sitios web siguen ocultos mientras la supervisión está activada.</string>
<string name="set_parental_turn_off">Desactivar supervisión</string>
<string name="set_calls_title">Llamadas</string>
<string name="set_calls_new_call">Llamada nueva</string>
@@ -169,4 +169,5 @@
<string name="social_unsealed_body">La clave de entrega de %1$s estaba desactualizada, así que este mensaje no se pudo enviar sellado. Está cifrado de todos modos; lo que cambió es que el servidor sabe que lo enviaste tú. Normalmente se arregla solo cuando te responde.</string>
<string name="social_address_changed_title">Tu dirección cambió</string>
<string name="social_address_changed_body">Este dispositivo antes era %1$s. Al iniciar sesión hace un momento salió una dirección distinta. Eso pasa si cambiaste tu frase de acceso. Si no la cambiaste, el servidor de cuentas le dio a este dispositivo una identidad distinta, y quienes tenían tu dirección anterior ya no pueden contactarte.</string>
<string name="social_address_qr_hello_desc">Tu enlace de saludo como código QR</string>
</resources>
+2
View File
@@ -297,4 +297,6 @@
<string name="chat_forward_to">Forward to</string>
<string name="chat_forward_note">This sends the words on their own. Whoever receives it sees that it was forwarded, and sees it from you.</string>
<string name="chat_band_parental_controls">Parental controls</string>
<string name="chat_forward_note_card">This sends the card on its own, open, whatever day it was sealed until. Whoever receives it sees that it was forwarded, and sees it from you.</string>
<string name="chat_forward_note_event">This sends a copy of the event with you as its organizer. The answers so far stay here. Whoever receives it sees that it was forwarded.</string>
</resources>
+4
View File
@@ -151,4 +151,8 @@
<string name="main_identity_checking">Checking what this server uses…</string>
<string name="main_identity_shared">One Flamenet account, the same one on every Flamenet relay.</string>
<string name="main_identity_local">This server keeps its own accounts.</string>
<string name="main_today">Today</string>
<string name="main_files_card">Card</string>
<string name="main_files_event">Event</string>
<string name="main_files_game">Game</string>
</resources>
+2 -2
View File
@@ -112,7 +112,7 @@
<!-- Settings > Parental controls (ParentalControlsSection.kt) -->
<string name="set_parental_title">Parental controls</string>
<string name="set_parental_intro">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.</string>
<string name="set_parental_intro">A parent sets a PIN on this phone. While supervision is on, adding contacts asks for the PIN, stories and calls can be switched off, and the Today tab and its directory of websites are hidden, all enforced on this device. Nothing about supervision is sent to any server. Use it alongside Family Link, which governs the phone itself.</string>
<string name="set_parental_pin_placeholder">Parent PIN (at least 4 digits)</string>
<string name="set_parental_repeat_pin">Repeat PIN</string>
<string name="set_parental_turn_on">Turn on supervision</string>
@@ -122,7 +122,7 @@
<string name="set_parental_gate_contacts">Ask the PIN to add a contact</string>
<string name="set_parental_gate_stories">Hide stories</string>
<string name="set_parental_gate_calls">Turn off calls</string>
<string name="set_parental_calls_off_note">With calls off, this phone neither places calls nor rings, a caller is answered busy.</string>
<string name="set_parental_calls_off_note">With calls off, this phone neither places calls nor rings, a caller is answered busy. The Today tab and its directory of websites stay hidden while supervision is on.</string>
<string name="set_parental_turn_off">Turn off supervision</string>
<!-- Home > Calls (CallsScreen.kt) -->
@@ -197,4 +197,5 @@
<string name="social_address_changed_title">Your address changed</string>
<!-- %1$s is the old address, fnid@relay. -->
<string name="social_address_changed_body">This device used to be %1$s. Signing in just now produced a different address. That happens if you changed your passphrase. If you did not, the account server has given this device a different identity, and people who had your old address can no longer reach you.</string>
<string name="social_address_qr_hello_desc">Your hello link as a QR code</string>
</resources>
@@ -0,0 +1,9 @@
package io.flamenet.messenger.app
/**
* A release build trusts no dev key. The debug source set carries the one
* used for testing.
*/
internal object TodayDevKey {
val KEYS: List<String> = emptyList()
}
@@ -0,0 +1,63 @@
package io.flamenet.messenger.app
import io.flamenet.e2e.Vault
import io.flamenet.games.GameType
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* A snoozed chat holds its notifications, an ordinary message's as well as a
* game's (owner, 0.32), and what arrives still counts as unread.
*/
class ASnoozedChatHoldsItsNotificationsTest {
private val sam = Vault.threadForUser(2)
private val ada = Vault.threadForUser(3)
private val club = Vault.threadForGroup("g1")
private fun due(
touched: Set<String>,
notice: Session.Notice? = null,
gameNews: Session.Notice? = null,
snoozed: Set<String>,
) = NotificationSettings.due(touched, notice, gameNews, snoozed)
@Test
fun `a message in a snoozed chat says nothing`() {
assertNull(due(setOf(sam), snoozed = setOf(sam)))
assertNull(due(setOf(club), snoozed = setOf(club)))
assertNull(due(setOf(sam), notice = Session.Notice.Card(2, null), snoozed = setOf(sam)))
assertNull(due(setOf(sam, club), snoozed = setOf(sam, club)))
}
@Test
fun `the rest of the poll is still said`() {
assertEquals(NotificationSettings.Due(1, null), due(setOf(sam, ada), snoozed = setOf(sam)))
assertEquals(NotificationSettings.Due(2, null), due(setOf(sam, ada), snoozed = emptySet()))
val card = Session.Notice.Card(3, null)
assertEquals(NotificationSettings.Due(1, card), due(setOf(ada), notice = card, snoozed = setOf(sam)))
}
@Test
fun `a game's news waits for the snooze too, and is said when nothing else is`() {
val move = Session.Notice.GameTurn(2, GameType.CHESS)
assertNull(due(emptySet(), gameNews = move, snoozed = setOf(sam)))
assertEquals(NotificationSettings.Due(1, move), due(emptySet(), gameNews = move, snoozed = setOf(ada)))
// A message from somebody else takes its place.
assertEquals(NotificationSettings.Due(1, null), due(setOf(ada), gameNews = move, snoozed = emptySet()))
// Unless that chat is snoozed.
assertEquals(NotificationSettings.Due(1, move), due(setOf(ada), gameNews = move, snoozed = setOf(ada)))
assertNull(due(emptySet(), snoozed = emptySet()))
}
@Test
fun `the service asks before it posts, for every kind`() {
val service = File("src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt").readText()
assertTrue(service.contains("NotificationSettings.due(touched, notice, gameNews, snoozed)"), "the service posts without asking")
assertTrue(service.contains("s.snoozedThreads()"))
assertFalse(service.contains("gameNotice"), "only a game's news is held again")
}
}
@@ -0,0 +1,32 @@
package io.flamenet.messenger.app
import java.io.File
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* With the relay unreachable at launch, a stored account opens to its chats
* and the saved Today page from what is on the phone, and connects in the
* background (DESIGN 47). Sign-in shows only with no stored credentials.
*/
class AStoredAccountOpensOfflineTest {
private val vm = File("src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt").readText()
private val restore = vm.substringAfter("private fun restore() =").substringBefore("\n /**")
@Test
fun `the chats open before the relay is asked anything`() {
val shown = restore.indexOf("_phase.value = restored.second")
assertTrue(shown > 0, "the restore no longer sets the phase where this test looks")
val before = restore.substring(0, shown)
assertFalse(before.contains(".roster()"), "the chats wait on the relay for the contact list")
assertFalse(before.contains(".namedGroups()"), "the chats wait on the relay for the groups")
}
@Test
fun `only missing credentials mean signed out`() {
assertTrue(restore.contains("if (stored == null || credential == null)"))
assertTrue(restore.contains("restored.second ?: Phase.SignedOut()"))
}
}
@@ -0,0 +1,63 @@
package io.flamenet.messenger.app
import android.content.Intent
import android.provider.CalendarContract
import io.flamenet.e2e.Content
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* "Add to calendar" hands the event to the system's own add-event sheet
* (§6.32) and asks for nothing: no calendar permission, nothing read back.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class AddToCalendarTest {
private val picnic =
Content.Event(
id = "e",
rev = 1,
title = "Picnic",
start = 1_767_312_000,
end = 1_767_319_200,
tz = "America/New_York",
place = "Riverside park",
note = "Bring a blanket",
)
@Test
fun `a timed event is offered as an insert, with its times, place and note`() {
val intent = EventCalendar.intent(picnic)!!
assertEquals(Intent.ACTION_INSERT, intent.action)
assertEquals(CalendarContract.Events.CONTENT_URI, intent.data)
assertEquals("Picnic", intent.getStringExtra(CalendarContract.Events.TITLE))
assertEquals(1_767_312_000_000L, intent.getLongExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, 0))
assertEquals(1_767_319_200_000L, intent.getLongExtra(CalendarContract.EXTRA_EVENT_END_TIME, 0))
assertFalse(intent.getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, true))
assertEquals("America/New_York", intent.getStringExtra(CalendarContract.Events.EVENT_TIMEZONE))
assertEquals("Riverside park", intent.getStringExtra(CalendarContract.Events.EVENT_LOCATION))
assertEquals("Bring a blanket", intent.getStringExtra(CalendarContract.Events.DESCRIPTION))
}
@Test
fun `an all-day event is offered as whole days`() {
val intent = EventCalendar.intent(Content.Event(id = "e", rev = 1, title = "Fair", day = "2026-07-04"))!!
assertTrue(intent.getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false))
assertFalse(intent.hasExtra(CalendarContract.Events.EVENT_TIMEZONE))
assertNull(intent.getStringExtra(CalendarContract.Events.EVENT_LOCATION))
}
@Test
fun `the app asks for no calendar permission`() {
val manifest = File("src/main/AndroidManifest.xml").readText()
assertFalse("READ_CALENDAR" in manifest, "the app could read the calendar")
assertFalse("WRITE_CALENDAR" in manifest, "the app could write the calendar without the person")
}
}
@@ -0,0 +1,122 @@
package io.flamenet.messenger.app
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import io.flamenet.e2e.ChatEvents
import io.flamenet.e2e.Content
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import kotlin.test.assertEquals
/**
* What the screen shows for a card and an event (§6.31 and §6.32): a sealed
* card's front and the day it opens and none of its words, and an event's
* answers with the line saying everyone in the chat sees them.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w375dp-h812dp-normal-long-notround-any-440dpi-keyshidden-nonav")
class CardAndEventScreensTest {
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
private fun show(content: @Composable () -> Unit) =
compose.setContent {
MessengerTheme {
Surface(modifier = Modifier.fillMaxSize()) { Column { content() } }
}
}
private val now = System.currentTimeMillis()
private val sealed =
Content.Card(design = "birthday", msg = "the surprise is a puppy", to = "Alice", from = "Sam", open = now / 1000 + 3 * 86_400)
@Test
fun `a sealed card shows its front and the day it opens, and none of its words`() {
var told: String? = null
var opened = false
val line = Session.Line(text = "", mine = false, at = now, id = "m-1", card = sealed)
show { CardBubble(line, sealed, forName = "Alice", onOpen = { opened = true }, onSealed = { told = it }, onLongPress = {}) }
compose.onNodeWithText("Happy Birthday", useUnmergedTree = true).assertExists()
compose.onNodeWithText("Opens on", substring = true, useUnmergedTree = true).assertExists()
compose.onNodeWithText("puppy", substring = true, useUnmergedTree = true).assertDoesNotExist()
compose.onNodeWithText("puppy", substring = true).assertDoesNotExist()
compose.onNodeWithTag("card-m-1").performClick()
assertEquals(CardArt.day(sealed.opensAtMillis(now)!!), told, "a tap on a sealed card should say when it opens")
assertEquals(false, opened, "a sealed card opened on a tap")
}
@Test
fun `the sender's own copy says whom it is sealed for, and opens`() {
var opened = false
val line = Session.Line(text = "", mine = true, at = now, id = "m-2", card = sealed)
show { CardBubble(line, sealed, forName = "Alice", onOpen = { opened = true }, onSealed = {}, onLongPress = {}) }
compose.onNodeWithText("Sealed until", substring = true, useUnmergedTree = true).assertExists()
compose.onNodeWithText("for Alice", substring = true, useUnmergedTree = true).assertExists()
compose.onNodeWithTag("card-m-2").performClick()
assertEquals(true, opened)
}
@Test
fun `an open card shows its words inside`() {
show { CardInsideDialog(sealed.copy(open = null)) {} }
compose.onNodeWithText("Dear Alice,", useUnmergedTree = true).assertExists()
compose.onNodeWithText("the surprise is a puppy", useUnmergedTree = true).assertExists()
compose.onNodeWithText("From Sam", useUnmergedTree = true).assertExists()
}
private val picnic =
Content.Event(id = "e-1", rev = 1, title = "Picnic", start = now / 1000 + 86_400, place = "Riverside park")
@Test
fun `an event says who sees an answer and takes one`() {
var answered: String? = null
val state = ChatEvents.State(picnic, organizer = 2, answers = mapOf(2L to ChatEvents.Answer("yes", 1)))
val line = Session.Line(text = "", mine = false, at = now, id = "m-3", event = state)
show {
EventBubble(
line,
state,
myUserId = 1,
nameOf = { if (it == 2L) "Sam" else "user $it" },
onAnswer = { answered = it },
onAddToCalendar = {},
onLongPress = {},
)
}
compose.onNodeWithText("Everyone in this chat sees your answer.", useUnmergedTree = true).assertExists()
compose.onNodeWithText("Organized by Sam", useUnmergedTree = true).assertExists()
compose.onNodeWithText("Going 1: Sam", useUnmergedTree = true).assertExists()
compose.onNodeWithText("Riverside park", useUnmergedTree = true).assertExists()
compose.onNodeWithTag("event-add-to-calendar").assertExists()
compose.onNodeWithTag("rsvp-maybe").performClick()
assertEquals("maybe", answered)
}
@Test
fun `a cancelled event offers no answers and no calendar`() {
val state = ChatEvents.State(picnic.copy(rev = 2, cancelled = true), organizer = 2)
val line = Session.Line(text = "", mine = false, at = now, id = "m-4", event = state)
show { EventBubble(line, state, myUserId = 1, nameOf = { "Sam" }, onAnswer = {}, onAddToCalendar = {}, onLongPress = {}) }
compose.onNodeWithTag("event-cancelled", useUnmergedTree = true).assertExists()
compose.onNodeWithText("Cancelled: Picnic", useUnmergedTree = true).assertExists()
compose.onNodeWithTag("rsvp-yes").assertDoesNotExist()
compose.onNodeWithTag("event-add-to-calendar").assertDoesNotExist()
}
}
@@ -0,0 +1,224 @@
package io.flamenet.messenger.app
import io.flamenet.e2e.ChatEvents
import io.flamenet.e2e.Content
import java.time.ZoneId
import java.util.Locale
import java.util.TimeZone
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* What a card or an event says, on the card, in a quote, and on a lock screen
* (§6.31 and §6.32), with the clock, zone and locale passed in.
*/
class CardAndEventWordsTest {
/** The narrow spaces some locales put before AM and PM, made plain for comparing. */
private fun plain(s: String) = s.replace('\u202F', ' ').replace('\u00A0', ' ')
private val day = 86_400_000L
private val utc = ZoneId.of("UTC")
private val newYork = ZoneId.of("America/New_York")
private val london = ZoneId.of("Europe/London")
// -- Cards -----------------------------------------------------------------.
@Test
fun `the fronts carry the site's own titles`() {
val site =
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",
)
assertEquals(site.keys.toList(), Content.Card.DESIGNS, "the nine designs, in the site's order")
site.forEach { (d, title) ->
assertEquals(title, CardArt.title(d))
assertEquals("cards/$d.png", CardArt.asset(d))
}
assertEquals("Hello!", CardArt.title("anniversary"))
assertEquals("cards/hello.png", CardArt.asset("anniversary"))
}
@Test
fun `a card's summary names the front, never the words`() {
val card = Content.Card(design = "birthday", msg = "the surprise is a puppy", to = "Sam")
assertEquals("Card: Happy Birthday", CardArt.summary(card))
val line = Session.Line(text = "", mine = false, at = 0L, card = card)
assertEquals("Card: Happy Birthday", line.summary)
}
@Test
fun `the seal is described from each side`() {
assertEquals("Opens on May 1, 2026", CardArt.sealedLine(mine = false, opensOn = "May 1, 2026", forName = "Sam"))
assertEquals(
"Sealed until May 1, 2026 for Sam",
CardArt.sealedLine(mine = true, opensOn = "May 1, 2026", forName = "Sam"),
)
assertTrue("not a lock" in CardArt.SEALED_HONESTY, "the copy claims more than a courtesy")
}
@Test
fun `a date reads as the reader's calendar writes it`() {
assertEquals("January 1, 2026", CardArt.day(1_767_225_600_000L, TimeZone.getTimeZone("UTC"), Locale.US))
// The same moment is still December 31 in New York.
assertEquals("December 31, 2025", CardArt.day(1_767_225_600_000L, TimeZone.getTimeZone("America/New_York"), Locale.US))
}
// -- Copy, forward, report -------------------------------------------------.
private val arrived = 1_700_000_000_000L
private val sealedFor3Days = Content.Card(design = "love", msg = "inside", to = "Sam", from = "Pat", open = (arrived + 3 * day) / 1000)
@Test
fun `a card sealed here gives nothing to copy, report or forward`() {
val received = Session.Line(text = "", mine = false, at = arrived, card = sealedFor3Days)
assertEquals("", copyableWords(received, nowMillis = arrived + day))
assertFalse(canForward(received, nowMillis = arrived + day))
// Once open, all three.
assertEquals("Sam\ninside\nPat", copyableWords(received, nowMillis = arrived + 3 * day))
assertTrue(canForward(received, nowMillis = arrived + 3 * day))
}
@Test
fun `the sender's own copy is open to them all along`() {
val sent = Session.Line(text = "", mine = true, at = arrived, card = sealedFor3Days)
assertEquals("Sam\ninside\nPat", copyableWords(sent, nowMillis = arrived))
assertTrue(canForward(sent, nowMillis = arrived))
}
@Test
fun `a report of a sealed card carries what is on the front, not the words`() {
val received = Session.Line(text = "", mine = false, at = System.currentTimeMillis(), card = sealedFor3Days.copy(open = Long.MAX_VALUE))
assertEquals("Card: With Love", reportableWords(received))
}
@Test
fun `a cancelled event is not offered for forwarding`() {
val ev = Content.Event(id = "e", rev = 2, title = "Picnic", start = 1, cancelled = true)
val line = Session.Line(text = "", mine = false, at = 0, event = ChatEvents.State(ev, organizer = 2))
assertFalse(canForward(line))
assertEquals("Cancelled: Picnic", line.summary)
assertTrue(canForward(line.copy(event = line.event!!.copy(event = ev.copy(cancelled = false)))))
}
// -- Event times -------------------------------------------------------------.
private val picnic =
Content.Event(id = "e", rev = 1, title = "Picnic", start = 1_767_312_000, end = 1_767_319_200, tz = "America/New_York")
@Test
fun `an event reads in the reader's own zone`() {
// 2026-01-01 19:00 to 21:00 in New York.
assertEquals(
"Thursday, January 1, 2026, 7:00 PM to 9:00 PM",
plain(EventText.whenLine(picnic, newYork, Locale.US)),
)
// The same event read in London starts after midnight.
assertEquals(
"Friday, January 2, 2026, 12:00 AM to 2:00 AM",
plain(EventText.whenLine(picnic, london, Locale.US)),
)
}
@Test
fun `an event over midnight names both days`() {
val late = picnic.copy(end = picnic.start!! + 8 * 3600)
assertEquals(
"Thursday, January 1, 2026, 7:00 PM to Friday, January 2, 2026, 3:00 AM",
plain(EventText.whenLine(late, newYork, Locale.US)),
)
}
@Test
fun `the organizer's time is shown only when their clock reads differently`() {
assertEquals("Thu 7:00 PM New York time", EventText.organizerLine(picnic, london, Locale.US)?.let(::plain))
assertNull(EventText.organizerLine(picnic, newYork, Locale.US), "the reader's own zone repeated")
// A different zone on the same clock is not worth a line.
assertNull(EventText.organizerLine(picnic, ZoneId.of("America/Toronto"), Locale.US))
assertEquals(
"4:00 PM Los Angeles time",
EventText.organizerLine(picnic.copy(tz = "America/Los_Angeles"), newYork, Locale.US)?.let(::plain),
)
assertNull(EventText.organizerLine(picnic.copy(tz = null), london, Locale.US))
assertNull(EventText.organizerLine(picnic.copy(tz = "Mars/Olympus_Mons"), london, Locale.US))
assertEquals("Buenos Aires", EventText.city("America/Argentina/Buenos_Aires"))
}
@Test
fun `an all-day event reads as days, wherever it is read`() {
val fair = Content.Event(id = "e", rev = 1, title = "Fair", day = "2026-07-04")
assertEquals("Saturday, July 4, 2026, all day", EventText.whenLine(fair, newYork, Locale.US))
assertEquals("Saturday, July 4, 2026, all day", EventText.whenLine(fair, ZoneId.of("Asia/Tokyo"), Locale.US))
assertEquals(
"Saturday, July 4, 2026 to Sunday, July 5, 2026, all day",
EventText.whenLine(fair.copy(days = 2), newYork, Locale.US),
)
}
@Test
fun `the calendar is handed the moment, the end and the zone`() {
val span = EventText.calendarSpan(picnic)!!
assertEquals(1_767_312_000_000L, span.beginMillis)
assertEquals(1_767_319_200_000L, span.endMillis)
assertFalse(span.allDay)
assertEquals("America/New_York", span.zone)
assertNull(EventText.calendarSpan(picnic.copy(tz = "Mars/Olympus_Mons"))!!.zone, "a zone the calendar cannot read was passed on")
}
@Test
fun `an all-day event goes to the calendar as whole days in UTC`() {
val span = EventText.calendarSpan(Content.Event(id = "e", rev = 1, title = "Fair", day = "2026-07-04", days = 2))!!
assertTrue(span.allDay)
assertEquals(java.time.LocalDate.of(2026, 7, 4).atStartOfDay(utc).toInstant().toEpochMilli(), span.beginMillis)
assertEquals(java.time.LocalDate.of(2026, 7, 6).atStartOfDay(utc).toInstant().toEpochMilli(), span.endMillis)
assertNull(span.zone)
}
// -- The lock screen -----------------------------------------------------------.
@Test
fun `with previews off a card or an event is just a new message`() {
for (notice in listOf(
Session.Notice.Card(2, opensAtMillis = 5),
Session.Notice.Card(2, opensAtMillis = null),
Session.Notice.Invite(2, "Picnic"),
Session.Notice.Changed(2, "Picnic", cancelled = false),
)) {
assertEquals("New message", NotificationSettings.noticeText(false, notice, "Sam", "May 1, 2026"))
}
}
@Test
fun `with previews on each says what it is and who sent it`() {
assertEquals(
"Sam sent you a card to open on May 1, 2026",
NotificationSettings.noticeText(true, Session.Notice.Card(2, 5), "Sam", "May 1, 2026"),
)
assertEquals("Sam sent you a card", NotificationSettings.noticeText(true, Session.Notice.Card(2, null), "Sam", null))
assertEquals("Sam invited you: Picnic", NotificationSettings.noticeText(true, Session.Notice.Invite(2, "Picnic"), "Sam", null))
assertEquals(
"Picnic changed",
NotificationSettings.noticeText(true, Session.Notice.Changed(2, "Picnic", cancelled = false), "Sam", null),
)
assertEquals(
"Picnic was cancelled",
NotificationSettings.noticeText(true, Session.Notice.Changed(2, "Picnic", cancelled = true), "Sam", null),
)
}
@Test
fun `with nobody to name the line stays plain`() {
assertEquals("New message", NotificationSettings.noticeText(true, Session.Notice.Card(2, null), " ", null))
assertEquals("New message", NotificationSettings.noticeText(true, Session.Notice.Invite(2, "Picnic"), null, null))
}
}
@@ -0,0 +1,558 @@
package io.flamenet.messenger.app
import io.flamenet.client.Conversations
import io.flamenet.client.MessengerApi
import io.flamenet.client.RelayClient
import io.flamenet.e2e.BackupPayload
import io.flamenet.e2e.ChatEvents
import io.flamenet.e2e.Content
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.E2ESession
import io.flamenet.e2e.Envelope
import io.flamenet.e2e.Vault
import io.flamenet.e2e.X25519
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.json.JSONObject
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.Base64
import javax.crypto.KeyGenerator
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Cards, events and answers through a real Session (§6.31 and §6.32).
*
* Sends go through the real ratchet to a relay double, and what was posted is
* opened again with the receiving identity, so the checks are on the bytes a
* member's phone would read: who they went to, whether they ring, and what
* they say. Arrivals go through the same routing a poll uses.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class CardsAndEventsInASessionTest {
/** A vault on a map, and a way to see whether anything at all was written. */
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
/** Every record and its sealed bytes, which change on any rewrite. */
fun snapshot(): Map<String, String> = records.mapValues { Base64.getEncoder().encodeToString(it.value) }
}
private val me = 1L
private val sam = 2L
private val pat = 3L
private val stranger = 9L
private val gid = "g-1"
private val groupThread = Vault.threadForGroup(gid)
private val samThread = Vault.threadForUser(sam)
/** Every other member's one device, so whatever is posted can be opened. */
private val bob = DeviceIdentity.create()
private lateinit var server: MockWebServer
private lateinit var storage: MapStorage
private lateinit var vault: Vault
private lateinit var identity: DeviceIdentity
private val posts = java.util.Collections.synchronizedList(mutableListOf<JSONObject>())
@BeforeTest fun start() {
server = MockWebServer().also { it.dispatcher = relay() }.also { it.start() }
}
@AfterTest fun stop() = server.shutdown()
private fun json(body: String) = MockResponse().setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
private fun bobWire(): String =
JSONObject()
.put("device_id", bob.deviceId)
.put("ik_dh", Base64.getEncoder().encodeToString(bob.identityDHPublic))
.put("ik_sig", Base64.getEncoder().encodeToString(bob.identitySigningPublic))
.put("spk_id", bob.signedPreKeyId)
.put("spk", Base64.getEncoder().encodeToString(X25519.rawFromPublicKey(bob.signedPreKey.public)))
.put("spk_sig", Base64.getEncoder().encodeToString(bob.signedPreKeySignature()))
.put("proto", 1)
.toString()
private fun relay() =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path.orEmpty()
return when {
"/e2e/log/" in path -> {
MockResponse().setResponseCode(404)
}
"/e2e/groups" in path -> {
json(
"""{"groups":[{"gid":"$gid","name":"Saturday","owner":1,""" +
""""members":[{"user_id":1},{"user_id":2},{"user_id":3}]}]}""",
)
}
"/e2e/messages" in path && request.method == "POST" -> {
posts += JSONObject(request.body.readUtf8())
json("""{"ok":true}""")
}
else -> {
json("""{"devices":[${bobWire()}]}""")
}
}
}
}
private fun group() = RelayClient.Group(gid, "Saturday", me, listOf(me, sam, pat))
private fun session(offTheRecordWith: Long? = null): Session {
identity = DeviceIdentity.create()
storage = MapStorage()
vault = Vault(storage, KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
vault.saveIdentity(identity)
// What a member's phone holds after the group was announced (§6.7).
vault.saveGroupName(gid, "Saturday")
offTheRecordWith?.let { vault.saveOffTheRecord(Vault.threadForUser(it), true) }
return sessionOn(vault)
}
private fun sessionOn(v: Vault): Session {
val base = server.url("/").toString()
return Session("alice", me, identity, v, RelayClient(base, "alice", "app pass word"), MessengerApi(base, "alice", "app pass word"))
}
private var nextId = 1L
private fun from(
user: Long,
wire: String,
) = Conversations.Received(
id = nextId++,
fromUser = user,
fromDevice = "device-$user",
content = Content.parse(wire),
senderProven = true,
at = System.currentTimeMillis(),
)
// -- What a member's phone reads -----------------------------------------.
private class Posted(
val to: Long,
val silent: Boolean,
val content: Content,
)
private var bobSession: E2ESession? = null
private var opened = 0
/** Everything posted since the last call, opened as the member's device opens it. */
private fun drain(): List<Posted> {
val out = mutableListOf<Posted>()
while (opened < posts.size) {
val body = posts[opened++]
val messages = body.getJSONArray("messages")
for (i in 0 until messages.length()) {
val envelope = Envelope.fromPayload(messages.getJSONObject(i).getString("payload"))
val session = bobSession ?: E2ESession.respond(bob, envelope).also { bobSession = it }
out += Posted(body.getLong("to"), body.optBoolean("silent", false), Content.parse(session.decrypt(envelope)))
}
}
return out
}
private fun picnic(
id: String = "e-1",
rev: Int = 1,
title: String = "Picnic",
cancelled: Boolean = false,
) = Content.Event(
id = id,
rev = rev,
title = title,
start = 1_767_225_600,
end = 1_767_232_800,
tz = "America/New_York",
place = "Riverside park",
cancelled = cancelled,
)
private val birthday = Content.Card(design = "birthday", msg = "Have a lovely day.", to = "Alice", from = "Sam")
// -- Sending ---------------------------------------------------------------.
@Test
fun `an event to a group reaches every member but me, and is news`() {
val s = session()
s.sendEvent(null, group(), picnic())
val out = drain()
assertEquals(listOf(sam, pat), out.map { it.to }.sorted(), "fan-out missed somebody, or reached me")
for (posted in out) {
assertEquals(Content.Kind.EVENT, posted.content.kind)
assertEquals(gid, posted.content.groupId)
assertEquals("Saturday", posted.content.raw!!.optString("gname"))
assertFalse(posted.silent, "an invitation should reach the person like a message")
}
val line = s.history(groupThread).single()
assertTrue(line.mine)
assertEquals(me, line.event!!.organizer)
assertEquals(out.first().content.messageId, line.id, "the card here and on the wire have different ids")
}
@Test
fun `an answer goes to every member but me, as silent control traffic, counting up`() {
val s = session()
s.sendEvent(null, group(), picnic())
drain()
val now = 1_800_000_000L
assertTrue(s.answerEvent(groupThread, "e-1", Content.Rsvp.YES, null, group(), nowSeconds = now))
val first = drain()
assertEquals(listOf(sam, pat), first.map { it.to }.sorted())
for (posted in first) {
assertEquals(Content.Kind.RSVP, posted.content.kind)
assertTrue(posted.content.isControl, "an answer without ctl would draw a bubble on an older build")
assertFalse(posted.content.isRenderable)
assertEquals(Content.Rsvp("e-1", "yes", now), posted.content.rsvp, "an answer is counted by the second it was given")
assertEquals(gid, posted.content.groupId)
assertFalse(posted.content.raw!!.has("gname"), "an answer carries no group name")
assertTrue(posted.silent, "an answer must not ring anybody's phone")
}
// A second answer in the same second still counts past the first.
assertTrue(s.answerEvent(groupThread, "e-1", Content.Rsvp.NO, null, group(), nowSeconds = now))
assertTrue(drain().all { it.content.rsvp == Content.Rsvp("e-1", "no", now + 1) })
assertEquals(ChatEvents.Answer("no", now + 1), s.eventState(groupThread, "e-1")!!.answers[me])
}
@Test
fun `a revision goes out under a fresh mid and folds into the same card`() {
val s = session()
s.sendEvent(sam, null, picnic())
val invite = drain().single()
assertTrue(s.reviseEvent(samThread, "e-1", sam, null) { it.copy(title = "Barbecue") })
val change = drain().single()
assertNotEquals(invite.content.messageId, change.content.messageId, "a revision reused the first mid")
assertEquals(2, change.content.event!!.rev)
assertEquals("Barbecue", change.content.event!!.title)
assertTrue(s.reviseEvent(samThread, "e-1", sam, null) { it.copy(cancelled = true) })
val cancel = drain().single().content.event!!
assertEquals(3, cancel.rev)
assertTrue(cancel.cancelled)
assertEquals("Barbecue", cancel.title, "a cancel carries the whole event")
val line = s.history(samThread).single()
assertEquals(invite.content.messageId, line.id, "the card lost the mid replies and reactions point at")
assertTrue(line.event!!.event.cancelled)
}
@Test
fun `nobody but the organizer can change an event, here or from elsewhere`() {
val s = session()
s.absorbForTesting(listOf(from(sam, Content.event(picnic(id = "e-s"), "m-1"))))
assertFalse(s.reviseEvent(samThread, "e-s", sam, null) { it.copy(title = "Mine now") })
assertEquals(0, posts.size, "a change to somebody else's event was sent")
s.absorbForTesting(listOf(from(sam, Content.event(picnic(id = "e-g"), "m-g", gid, "Saturday"))))
val hijack = s.absorbForTesting(listOf(from(pat, Content.event(picnic(id = "e-g", rev = 5, title = "Moved"), "m-x", gid))))
assertTrue(hijack.isEmpty())
assertEquals("Picnic", s.eventState(groupThread, "e-g")!!.event.title)
s.absorbForTesting(listOf(from(sam, Content.event(picnic(id = "e-g", rev = 2, cancelled = true), "m-y", gid))))
assertTrue(s.eventState(groupThread, "e-g")!!.event.cancelled, "the organizer's cancel did not land")
assertEquals(1, s.history(groupThread).size, "a revision drew a second card")
}
@Test
fun `a stale or repeated revision changes nothing and is not news`() {
val s = session()
s.absorbForTesting(listOf(from(sam, Content.event(picnic(rev = 3, title = "Third"), "m-3"))))
s.takeNotice()
val older = s.absorbForTesting(listOf(from(sam, Content.event(picnic(rev = 2, title = "Second"), "m-2"))))
val again = s.absorbForTesting(listOf(from(sam, Content.event(picnic(rev = 3, title = "Third"), "m-3b"))))
assertTrue(older.isEmpty() && again.isEmpty())
assertNull(s.takeNotice())
assertEquals("Third", s.history(samThread).single().event!!.event.title)
}
// -- Answers ---------------------------------------------------------------.
@Test
fun `answers fold onto the card, the highest count wins, and they never notify`() {
val s = session()
s.absorbForTesting(listOf(from(sam, Content.event(picnic(), "m-1", gid, "Saturday"))))
s.takeNotice()
s.takeQuietlyChanged()
val touched =
s.absorbForTesting(
listOf(
from(pat, Content.rsvp(Content.Rsvp("e-1", "yes", 2), gid)),
from(pat, Content.rsvp(Content.Rsvp("e-1", "no", 1), gid)),
from(sam, Content.rsvp(Content.Rsvp("e-1", "maybe", 1), gid)),
),
)
assertTrue(touched.isEmpty(), "an answer marked the chat unread, which is what notifies")
assertNull(s.takeNotice(), "an answer had words for the lock screen")
assertEquals(setOf(groupThread), s.takeQuietlyChanged(), "the card was not redrawn")
val state = s.eventState(groupThread, "e-1")!!
assertEquals(ChatEvents.Answer("yes", 2), state.answers[pat], "an older answer arriving late won")
assertEquals(listOf(sam), state.who("maybe"))
}
@Test
fun `an answer that arrives before its event is applied when the event lands`() {
val s = session()
s.absorbForTesting(listOf(from(pat, Content.rsvp(Content.Rsvp("e-1", "yes", 1), gid))))
assertNull(s.eventState(groupThread, "e-1"))
s.absorbForTesting(listOf(from(sam, Content.event(picnic(), "m-1", gid, "Saturday"))))
assertEquals(listOf(pat), s.eventState(groupThread, "e-1")!!.who("yes"))
}
@Test
fun `nothing from somebody outside the group is taken`() {
val s = session()
s.absorbForTesting(listOf(from(sam, Content.event(picnic(), "m-1", gid, "Saturday"))))
s.absorbForTesting(
listOf(
from(stranger, Content.rsvp(Content.Rsvp("e-1", "yes", 1), gid)),
from(stranger, Content.event(picnic(id = "e-x"), "m-x", gid)),
from(stranger, Content.card(birthday, "m-c", gid)),
),
)
assertEquals(emptyMap(), s.eventState(groupThread, "e-1")!!.answers)
assertEquals(1, s.history(groupThread).size)
}
// -- Placement -------------------------------------------------------------.
@Test
fun `my own card from another device is mine in a group and lands nowhere one-to-one`() {
val s = session()
val oneToOne = s.absorbForTesting(listOf(from(me, Content.card(birthday, "m-own"))))
assertTrue(oneToOne.isEmpty())
assertTrue(s.history(Vault.threadForUser(me)).isEmpty(), "with no sent copies it has no conversation to land in")
val inGroup = s.absorbForTesting(listOf(from(me, Content.card(birthday, "m-own-g", gid, "Saturday"))))
assertTrue(inGroup.isEmpty(), "my own card marked my own chat unread")
assertNull(s.takeNotice())
assertEquals(setOf(groupThread), s.takeQuietlyChanged(), "my own card was not redrawn")
val line = s.history(groupThread).single()
assertTrue(line.mine, "my own card in the group was drawn as somebody else's")
assertEquals(birthday, line.card)
}
@Test
fun `a card or an event that carries ctl is consumed, never drawn`() {
val s = session()
val touched =
s.absorbForTesting(
listOf(
from(sam, """{"fnc":1,"kind":"card","ctl":1,"mid":"m","card":{"d":"love","msg":"x"}}"""),
from(sam, """{"fnc":1,"kind":"event","ctl":true,"mid":"m2","ev":{"id":"e","rev":1,"title":"T","start":1}}"""),
),
)
assertTrue(touched.isEmpty())
assertTrue(s.history(samThread).isEmpty())
}
// -- What the lock screen says -----------------------------------------------.
@Test
fun `a sealed card names the day it opens and never its words`() {
val s = session()
val opens = System.currentTimeMillis() / 1000 + 3 * 86_400
s.absorbForTesting(listOf(from(sam, Content.card(birthday.copy(msg = "the surprise is a puppy", open = opens), "m-c"))))
val notice = s.takeNotice() as Session.Notice.Card
assertEquals(sam, notice.from)
assertNotNull(notice.opensAtMillis)
val text = NotificationSettings.noticeText(true, notice, "Sam", CardArt.day(notice.opensAtMillis!!))
assertTrue(text.startsWith("Sam sent you a card to open on "), text)
assertFalse("puppy" in text)
assertEquals("New message", NotificationSettings.noticeText(false, notice, "Sam", "May 1"))
val line = s.history(samThread).single()
assertEquals("", copyableWords(line), "Copy would hand over a sealed card's words")
assertFalse(canForward(line), "a sealed card was offered for forwarding")
assertEquals("Card: Happy Birthday", line.summary)
}
@Test
fun `an open card notifies without a date`() {
val s = session()
s.absorbForTesting(listOf(from(sam, Content.card(birthday, "m-c"))))
val notice = s.takeNotice() as Session.Notice.Card
assertNull(notice.opensAtMillis)
assertEquals("Sam sent you a card", NotificationSettings.noticeText(true, notice, "Sam", null))
}
@Test
fun `an invitation and a change notify in their own words`() {
val s = session()
s.absorbForTesting(listOf(from(sam, Content.event(picnic(), "m-1"))))
val invite = s.takeNotice()
assertEquals(Session.Notice.Invite(sam, "Picnic"), invite)
assertEquals("Sam invited you: Picnic", NotificationSettings.noticeText(true, invite!!, "Sam", null))
s.absorbForTesting(listOf(from(sam, Content.event(picnic(rev = 2, title = "Barbecue"), "m-2"))))
val change = s.takeNotice()
assertEquals(Session.Notice.Changed(sam, "Picnic", cancelled = false), change, "a change is named as the reader knew it")
assertEquals("Picnic changed", NotificationSettings.noticeText(true, change!!, "Sam", null))
s.absorbForTesting(listOf(from(sam, Content.event(picnic(rev = 3, title = "Barbecue", cancelled = true), "m-3"))))
val cancel = s.takeNotice()
assertEquals(Session.Notice.Changed(sam, "Barbecue", cancelled = true), cancel)
assertEquals("Barbecue was cancelled", NotificationSettings.noticeText(true, cancel!!, "Sam", null))
assertEquals("Cancelled: Barbecue", s.history(samThread).single().summary)
}
@Test
fun `a rev 1 that takes over from a stand-in organizer is redrawn and never news (DESIGN 36)`() {
val s = session()
// Pat's rev 3 arrived first, so Pat stands in as the organizer.
s.absorbForTesting(listOf(from(pat, Content.event(picnic(rev = 3, title = "Moved"), "m-p", gid, "Saturday"))))
s.takeNotice()
s.takeQuietlyChanged()
val touched = s.absorbForTesting(listOf(from(sam, Content.event(picnic(rev = 1), "m-s", gid, "Saturday"))))
assertTrue(touched.isEmpty(), "a takeover marked the chat unread")
assertNull(s.takeNotice(), "a takeover said \"Moved changed\"")
assertTrue(groupThread in s.takeQuietlyChanged(), "the card was not redrawn")
val state = s.eventState(groupThread, "e-1")!!
assertEquals(sam, state.organizer)
assertEquals("Picnic", state.event.title)
assertFalse(state.organizerProvisional)
}
@Test
fun `a card or an event that brings a timer of its own keeps the chat's (DESIGN 46)`() {
val s = session()
val card = Content.card(birthday, "m-c").replaceFirst("{\"fnc\":1,", "{\"fnc\":1,\"secs\":5,")
val event = Content.event(picnic(), "m-e").replaceFirst("{\"fnc\":1,", "{\"fnc\":1,\"secs\":5,")
s.absorbForTesting(listOf(from(sam, card), from(sam, event)))
assertEquals(listOf(0, 0), s.history(samThread).map { it.lifetimeSeconds }, "a card or an event took its own timer")
}
/** DESIGN 45: what this phone writes keeps to 31 days, as what it reads does. */
@Test
fun `the event composer keeps to thirty-one days`() {
val composer = java.io.File("src/main/kotlin/io/flamenet/messenger/app/EventUi.kt").readText()
assertTrue(
composer.contains("days = if (allDay && hasEnd) Content.Event.clampDays(days.toLong()) else null"),
"the composer writes days past 31 again",
)
assertEquals(31, Content.Event.clampDays(60))
assertNull(Content.Event.clampDays(1))
}
@Test
fun `two things in one batch get the plain line`() {
val s = session()
s.absorbForTesting(listOf(from(sam, Content.card(birthday, "m-c")), from(sam, Content.text("and hello", messageId = "m-t"))))
assertNull(s.takeNotice())
}
// -- Off the record ----------------------------------------------------------.
@Test
fun `nothing that arrives in an off-the-record conversation reaches the disk`() {
val s = session(offTheRecordWith = sam)
val before = storage.snapshot()
s.absorbForTesting(listOf(from(sam, Content.rsvp(Content.Rsvp("e-1", "yes", 1)))))
s.absorbForTesting(listOf(from(sam, Content.card(birthday.copy(open = System.currentTimeMillis() / 1000 + 86_400), "m-c"))))
s.absorbForTesting(listOf(from(sam, Content.event(picnic(), "m-1"))))
s.absorbForTesting(listOf(from(sam, Content.event(picnic(rev = 2, title = "Barbecue"), "m-2"))))
s.absorbForTesting(listOf(from(sam, Content.rsvp(Content.Rsvp("e-1", "maybe", 2)))))
assertEquals(before, storage.snapshot(), "an off-the-record card, event, revision or answer was written")
val lines = s.history(samThread)
assertEquals(2, lines.size, "the card and the event should still show while the app is open")
assertEquals("Barbecue", lines.last().event!!.event.title)
assertEquals("maybe", lines.last().event!!.answers[sam]?.r)
assertTrue(sessionOn(vault).history(samThread).isEmpty(), "a restart found the conversation")
}
@Test
fun `what I send in an off-the-record conversation stays off the disk too`() {
val s = session(offTheRecordWith = sam)
s.sendCard(sam, birthday)
s.sendEvent(sam, null, picnic())
assertTrue(s.answerEvent(samThread, "e-1", "yes", sam, null))
assertTrue(s.reviseEvent(samThread, "e-1", sam, null) { it.copy(title = "Barbecue") })
assertTrue(storage.names().none { it.startsWith("history/") }, "a line reached the vault")
assertEquals(0, vault.outbox().length(), "a queued copy reached the vault")
assertEquals(2, s.history(samThread).size)
assertEquals(4, drain().size, "the sends themselves still went out")
}
@Test
fun `deleting a card or an event in an off-the-record conversation writes nothing`() {
val s = session(offTheRecordWith = sam)
s.absorbForTesting(listOf(from(sam, Content.card(birthday, "m-c"))))
s.absorbForTesting(listOf(from(sam, Content.event(picnic(), "m-1"))))
val before = storage.snapshot()
val lines = s.history(samThread)
assertEquals(2, lines.size)
// Delete matches on time, side and words, so two lines from one
// millisecond can go together; what matters is that both go.
assertTrue(s.deleteMessage(samThread, lines[0].at, lines[0].mine, lines[0].text), "delete for me found nothing")
lines.drop(1).forEach { s.deleteMessage(samThread, it.at, it.mine, it.text) }
assertTrue(s.history(samThread).isEmpty(), "the lines are still there")
assertEquals(before, storage.snapshot(), "deleting wrote a record naming the conversation")
}
// -- Backups -----------------------------------------------------------------.
@Test
fun `a card and an event come back whole through a backup`() {
val s = session()
s.absorbForTesting(listOf(from(sam, Content.card(birthday.copy(open = 1_900_000_000), "m-c"))))
s.absorbForTesting(listOf(from(sam, Content.event(picnic(), "m-1", gid, "Saturday"))))
s.absorbForTesting(listOf(from(pat, Content.rsvp(Content.Rsvp("e-1", "no", 4), gid))))
s.absorbForTesting(listOf(from(sam, Content.event(picnic(rev = 2, title = "Barbecue"), "m-2", gid))))
val file = BackupPayload.toShared(vault.exportPayload()).toString()
val restoredVault = Vault(MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
restoredVault.mergeImportedPayload(BackupPayload.fromShared(JSONObject(file)), nowSeconds = System.currentTimeMillis() / 1000.0)
val restored = sessionOn(restoredVault)
assertEquals(s.history(samThread).single().card, restored.history(samThread).single().card)
val before = s.history(groupThread).single()
val after = restored.history(groupThread).single()
assertEquals(before.event, after.event)
assertEquals(before.id, after.id)
assertEquals("Barbecue", after.event!!.event.title)
assertEquals(ChatEvents.Answer("no", 4), after.event!!.answers[pat])
}
}
@@ -0,0 +1,63 @@
package io.flamenet.messenger.app
import androidx.compose.ui.graphics.Color
import java.io.File
import kotlin.math.pow
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* The event, card and poll cards write their secondary lines in the theme's
* onSurfaceVariant, as the game card does (owner, 0.32). The palette's fixed
* grey is 1.7:1 on the dark theme's surface.
*/
class CardsReadInTheDarkTest {
private val dir = "src/main/kotlin/io/flamenet/messenger/app/"
@Test
fun `no secondary line on an event, a card or a poll is the fixed grey`() {
for (name in listOf("CardUi.kt", "EventUi.kt", "PollUi.kt")) {
val src = File(dir + name).readText()
assertFalse(
Regex("""color\s*=\s*Palette\.SecondaryText\b(?!\.)""").containsMatchIn(src),
"$name writes text in the fixed grey again",
)
assertTrue(src.contains("color = MaterialTheme.colorScheme.onSurfaceVariant"), "$name has no secondary line at all")
}
val game = File(dir + "GameUi.kt").readText()
assertTrue(game.contains("color = MaterialTheme.colorScheme.onSurfaceVariant"), "the game card is the model here")
}
/** The stories feed's secondary lines and its empty-state icon, the same way (owner, 0.32). */
@Test
fun `the stories feed draws nothing in the fixed grey`() {
val feed = File(dir + "StoriesFeed.kt").readText()
val uses = Regex("""Palette\.SecondaryText\b""").findAll(feed).count()
assertTrue(uses == 0, "the stories feed still draws $uses things in the fixed grey")
assertTrue(feed.contains("color = MaterialTheme.colorScheme.onSurfaceVariant"))
}
@Test
fun `the theme's colour reads on the dark surface, and the fixed grey does not`() {
assertTrue(contrast(Palette.NightSecondary, Palette.InkSurface) >= 4.5, "the dark theme's secondary text is too faint")
assertTrue(contrast(Palette.SecondaryText, Palette.InkSurface) < 3.0, "the old grey was readable after all")
// In the light theme the two are one colour, so nothing changes there.
assertTrue(contrast(Palette.SecondaryText, Color.White) >= 4.5)
}
/** WCAG 2 contrast ratio. */
private fun contrast(
a: Color,
b: Color,
): Double {
val la = luminance(a)
val lb = luminance(b)
return (maxOf(la, lb) + 0.05) / (minOf(la, lb) + 0.05)
}
private fun luminance(c: Color): Double {
fun lin(v: Float): Double = if (v <= 0.04045f) v / 12.92 else ((v + 0.055) / 1.055).pow(2.4)
return 0.2126 * lin(c.red) + 0.7152 * lin(c.green) + 0.0722 * lin(c.blue)
}
}
@@ -0,0 +1,619 @@
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.PieceKind
import io.flamenet.games.ReplayResult
import io.flamenet.games.Side
import io.flamenet.games.WinReason
import org.json.JSONArray
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* A game as a conversation holds it (§6.33): the receiving rules 1 to 4, the
* cap, every ending, this person's own moves, the words on the card and the
* history row.
*/
class ChatGamesTest {
private val id = "g-1"
private fun chess(
me: Side,
vararg log: String,
startedByMe: Boolean = true,
) = ChatGames.State(id, GameType.CHESS, me, log.toList(), startedByMe)
private fun move(
mine: Side,
vararg log: String,
type: String = "chess",
) = Content.Game(id, type, mine.wire, log.toList())
private fun end(
mine: Side,
ending: String,
vararg log: String,
) = Content.Game(id, "chess", mine.wire, log.toList(), ending)
private fun take(
current: ChatGames.State?,
game: Content.Game,
) = ChatGames.receive(current, if (game.ending == null) Content.Kind.GAME_MOVE else Content.Kind.GAME_END, game)
private fun stateOf(received: ChatGames.Received): ChatGames.State =
when (received) {
is ChatGames.Received.Moved -> received.state
is ChatGames.Received.Ended -> received.state
is ChatGames.Received.Offered -> received.state
is ChatGames.Received.Quiet -> received.state
is ChatGames.Received.OutOfStep -> received.state
ChatGames.Received.Ignored -> throw AssertionError("ignored")
}
// -- Rule 1: a game this chat does not hold ------------------------------------.
@Test
fun `rule 1, an unknown game is rebuilt from a move, on the side the sender does not play`() {
val got = assertIs<ChatGames.Received.Moved>(take(null, move(Side.FIRST, "e2e4")))
val state = got.state
assertEquals(Side.SECOND, state.me)
assertEquals(GameType.CHESS, state.type)
assertEquals(listOf("e2e4"), state.log)
assertFalse(state.startedByMe)
assertTrue(state.myMove)
assertEquals("Your move. Playing it accepts the game", ChatGames.status(state))
}
@Test
fun `rule 1, an unknown game is rebuilt from an ending too`() {
val got = assertIs<ChatGames.Received.Ended>(take(null, end(Side.SECOND, ChatGames.RESIGN, "e2e4", "e7e5")))
assertEquals(Side.FIRST, got.state.me)
assertEquals(ChatGames.RESIGN, got.state.ending)
assertEquals(Side.SECOND, got.state.endingBy)
assertEquals("They resigned. You won", ChatGames.result(got.state))
}
@Test
fun `rule 1, an unknown game whose log does not replay is out of step from the start`() {
val bad = assertIs<ChatGames.Received.OutOfStep>(take(null, move(Side.SECOND, "e2e4", "e7e9")))
assertTrue(bad.state.outOfStep)
assertEquals(emptyList(), bad.state.log, "a log that does not replay was kept")
assertEquals("Your boards disagree", ChatGames.status(bad.state))
val tooLong = Content.Game(id, "tictactoe", "second", listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "1"))
assertIs<ChatGames.Received.OutOfStep>(ChatGames.receive(null, Content.Kind.GAME_MOVE, tooLong))
}
// -- Rules 2 and 3: logs that are not news, or do not agree ------------------.
@Test
fun `rule 2, a duplicate or a stale log changes nothing`() {
val known = chess(Side.FIRST, "e2e4", "e7e5", "g1f3")
assertEquals(ChatGames.Received.Ignored, take(known, move(Side.SECOND, "e2e4", "e7e5")))
assertEquals(ChatGames.Received.Ignored, take(known, move(Side.SECOND, "e2e4", "e7e5", "g1f3")))
assertEquals(ChatGames.Received.Ignored, take(known, move(Side.SECOND)))
}
@Test
fun `rule 2, a log no longer than mine that disagrees puts the game out of step`() {
val known = chess(Side.FIRST, "e2e4", "e7e5")
for (theirs in listOf(move(Side.SECOND, "d2d4"), move(Side.SECOND, "e2e4", "c7c5"))) {
val got = assertIs<ChatGames.Received.OutOfStep>(take(known, theirs), theirs.log.toString())
assertTrue(got.state.outOfStep)
assertEquals(known.log, got.state.log, "the known game was not kept")
}
}
@Test
fun `rule 3, a longer log that does not start with mine puts the game out of step`() {
val known = chess(Side.FIRST, "e2e4")
val got = assertIs<ChatGames.Received.OutOfStep>(take(known, move(Side.SECOND, "d2d4", "d7d5")))
assertEquals(listOf("e2e4"), got.state.log)
}
// -- Rule 4: what a peer adds -------------------------------------------------.
@Test
fun `rule 4, their move is taken and it is then my move`() {
val got = assertIs<ChatGames.Received.Moved>(take(chess(Side.FIRST, "e2e4"), move(Side.SECOND, "e2e4", "e7e5")))
assertEquals(listOf("e2e4", "e7e5"), got.state.log)
assertTrue(got.state.myMove)
assertTrue(got.state.accepted, "their first move accepts the game")
assertEquals("Your move", ChatGames.status(got.state))
}
@Test
fun `rule 4, a peer adding a move for my side is rejected and the known game kept`() {
val mine = chess(Side.FIRST, "e2e4", "e7e5")
val forMe = assertIs<ChatGames.Received.OutOfStep>(take(mine, move(Side.SECOND, "e2e4", "e7e5", "g1f3")))
assertEquals(listOf("e2e4", "e7e5"), forMe.state.log, "a move for my side was played")
assertTrue(forMe.state.outOfStep)
// Theirs, then mine as well, in one envelope.
val two = assertIs<ChatGames.Received.OutOfStep>(take(chess(Side.FIRST, "e2e4"), move(Side.SECOND, "e2e4", "e7e5", "g1f3", "b8c6")))
assertEquals(listOf("e2e4"), two.state.log)
}
@Test
fun `rule 4, a peer claiming my side is rejected`() {
val got = assertIs<ChatGames.Received.OutOfStep>(take(chess(Side.FIRST, "e2e4"), move(Side.FIRST, "e2e4", "e7e5")))
assertEquals(listOf("e2e4"), got.state.log)
}
@Test
fun `rule 4, a malformed or illegal move puts the game out of step`() {
val known = chess(Side.FIRST, "e2e4")
for (bad in listOf("e7e9", "E7E5", "e7e4", "", "e7e5 ")) {
val got = assertIs<ChatGames.Received.OutOfStep>(take(known, move(Side.SECOND, "e2e4", bad)), "took \"$bad\"")
assertEquals(listOf("e2e4"), got.state.log)
}
}
@Test
fun `a different game under the same id is out of step`() {
assertIs<ChatGames.Received.OutOfStep>(take(chess(Side.FIRST, "e2e4"), move(Side.SECOND, "11-15", "23-19", type = "checkers")))
}
@Test
fun `a game out of step or finished takes nothing more`() {
val outOfStep = chess(Side.FIRST, "e2e4").copy(outOfStep = true)
assertEquals(ChatGames.Received.Ignored, take(outOfStep, move(Side.SECOND, "e2e4", "e7e5")))
val mated = chess(Side.FIRST, "f2f3", "e7e5", "g2g4", "d8h4")
assertTrue(mated.finished)
assertEquals(ChatGames.Received.Ignored, take(mated, end(Side.SECOND, ChatGames.RESIGN, "f2f3", "e7e5", "g2g4", "d8h4")))
val resigned = chess(Side.FIRST, "e2e4", "e7e5").copy(ending = ChatGames.RESIGN, endingBy = Side.FIRST)
assertEquals(ChatGames.Received.Ignored, take(resigned, move(Side.SECOND, "e2e4", "e7e5", "g1f3", "b8c6")))
assertEquals(ChatGames.Received.Ignored, take(resigned, end(Side.SECOND, ChatGames.DRAW_OFFER, "e2e4", "e7e5")))
}
// -- The cap ---------------------------------------------------------------------.
/**
* A legal chess game [plies] long that is still going: no captures, a
* pawn move at least every ninety plies, and never a move that ends it.
* The cap has to be what refuses a longer log, not some illegal move in it.
*/
private fun longChessGame(plies: Int): List<String> {
var position: GamePosition = Games.start(GameType.CHESS)
val log = mutableListOf<String>()
var quiet = 0
while (log.size < plies) {
val board = position.board
val pieces = board.cells.count { it != null }
fun pawn(move: String) = board.cells[board.squares.indexOf(move.substring(0, 2))]?.kind == PieceKind.PAWN
val legal = Games.legalMoves(position)
val (pawns, others) = legal.partition(::pawn)
val order = if (quiet < 90) others + pawns else pawns + others
var chosen: Pair<String, GamePosition>? = null
for (capture in listOf(false, true)) {
for (candidate in order) {
val next = (Games.apply(position, candidate) as? MoveResult.Applied)?.position ?: continue
if ((next.board.cells.count { it != null } != pieces) != capture) continue
if (Games.outcome(next).isOver) continue
chosen = candidate to next
break
}
if (chosen != null) break
}
val (played, next) = assertNotNull(chosen, "no move keeps the game going at ply ${log.size}")
quiet = if (pawn(played)) 0 else quiet + 1
log += played
position = next
}
return log
}
@Test
fun `the cap, a log longer than the game allows is refused even when every move in it is legal`() {
val long = longChessGame(GameType.CHESS.maxPlies + 1)
// Every move is legal and the game is still going without the cap.
val uncapped = assertIs<ReplayResult.Replayed>(Games.replay(Games.start(GameType.CHESS), long))
assertEquals(Outcome.Ongoing, Games.outcome(uncapped.position))
assertEquals(Side.FIRST, GameType.CHESS.sideOfPly(GameType.CHESS.maxPlies))
// A known game at the cap, and their one more move: theirs, legal, and refused.
val atCap = chess(Side.SECOND, *long.take(GameType.CHESS.maxPlies).toTypedArray())
assertFalse(atCap.finished)
val got = assertIs<ChatGames.Received.OutOfStep>(take(atCap, move(Side.FIRST, *long.toTypedArray())))
assertEquals(GameType.CHESS.maxPlies, got.state.log.size)
// Nor is it rebuilt as a new game.
assertIs<ChatGames.Received.OutOfStep>(take(null, move(Side.FIRST, *long.toTypedArray())))
// And I cannot play past it either.
val mineAtCap = chess(Side.FIRST, *long.take(GameType.CHESS.maxPlies).toTypedArray())
assertTrue(long.last() in Games.legalMoves(mineAtCap.position!!))
assertNull(ChatGames.play(mineAtCap, long.last()))
}
// -- Endings ------------------------------------------------------------------------.
@Test
fun `a resignation ends the game for whoever sent it`() {
val playing = chess(Side.FIRST, "e2e4", "e7e5")
val got = assertIs<ChatGames.Received.Ended>(take(playing, end(Side.SECOND, ChatGames.RESIGN, "e2e4", "e7e5")))
assertTrue(got.state.finished)
assertEquals(Side.SECOND, got.state.endingBy)
assertEquals("They resigned. You won", ChatGames.status(got.state))
assertFalse(got.state.myMove)
assertEquals(emptyList(), got.state.myMoves)
}
@Test
fun `a draw is offered, accepted or declined, and an answer to no offer is ignored`() {
val playing = chess(Side.FIRST, "e2e4", "e7e5")
val offered = assertIs<ChatGames.Received.Offered>(take(playing, end(Side.SECOND, ChatGames.DRAW_OFFER, "e2e4", "e7e5"))).state
assertEquals(Side.SECOND, offered.offerBy)
assertEquals("They offered a draw", ChatGames.status(offered))
assertTrue(ChatGames.canEnd(offered, ChatGames.DRAW_ACCEPT))
assertTrue(ChatGames.canEnd(offered, ChatGames.DRAW_DECLINE))
assertFalse(ChatGames.canEnd(offered, ChatGames.DRAW_OFFER), "an offer while theirs is open")
assertEquals(ChatGames.Received.Ignored, take(offered, end(Side.SECOND, ChatGames.DRAW_OFFER, "e2e4", "e7e5")))
// Their accept or decline of an offer I never made.
assertEquals(ChatGames.Received.Ignored, take(playing, end(Side.SECOND, ChatGames.DRAW_ACCEPT, "e2e4", "e7e5")))
assertEquals(ChatGames.Received.Ignored, take(playing, end(Side.SECOND, ChatGames.DRAW_DECLINE, "e2e4", "e7e5")))
// Mine, once I have moved, then their answers.
assertFalse(ChatGames.canEnd(playing, ChatGames.DRAW_OFFER), "an offer on my own move")
val moved = chess(Side.FIRST, "e2e4", "e7e5", "g1f3")
assertTrue(ChatGames.canEnd(moved, ChatGames.DRAW_OFFER))
val mine = ChatGames.ended(moved, ChatGames.DRAW_OFFER)
assertEquals(Side.FIRST, mine.offerBy)
assertEquals("You offered a draw", ChatGames.status(mine))
val answer = arrayOf("e2e4", "e7e5", "g1f3")
val drawn = assertIs<ChatGames.Received.Ended>(take(mine, end(Side.SECOND, ChatGames.DRAW_ACCEPT, *answer))).state
assertEquals("Draw by agreement", ChatGames.result(drawn))
assertNull(drawn.offerBy)
val refused = assertIs<ChatGames.Received.Quiet>(take(mine, end(Side.SECOND, ChatGames.DRAW_DECLINE, *answer))).state
assertNull(refused.offerBy)
assertFalse(refused.finished)
}
@Test
fun `a draw offer counts only from the side that has just moved, at the whole log`() {
// It is their move: an offer now could race their move.
val theirMove = chess(Side.FIRST, "e2e4", "e7e5", "g1f3")
assertEquals(ChatGames.Received.Ignored, take(theirMove, end(Side.SECOND, ChatGames.DRAW_OFFER, "e2e4", "e7e5", "g1f3")))
// Made after their last move, but I have moved since: it lapsed.
assertEquals(ChatGames.Received.Ignored, take(theirMove, end(Side.SECOND, ChatGames.DRAW_OFFER, "e2e4", "e7e5")))
// Before the game was accepted there is nothing to offer a draw in.
val waiting = chess(Side.SECOND, "e2e4", startedByMe = false)
assertEquals(ChatGames.Received.Ignored, take(waiting, end(Side.FIRST, ChatGames.DRAW_OFFER, "e2e4")))
}
@Test
fun `a move answers an open draw offer, on either side`() {
val theirs = chess(Side.FIRST, "e2e4", "e7e5").copy(offerBy = Side.SECOND)
assertNull(assertNotNull(ChatGames.play(theirs, "g1f3")).offerBy)
val mine = chess(Side.FIRST, "e2e4").copy(offerBy = Side.FIRST)
assertNull(stateOf(take(mine, move(Side.SECOND, "e2e4", "e7e5"))).offerBy)
}
@Test
fun `an ending that comes with their next move takes both`() {
val withMove = end(Side.SECOND, ChatGames.DRAW_OFFER, "e2e4", "e7e5")
val got = assertIs<ChatGames.Received.Offered>(take(chess(Side.FIRST, "e2e4"), withMove))
assertEquals(listOf("e2e4", "e7e5"), got.state.log)
assertEquals(Side.SECOND, got.state.offerBy)
}
@Test
fun `only the invitee declines, and only before their first move`() {
// I started and moved first; they decline before playing.
val waiting = chess(Side.FIRST, "e2e4")
val declined = assertIs<ChatGames.Received.Ended>(take(waiting, end(Side.SECOND, ChatGames.DECLINE, "e2e4"))).state
assertEquals("They declined", ChatGames.result(declined))
// Too late once they have played.
val played = chess(Side.FIRST, "e2e4", "e7e5")
assertEquals(ChatGames.Received.Ignored, take(played, end(Side.SECOND, ChatGames.DECLINE, "e2e4", "e7e5")))
// And never from the one who started it.
val invited = chess(Side.SECOND, "e2e4", startedByMe = false)
assertEquals(ChatGames.Received.Ignored, take(invited, end(Side.FIRST, ChatGames.DECLINE, "e2e4")))
}
@Test
fun `only the starter cancels, and only before the invitee has moved`() {
val invited = chess(Side.SECOND, "e2e4", startedByMe = false)
val cancelled = assertIs<ChatGames.Received.Ended>(take(invited, end(Side.FIRST, ChatGames.CANCEL, "e2e4"))).state
assertEquals("They cancelled", ChatGames.result(cancelled))
assertEquals("They cancelled", ChatGames.status(cancelled))
// Too late once I have played, in the log the cancel was sent at.
// The move it carries still counts.
val accepted = chess(Side.SECOND, "e2e4", "e7e5", startedByMe = false)
val late = assertIs<ChatGames.Received.Moved>(take(accepted, end(Side.FIRST, ChatGames.CANCEL, "e2e4", "e7e5", "g1f3"))).state
assertEquals(listOf("e2e4", "e7e5", "g1f3"), late.log)
assertNull(late.ending)
// And never from the one who was asked.
assertEquals(ChatGames.Received.Ignored, take(chess(Side.FIRST), end(Side.SECOND, ChatGames.CANCEL)))
}
@Test
fun `a cancel sent before my first move counts even when my move went first here`() {
// DESIGN item 23: judged on the log the gend carries. They cancelled
// at e2e4 while my answer was on its way; their phone already shows
// it cancelled and ignores my move, so mine has to agree.
val answered = chess(Side.SECOND, "e2e4", "e7e5", startedByMe = false)
val cancelled = assertIs<ChatGames.Received.Ended>(take(answered, end(Side.FIRST, ChatGames.CANCEL, "e2e4"))).state
assertEquals("They cancelled", ChatGames.result(cancelled))
}
@Test
fun `a decline for a game this phone never saw rebuilds it already ended`() {
val got = assertIs<ChatGames.Received.Ended>(take(null, end(Side.SECOND, ChatGames.DECLINE, "e2e4"))).state
assertTrue(got.startedByMe, "only the invitee declines, so this phone started it")
assertEquals(Side.FIRST, got.me)
assertEquals("They declined", ChatGames.result(got))
}
// -- This person's own moves and endings -------------------------------------------.
@Test
fun `I play only my own legal moves, and never past the end`() {
val start = chess(Side.FIRST)
assertEquals(listOf("e2e4"), ChatGames.play(start, "e2e4")!!.log)
assertNull(ChatGames.play(start, "e7e5"), "a move for the other side")
assertNull(ChatGames.play(start, "e2e5"), "an illegal move")
assertNull(ChatGames.play(chess(Side.SECOND), "e2e4"), "a move out of turn")
assertNull(ChatGames.play(chess(Side.FIRST).copy(outOfStep = true), "e2e4"))
assertNull(ChatGames.play(chess(Side.FIRST).copy(ending = ChatGames.CANCEL, endingBy = Side.FIRST), "e2e4"))
val mated = chess(Side.FIRST, "f2f3", "e7e5", "g2g4", "d8h4")
assertNull(ChatGames.play(mated, "e2e4"))
}
@Test
fun `what I may send depends on where the game is`() {
fun allowed(state: ChatGames.State) =
listOf(
ChatGames.RESIGN,
ChatGames.DRAW_OFFER,
ChatGames.DRAW_ACCEPT,
ChatGames.DRAW_DECLINE,
ChatGames.DECLINE,
ChatGames.CANCEL,
).filter { ChatGames.canEnd(state, it) }
.toSet()
// Started by me: until they play, I can only take it back.
assertEquals(setOf(ChatGames.CANCEL), allowed(chess(Side.FIRST)))
assertEquals(setOf(ChatGames.CANCEL), allowed(chess(Side.FIRST, "e2e4")))
// A draw is offered only on their move, after one of mine (DESIGN item 22).
assertEquals(setOf(ChatGames.RESIGN), allowed(chess(Side.FIRST, "e2e4", "e7e5")))
assertEquals(setOf(ChatGames.RESIGN, ChatGames.DRAW_OFFER), allowed(chess(Side.FIRST, "e2e4", "e7e5", "g1f3")))
// Asked by them: until I play, I can only decline.
assertEquals(setOf(ChatGames.DECLINE), allowed(chess(Side.SECOND, "e2e4", startedByMe = false)))
assertEquals(setOf(ChatGames.RESIGN, ChatGames.DRAW_OFFER), allowed(chess(Side.SECOND, "e2e4", "e7e5", startedByMe = false)))
// Their offer open.
assertEquals(
setOf(ChatGames.RESIGN, ChatGames.DRAW_ACCEPT, ChatGames.DRAW_DECLINE),
allowed(chess(Side.FIRST, "e2e4", "e7e5").copy(offerBy = Side.SECOND)),
)
// Mine open: nothing to answer, no second offer.
assertEquals(setOf(ChatGames.RESIGN), allowed(chess(Side.FIRST, "e2e4", "e7e5", "g1f3").copy(offerBy = Side.FIRST)))
// Over, or out of step: nothing at all.
assertEquals(emptySet(), allowed(chess(Side.FIRST, "f2f3", "e7e5", "g2g4", "d8h4")))
assertEquals(emptySet(), allowed(chess(Side.FIRST, "e2e4", "e7e5").copy(outOfStep = true)))
}
@Test
fun `what goes on the wire is my side, the whole log, and the ending`() {
val state = chess(Side.SECOND, "e2e4", "e7e5", startedByMe = false)
assertEquals(Content.Game(id, "chess", "second", listOf("e2e4", "e7e5")), ChatGames.wire(state))
assertEquals(Content.Game(id, "chess", "second", listOf("e2e4", "e7e5"), "resign"), ChatGames.wire(state, ChatGames.RESIGN))
}
// -- Words ----------------------------------------------------------------------------.
@Test
fun `every natural end has words, from either side`() {
val wins =
mapOf(
WinReason.CHECKMATE to ("Checkmate. You won" to "Checkmate. They won"),
WinReason.NO_MOVES to ("You won. They have no moves left" to "They won. You have no moves left"),
WinReason.MORE_DISCS to ("You won with more discs" to "They won with more discs"),
WinReason.THREE_IN_A_ROW to ("You won with three in a row" to "They won with three in a row"),
)
assertEquals(WinReason.entries.toSet(), wins.keys)
for ((reason, words) in wins) {
assertEquals(words.first, ChatGames.outcomeWords(Outcome.Win(Side.FIRST, reason), Side.FIRST))
assertEquals(words.second, ChatGames.outcomeWords(Outcome.Win(Side.SECOND, reason), Side.FIRST))
}
val draws =
mapOf(
DrawReason.STALEMATE to "Draw by stalemate",
DrawReason.INSUFFICIENT_MATERIAL to "Draw by insufficient material",
DrawReason.FIFTY_MOVES to "Draw by fifty moves",
DrawReason.FORTY_MOVES to "Draw by forty moves each without a capture",
DrawReason.REPETITION to "Draw by repetition",
DrawReason.EQUAL_DISCS to "Draw by equal discs",
DrawReason.FULL_BOARD to "Draw by full board",
)
assertEquals(DrawReason.entries.toSet(), draws.keys)
for ((reason, words) in draws) assertEquals(words, ChatGames.outcomeWords(Outcome.Draw(reason), Side.SECOND))
assertNull(ChatGames.outcomeWords(Outcome.Ongoing, Side.FIRST))
}
@Test
fun `the engines' own ends reach the card`() {
fun result(
type: GameType,
me: Side,
vararg log: String,
) = ChatGames.result(ChatGames.State(id, type, me, log.toList(), startedByMe = true))
assertEquals("Checkmate. They won", result(GameType.CHESS, Side.FIRST, "f2f3", "e7e5", "g2g4", "d8h4"))
assertEquals(
"Draw by repetition",
result(GameType.CHESS, Side.FIRST, "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8"),
)
assertEquals(
"Draw by stalemate",
result(
GameType.CHESS,
Side.SECOND,
"e2e3", "a7a5", "d1h5", "a8a6", "h5a5", "h7h5", "h2h4", "a6h6", "a5c7", "f7f6",
"c7d7", "e8f7", "d7b7", "d8d3", "b7b8", "d3h7", "b8c8", "f7g6", "c8e6",
),
)
assertEquals("They won with three in a row", result(GameType.TICTACTOE, Side.SECOND, "1", "4", "2", "5", "3"))
assertEquals("Draw by full board", result(GameType.TICTACTOE, Side.FIRST, "1", "2", "3", "5", "4", "6", "8", "7", "9"))
assertEquals("You won with more discs", result(GameType.REVERSI, Side.FIRST, "d3", "c3", "b3", "d2", "e1", "d6", "d7", "e3", "f4"))
assertNull(result(GameType.CHECKERS, Side.FIRST, "11-15"))
}
@Test
fun `endings say who, or only what when a backup did not say who`() {
val base = chess(Side.FIRST, "e2e4", "e7e5")
fun said(
ending: String,
by: Side?,
) = ChatGames.result(base.copy(ending = ending, endingBy = by))
assertEquals("You resigned. They won", said(ChatGames.RESIGN, Side.FIRST))
assertEquals("They resigned. You won", said(ChatGames.RESIGN, Side.SECOND))
assertEquals("Resigned", said(ChatGames.RESIGN, null))
assertEquals("Draw by agreement", said(ChatGames.DRAW_ACCEPT, null))
assertEquals("You declined", said(ChatGames.DECLINE, Side.FIRST))
assertEquals("They declined", said(ChatGames.DECLINE, Side.SECOND))
assertEquals("Declined", said(ChatGames.DECLINE, null))
assertEquals("You cancelled", said(ChatGames.CANCEL, Side.FIRST))
assertEquals("They cancelled", said(ChatGames.CANCEL, Side.SECOND))
assertEquals("Cancelled", said(ChatGames.CANCEL, null))
}
@Test
fun `the line under the board`() {
assertEquals("Your move", ChatGames.status(chess(Side.FIRST, "e2e4", "e7e5")))
assertEquals("Their move", ChatGames.status(chess(Side.FIRST, "e2e4", "e7e5", "g1f3")))
assertEquals("Waiting for them to play or decline", ChatGames.status(chess(Side.FIRST, "e2e4")))
assertEquals("Waiting for them to play or decline", ChatGames.status(chess(Side.SECOND)))
assertEquals("Your move. Playing it accepts the game", ChatGames.status(chess(Side.SECOND, "e2e4", startedByMe = false)))
assertEquals("Your boards disagree", ChatGames.status(chess(Side.FIRST, "e2e4").copy(outOfStep = true)))
}
@Test
fun `names and sides read as the games call them`() {
assertEquals(listOf("Chess", "Checkers", "Reversi", "Tic-tac-toe"), GameType.entries.map(ChatGames::name))
assertEquals("tic-tac-toe", ChatGames.lowerName(GameType.TICTACTOE))
assertEquals(
listOf("white", "black", "dark", "light", "black", "white", "X", "O"),
GameType.entries.flatMap { type -> Side.entries.map { ChatGames.sideName(type, it) } },
)
}
// -- On a history row ------------------------------------------------------------.
@Test
fun `a game goes onto a history row and comes back whole`() {
val state =
ChatGames.State(
id = id,
type = GameType.REVERSI,
me = Side.SECOND,
log = listOf("d3", "c3"),
startedByMe = true,
ending = ChatGames.RESIGN,
endingBy = Side.FIRST,
offerBy = Side.SECOND,
outOfStep = true,
)
val row = ChatGames.toRow(state, JSONObject().put("mine", true))
val game = row.getJSONObject(ChatGames.ROW_GAME)
assertEquals(listOf("id", "log", "me", "r", "rby", "t"), game.keys().asSequence().sorted().toList())
assertEquals(state, ChatGames.fromRow(row))
// Cleared state leaves no key behind.
val calm = ChatGames.toRow(state.copy(offerBy = null, outOfStep = false, ending = null, endingBy = null), row)
assertFalse(calm.has(ChatGames.ROW_OFFER))
assertFalse(calm.has(ChatGames.ROW_OUT_OF_STEP))
assertFalse(calm.getJSONObject(ChatGames.ROW_GAME).has("r"))
assertFalse(calm.getJSONObject(ChatGames.ROW_GAME).has("rby"))
}
@Test
fun `a row from a backup without who ended it still reads`() {
val row =
JSONObject()
.put("mine", false)
.put(
ChatGames.ROW_GAME,
JSONObject()
.put("id", "g-9")
.put("t", "chess")
.put("me", "first")
.put("log", JSONArray(listOf("e2e4")))
.put("r", "resign"),
)
val state = assertNotNull(ChatGames.fromRow(row))
assertEquals(ChatGames.RESIGN, state.ending)
assertNull(state.endingBy)
assertFalse(state.startedByMe)
assertEquals("Resigned", ChatGames.result(state))
}
@Test
fun `a row that holds no readable game gives none`() {
fun row(game: String) = JSONObject().put(ChatGames.ROW_GAME, JSONObject(game))
assertNull(ChatGames.fromRow(JSONObject().put("text", "hi")))
assertNull(ChatGames.fromRow(row("""{"id":"","t":"chess","me":"first","log":[]}""")))
assertNull(ChatGames.fromRow(row("""{"id":"g","t":"go","me":"first","log":[]}""")))
assertNull(ChatGames.fromRow(row("""{"id":"g","t":"chess","me":"white","log":[]}""")))
assertNull(ChatGames.fromRow(row("""{"id":"g","t":"chess","me":"first","log":["e2e4",1]}""")))
// An ending this build does not know is not taken as one.
assertNull(ChatGames.fromRow(row("""{"id":"g","t":"chess","me":"first","log":[],"r":"surrender"}"""))!!.ending)
}
@Test
fun `a game whose id is a space comes back off its row (DESIGN 36)`() {
val row = JSONObject().put(ChatGames.ROW_GAME, JSONObject("""{"id":" ","t":"chess","me":"first","log":["e2e4"]}"""))
assertEquals(" ", ChatGames.fromRow(row)?.id, "a game taken off the wire vanished at the next read")
}
// -- Shapes and timing (DESIGN 37) ----------------------------------------------.
@Test
fun `a move with no log or an empty one, and an ending with no log, are dropped`() {
// Each would make a board out of nothing, or end one at no position.
assertEquals(ChatGames.Received.Ignored, ChatGames.receive(null, Content.Kind.GAME_MOVE, Content.Game(id, "chess", "first", null)))
assertEquals(ChatGames.Received.Ignored, ChatGames.receive(null, Content.Kind.GAME_MOVE, move(Side.FIRST)))
assertEquals(
ChatGames.Received.Ignored,
ChatGames.receive(null, Content.Kind.GAME_END, Content.Game(id, "chess", "second", null, ChatGames.DECLINE)),
)
val playing = chess(Side.FIRST, "e2e4", "e7e5")
assertEquals(ChatGames.Received.Ignored, take(playing, move(Side.SECOND)))
assertEquals(
ChatGames.Received.Ignored,
ChatGames.receive(playing, Content.Kind.GAME_END, Content.Game(id, "chess", "second", null, ChatGames.RESIGN)),
"a resignation at no position ended the game",
)
// An ending with an empty log is a real one: a decline before any move.
val waiting = chess(Side.FIRST)
assertIs<ChatGames.Received.Ended>(take(waiting, end(Side.SECOND, ChatGames.DECLINE)))
}
@Test
fun `a draw accept counts only at my whole log`() {
val mine = ChatGames.ended(chess(Side.FIRST, "e2e4", "e7e5", "g1f3"), ChatGames.DRAW_OFFER)
assertEquals(Side.FIRST, mine.offerBy)
assertEquals(ChatGames.Received.Ignored, take(mine, end(Side.SECOND, ChatGames.DRAW_ACCEPT, "e2e4", "e7e5")))
assertEquals(ChatGames.Received.Ignored, take(mine, end(Side.SECOND, ChatGames.DRAW_ACCEPT, "e2e4")))
assertEquals(ChatGames.Received.Ignored, take(mine, end(Side.SECOND, ChatGames.DRAW_ACCEPT)))
assertIs<ChatGames.Received.Ended>(take(mine, end(Side.SECOND, ChatGames.DRAW_ACCEPT, "e2e4", "e7e5", "g1f3")))
}
}
@@ -0,0 +1,83 @@
package io.flamenet.messenger.app
import io.flamenet.client.MessengerApi
import io.flamenet.client.RelayClient
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Vault
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import javax.crypto.KeyGenerator
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* "Delete for me" works where the line lives (§6.28).
*
* An off-the-record line is held in memory, so that is where it is deleted.
* Deleting through the vault would leave it on screen and write the
* conversation's history record, empty, to disk: a record naming the person
* for a conversation whose one promise is that nothing about it is written.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class DeleteForMeOffTheRecordTest {
private class MapStorage : Vault.Storage {
val records = mutableMapOf<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
}
private val storage = MapStorage()
private val vault = Vault(storage, KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
private fun session(): Session {
val identity = DeviceIdentity.create()
vault.saveIdentity(identity)
return Session("alice", 1, identity, vault, RelayClient("http://127.0.0.1:9/", "a", "b"), MessengerApi("http://127.0.0.1:9/", "a", "b"))
}
@Test
fun `deleting an off-the-record message removes it and writes nothing`() {
val thread = Vault.threadForUser(99)
vault.saveOffTheRecord(thread, on = true)
val s = session()
s.record(thread, "meet at nine", mine = false, author = 99, at = 1_000)
s.record(thread, "see you there", mine = true, at = 2_000)
val before = storage.records.mapValues { it.value.toList() }
assertTrue(s.deleteMessage(thread, at = 1_000, mine = false, text = "meet at nine"))
assertEquals(listOf("see you there"), s.history(thread).map { it.text }, "the line is still on screen")
assertFalse(storage.names().any { it.startsWith("history/") }, "a history record reached the disk")
assertEquals(before, storage.records.mapValues { it.value.toList() }, "deleting wrote something")
}
@Test
fun `deleting an ordinary message still deletes it from the vault`() {
val thread = Vault.threadForUser(98)
val s = session()
s.record(thread, "keep", mine = true, at = 1_000)
s.record(thread, "drop", mine = true, at = 2_000)
assertTrue(s.deleteMessage(thread, at = 2_000, mine = true, text = "drop"))
assertFalse(s.deleteMessage(thread, at = 2_000, mine = true, text = "drop"), "deleted twice")
assertEquals(listOf("keep"), vault.history(thread).let { a -> (0 until a.length()).map { a.getJSONObject(it).getString("text") } })
}
}
@@ -0,0 +1,165 @@
package io.flamenet.messenger.app
import io.flamenet.client.MessengerApi
import io.flamenet.client.RelayClient
import io.flamenet.e2e.Content
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Vault
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.json.JSONObject
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
import javax.crypto.KeyGenerator
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* What sending a directory site to a chat puts on the wire (SPEC §6.12,
* DESIGN 5): an ordinary text through the ordinary send path, carrying a
* preview built from the entry, and nothing fetched to make it.
*
* The site's address points at a server of its own here, so any fetch of the
* page, by the share or by anything under it, is counted.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class DirectoryShareTest {
private lateinit var relay: MockWebServer
private lateinit var site: MockWebServer
private class NoKeyLog : okhttp3.mockwebserver.QueueDispatcher() {
override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse =
if ("/e2e/log/" in request.path.orEmpty()) {
MockResponse().setResponseCode(404)
} else {
super.dispatch(request)
}
}
@BeforeTest fun start() {
relay = MockWebServer().also {
it.dispatcher = NoKeyLog()
it.start()
}
site = MockWebServer().also { it.start() }
}
@AfterTest fun stop() {
relay.shutdown()
site.shutdown()
}
private class MapStorage : Vault.Storage {
private val records = mutableMapOf<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
}
private fun session(): Session {
val identity = DeviceIdentity.create()
val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
val vault = Vault(MapStorage(), key).also { it.saveIdentity(identity) }
val base = relay.url("/").toString()
return Session(
handle = "alice",
userId = 1,
identity = identity,
vault = vault,
relay = RelayClient(base, "alice", "app pass word"),
api = MessengerApi(base, "alice", "app pass word"),
)
}
private val peer = 99L
@Test
fun `a share is one ordinary text with the entry's preview, and nothing is fetched`() {
val s = session()
relay.enqueue(MockResponse().setResponseCode(200).setHeader("Content-Type", "application/json").setBody("""{"devices":[]}"""))
val url = site.url("/").toString()
val entry = Directory.Site("A small site", url, "FlameNet's own line about it.", listOf("arts"))
val sent = DirectoryShare.send(s, peer, entry, withPreview = true)
// Nobody has a device, so it waits in the outbox exactly as it would go.
assertFalse(sent.reachedSomeone)
val plaintext = s.vault.outbox().getJSONObject(0).getString("text")
val o = JSONObject(plaintext)
assertEquals(setOf("fnc", "kind", "mid", "caption", "lnk"), o.keys().asSequence().toSet())
assertEquals(1, o.getInt("fnc"))
assertEquals("text", o.getString("kind"))
assertEquals(url, o.getString("caption"))
val lnk = o.getJSONObject("lnk")
assertEquals(setOf("url", "title", "desc"), lnk.keys().asSequence().toSet())
assertEquals(url, lnk.getString("url"))
assertEquals("A small site", lnk.getString("title"))
assertEquals("", lnk.getString("desc"), "the description went into the preview")
assertFalse(plaintext.contains("own line"), "the directory's words about the site were sent")
// The text path's own order: fnc, kind, mid, caption, then lnk.
assertTrue(plaintext.startsWith("{\"fnc\":1,\"kind\":\"text\",\"mid\":\""), plaintext)
assertTrue(plaintext.indexOf("\"caption\":") < plaintext.indexOf("\"lnk\":"), plaintext)
// A receiver reads it as a text with a preview it does not fetch.
val received = Content.parse(plaintext)
assertEquals(url, received.text)
assertEquals(Content.LinkPreview(url, "A small site", ""), received.link)
assertTrue(received.isRenderable)
// The page itself was never asked for.
assertEquals(0, site.requestCount, "something fetched the shared page")
// And the copy in my own thread says the same.
val line = s.history(Vault.threadForUser(peer)).single()
assertEquals(url, line.text)
assertEquals(Content.LinkPreview(url, "A small site", ""), line.link)
assertTrue(line.mine)
}
/** Previews off means the address alone, as if it had been typed (DESIGN 18). */
@Test
fun `with link previews off the address goes alone`() {
val s = session()
relay.enqueue(MockResponse().setResponseCode(200).setHeader("Content-Type", "application/json").setBody("""{"devices":[]}"""))
val url = site.url("/").toString()
DirectoryShare.send(s, peer, Directory.Site("A small site", url, "A line.", emptyList()), withPreview = false)
val o = JSONObject(s.vault.outbox().getJSONObject(0).getString("text"))
assertEquals(setOf("fnc", "kind", "mid", "caption"), o.keys().asSequence().toSet())
assertEquals(url, o.getString("caption"))
assertEquals(0, site.requestCount)
}
/** The send path the screen calls is this one, with nothing between that fetches. */
@Test
fun `the directory's send goes straight to the share, never through the preview fetcher`() {
val vm =
File("src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt").readText()
val share = vm.substringAfter("fun shareSite(", "").substringBefore("\n }\n")
assertTrue(share.isNotEmpty(), "shareSite is gone from the view model")
assertTrue(share.contains("DirectoryShare.send("), "shareSite does not use DirectoryShare.send")
assertTrue(share.contains("mediaSettings.linkPreviews"), "shareSite ignores the link preview setting")
for (fetch in listOf("linkPreviewFor", "LinkPreviewFetcher")) {
assertFalse(share.contains(fetch), "shareSite reaches $fetch, which fetches the page")
}
val shareFile = File("src/main/kotlin/io/flamenet/messenger/app/DirectoryShare.kt").readText()
assertFalse(shareFile.contains("LinkPreviewFetcher"), "DirectoryShare fetches the page")
}
}
@@ -0,0 +1,125 @@
package io.flamenet.messenger.app
import io.flamenet.e2e.Content
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The small-web directory shipped in the app (DESIGN 5): the file itself,
* browsing it, searching it, and what a share of one site would say.
*/
class DirectoryTest {
/** The very file the APK carries, read from the source tree. */
private val shipped: Directory by lazy {
val file = File("src/main/assets/${Directory.ASSET}")
assertTrue(file.isFile, "the directory is not shipped at src/main/assets/${Directory.ASSET}")
Directory.parse(file.readText())
}
private fun site(title: String) = shipped.sites.single { it.title == title }
@Test
fun `the shipped file loads whole`() {
assertEquals(2964, shipped.sites.size)
assertEquals(90, shipped.categories.size)
assertEquals(15, shipped.top.size)
assertTrue(shipped.note!!.startsWith("Descriptions here were drafted"), "the note that labels the descriptions is missing")
assertEquals("https://flamenet.io/directory/", shipped.from)
}
@Test
fun `every shipped site can be opened, and stays inside its bounds`() {
for (s in shipped.sites) {
assertTrue(Directory.isWebAddress(s.url), s.url)
assertTrue(s.title.length <= Directory.MAX_TITLE, s.title)
assertTrue((s.description?.length ?: 0) <= Directory.MAX_DESC, s.title)
assertTrue(s.description == null || s.description!!.isNotBlank(), "an empty description on ${s.title}")
}
// `d` is optional: absent, never empty.
assertEquals(126, shipped.sites.count { it.description == null })
}
@Test
fun `browsing starts at the top of the tree, in the file's order`() {
assertEquals("arts-humanities", shipped.top.first().id)
assertEquals("Arts & Humanities", shipped.top.first().name)
assertTrue(shipped.children("arts-humanities").any { it.id == "architecture" })
assertTrue(shipped.children("architecture").isEmpty())
}
@Test
fun `a category lists its own sites by title, and counts everything under it once`() {
val architecture = shipped.sitesIn("architecture")
assertEquals(45, architecture.size)
assertEquals(listOf("ArchDaily", "Archidose", "ArchiExpo Virtual Fair"), architecture.take(3).map { it.title })
assertEquals(349, shipped.count("arts-humanities"))
assertEquals(45, shipped.count("architecture"))
// A site filed in two places is in both lists.
val smithsonian = site("Smithsonian Magazine")
assertTrue(smithsonian in shipped.sitesIn("arts-humanities"))
assertTrue(smithsonian in shipped.sitesIn("magazines"))
}
@Test
fun `a site with no category is found by search only`() {
val loose = shipped.sites.filter { it.categories.isEmpty() }
assertEquals(26, loose.size)
val inSomeCategory = shipped.categories.flatMap { shipped.sitesIn(it.id) }.toSet()
for (s in loose) {
assertTrue(s !in inSomeCategory, "${s.title} turned up while browsing")
assertTrue(s in shipped.search(s.title), "${s.title} cannot be found by its own name")
}
}
@Test
fun `search matches titles first, then hosts, then descriptions`() {
assertEquals("ArchDaily", shipped.search("archdaily").first().title)
assertEquals("Smithsonian Magazine", shipped.search("smithsonianmag").first().title)
val architecture = shipped.search("architecture")
assertTrue(architecture.first().title.lowercase().contains("architecture"), architecture.first().title)
assertTrue(architecture.any { it.title == "ArchDaily" }, "a description match was missed")
}
@Test
fun `search ignores case and accents and needs every word`() {
assertTrue(shipped.search("APPETIT").any { it.title == "Epicurious" }, "an accent kept a match out")
assertTrue(shipped.search("bon appetit recipes").any { it.title == "Epicurious" })
assertTrue(shipped.search("bon appetit zzzzzz").isEmpty())
assertEquals("cafe muller", Directory.fold(" Café Müller "))
}
@Test
fun `an empty search finds nothing, and a broad one stops at the limit`() {
assertTrue(shipped.search(" ").isEmpty())
assertEquals(Directory.SEARCH_LIMIT, shipped.search("e").size)
}
@Test
fun `a share is the address and a preview of the title, never the description`() {
val archdaily = site("ArchDaily")
assertTrue(archdaily.description!!.isNotEmpty(), "the test needs a site with a description")
assertEquals("https://www.archdaily.com/", DirectoryShare.text(archdaily))
assertEquals(
Content.LinkPreview("https://www.archdaily.com/", "ArchDaily", ""),
DirectoryShare.preview(archdaily),
)
}
@Test
fun `a site that cannot be opened or has no title is left out of the file`() {
val d =
Directory.parse(
"""{"v":1,"made":"2026-10-04","from":"https://flamenet.io/directory/",
"cats":[{"id":"a","name":"A","parent":""}],
"sites":[{"t":"Good","u":"https://good.example/","c":["a","missing"]},
{"t":"Script","u":"javascript:alert(1)","c":["a"]},
{"t":"","u":"https://untitled.example/","c":["a"]},
{"t":"File","u":"file:///etc/hosts","c":["a"]}]}""",
)
assertEquals(listOf("Good"), d.sites.map { it.title })
assertEquals(listOf("a"), d.sites.single().categories, "a category that does not exist was kept")
assertEquals(null, d.note)
}
}
@@ -0,0 +1,99 @@
package io.flamenet.messenger.app
import io.flamenet.games.GameType
import io.flamenet.games.Games
import io.flamenet.games.Piece
import io.flamenet.games.PieceKind
import io.flamenet.games.Side
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
/**
* How taps on a board become moves (§6.33): worked out from the engine's own
* legal moves, so whatever is sent is exactly a string the engine offered. A
* checkers jump is entered square by square, a promotion asks which piece,
* and pass is never a square.
*/
class GameBoardTapsTest {
private fun movesAt(
type: GameType,
setup: String,
): List<String> = Games.legalMoves(assertNotNull(Games.position(type, setup), setup))
@Test
fun `checkers, a multi-jump is entered square by square and sent as one move`() {
val moves = movesAt(GameType.CHECKERS, "B:W14,22,23:B9")
assertEquals(setOf("9x18x25", "9x18x27"), moves.toSet())
val pick = MoveSelection(GameType.CHECKERS, moves)
assertEquals(emptySet(), pick.targets(emptyList()), "nothing is marked before a piece is chosen")
assertEquals(MoveSelection.Next.Select(listOf("9")), pick.tap(emptyList(), "9"))
assertEquals(setOf("18"), pick.targets(listOf("9")))
assertEquals(MoveSelection.Next.Select(listOf("9", "18")), pick.tap(listOf("9"), "18"))
assertEquals(setOf("25", "27"), pick.targets(listOf("9", "18")))
assertEquals(MoveSelection.Next.Play("9x18x27"), pick.tap(listOf("9", "18"), "27"))
}
@Test
fun `checkers, a shorter capture beside a longer one is played at once`() {
val moves = movesAt(GameType.CHECKERS, "B:W14,16,23,26:B9,12")
val pick = MoveSelection(GameType.CHECKERS, moves)
assertEquals(MoveSelection.Next.Play("12x19"), pick.tap(listOf("12"), "19"))
assertEquals(MoveSelection.Next.Select(listOf("12")), pick.tap(listOf("9"), "12"), "another piece is chosen instead")
assertEquals(MoveSelection.Next.Select(emptyList()), pick.tap(listOf("9"), "9"), "the same piece again lets go")
assertEquals(MoveSelection.Next.Select(emptyList()), pick.tap(emptyList(), "30"), "a square with no move of mine")
}
@Test
fun `chess, a promotion asks which piece`() {
val moves = movesAt(GameType.CHESS, "8/4P3/8/8/8/8/k7/7K w - - 0 1")
val pick = MoveSelection(GameType.CHESS, moves)
assertEquals(setOf("e8"), pick.targets(listOf("e7")))
assertEquals(MoveSelection.Next.Choose(listOf("e7e8q", "e7e8r", "e7e8b", "e7e8n")), pick.tap(listOf("e7"), "e8"))
assertEquals(setOf("g1", "g2", "h2"), pick.targets(listOf("h1")))
assertEquals(MoveSelection.Next.Play("h1g1"), pick.tap(listOf("h1"), "g1"))
}
@Test
fun `chess, tapping another of my pieces chooses it, and an empty square lets go`() {
val pick = MoveSelection(GameType.CHESS, Games.legalMoves(Games.start(GameType.CHESS)))
assertEquals(MoveSelection.Next.Select(listOf("e2")), pick.tap(emptyList(), "e2"))
assertEquals(setOf("e3", "e4"), pick.targets(listOf("e2")))
assertEquals(MoveSelection.Next.Select(listOf("g1")), pick.tap(listOf("e2"), "g1"))
assertEquals(MoveSelection.Next.Select(emptyList()), pick.tap(listOf("g1"), "g1"))
assertEquals(MoveSelection.Next.Select(emptyList()), pick.tap(listOf("e2"), "e5"))
assertEquals(MoveSelection.Next.Play("e2e4"), pick.tap(listOf("e2"), "e4"))
}
@Test
fun `reversi and tic-tac-toe play the tapped square, and pass is never a square`() {
val reversi = MoveSelection(GameType.REVERSI, Games.legalMoves(Games.start(GameType.REVERSI)))
assertEquals(setOf("d3", "c4", "f5", "e6"), reversi.targets(emptyList()))
assertEquals(MoveSelection.Next.Play("d3"), reversi.tap(emptyList(), "d3"))
assertEquals(MoveSelection.Next.Select(emptyList()), reversi.tap(emptyList(), "a1"))
val passOnly = movesAt(GameType.REVERSI, "ox------/--------/--------/--------/--------/--------/--------/-------- x")
assertEquals(listOf("pass"), passOnly)
assertEquals(emptySet(), MoveSelection(GameType.REVERSI, passOnly).targets(emptyList()))
val ttt = MoveSelection(GameType.TICTACTOE, Games.legalMoves(Games.start(GameType.TICTACTOE)))
assertEquals((1..9).map { it.toString() }.toSet(), ttt.targets(emptyList()))
assertEquals(MoveSelection.Next.Play("5"), ttt.tap(emptyList(), "5"))
}
@Test
fun `every piece is said in words for TalkBack`() {
assertEquals("white knight", describe(GameType.CHESS, Piece(Side.FIRST, PieceKind.KNIGHT)))
assertEquals("black pawn", describe(GameType.CHESS, Piece(Side.SECOND, PieceKind.PAWN)))
assertEquals("dark man", describe(GameType.CHECKERS, Piece(Side.FIRST, PieceKind.MAN)))
assertEquals("light king", describe(GameType.CHECKERS, Piece(Side.SECOND, PieceKind.KING)))
assertEquals("black disc", describe(GameType.REVERSI, Piece(Side.FIRST, PieceKind.DISC)))
assertEquals("O", describe(GameType.TICTACTOE, Piece(Side.SECOND, PieceKind.MARK)))
}
@Test
fun `the move list numbers each turn`() {
assertEquals("1. e2e4 e7e5\n2. g1f3", moveList(listOf("e2e4", "e7e5", "g1f3")))
assertEquals("", moveList(emptyList()))
}
}
@@ -0,0 +1,345 @@
package io.flamenet.messenger.app
import android.graphics.Bitmap
import android.graphics.Canvas
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.dp
import io.flamenet.client.RelayClient
import io.flamenet.games.GameType
import io.flamenet.games.Games
import io.flamenet.games.Side
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* What a game looks like and how it is played on the screen (§6.33): each
* board in both themes, the second player's board turned round, a jump and a
* promotion entered by taps, the lone Pass, the controls in one row, a
* finished game and one whose boards disagree. Pictures go to
* build/screenshots/games.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w375dp-h812dp-normal-long-notround-any-440dpi-keyshidden-nonav")
class GameScreensTest {
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
private val out = File("build/screenshots/games").also { it.mkdirs() }
private val moves = mutableListOf<String>()
private val endings = mutableListOf<String>()
private var newGames = 0
private fun state(
type: GameType,
me: Side,
log: List<String> = emptyList(),
startedByMe: Boolean = true,
) = ChatGames.State("g-1", type, me, log, startedByMe)
private fun theme(dark: Boolean) {
DisplaySettings(compose.activity).appearance = if (dark) DisplaySettings.Appearance.DARK else DisplaySettings.Appearance.LIGHT
}
private fun show(
game: ChatGames.State,
dark: Boolean = false,
) = setContent(dark) {
GameBubble(
game,
peerName = "Sam",
onMove = { moves += it },
onEnd = { endings += it },
onNewGame = { newGames++ },
onLongPress = {},
)
}
/** What is on the screen now. A test sets content once, so later screens swap in through this. */
private var screen by mutableStateOf<(@Composable ColumnScope.() -> Unit)?>(null)
private var shown = false
private fun setContent(
dark: Boolean,
content: @Composable ColumnScope.() -> Unit,
) {
theme(dark)
screen = content
if (!shown) {
shown = true
compose.setContent {
// Keyed on the screen, so the theme reads the appearance again
// and nothing remembered carries over from the last one.
key(screen) {
MessengerTheme {
Surface(modifier = Modifier.fillMaxSize()) { Column(Modifier.padding(16.dp)) { screen?.invoke(this) } }
}
}
}
}
compose.waitForIdle()
}
/** Draw the window, as the app's other screenshots do. */
private fun shoot(name: String) {
compose.waitForIdle()
var shot: Bitmap? = null
compose.activityRule.scenario.onActivity { activity ->
val view = activity.window.decorView
val bitmap = Bitmap.createBitmap(view.width.coerceAtLeast(1), view.height.coerceAtLeast(1), Bitmap.Config.ARGB_8888)
view.draw(Canvas(bitmap))
shot = bitmap
}
val file = File(out, "$name.png")
file.outputStream().use { shot!!.compress(Bitmap.CompressFormat.PNG, 100, it) }
println("wrote ${file.absolutePath}")
}
private fun square(name: String) = compose.onNodeWithTag("square-$name", useUnmergedTree = true)
private fun said(node: SemanticsNodeInteraction): String =
node.fetchSemanticsNode().config[SemanticsProperties.ContentDescription].joinToString()
private fun top(name: String) = square(name).getUnclippedBoundsInRoot().top
private fun left(name: String) = square(name).getUnclippedBoundsInRoot().left
// -- Each board ---------------------------------------------------------------------.
@Test
fun `chess, a piece chosen shows where it can go, and the board says so in words`() {
for (dark in listOf(false, true)) {
moves.clear()
show(state(GameType.CHESS, Side.FIRST, listOf("e2e4", "e7e5")), dark)
square("g1").performClick()
assertEquals("g1, white knight, selected", said(square("g1")))
assertEquals("f3, empty, can move here", said(square("f3")))
assertEquals("e5, black pawn", said(square("e5")))
assertTrue(top("a8") < top("a1"), "white's board has black at the top")
shoot(if (dark) "chess-dark" else "chess-light")
square("f3").performClick()
assertEquals(listOf("g1f3"), moves)
}
}
@Test
fun `chess, a promotion asks which piece and sends exactly that move`() {
val log = listOf("b2b4", "a7a5", "f2f3", "a5b4", "g2g4", "b4b3", "a2a3", "b3c2", "a3a4")
show(state(GameType.CHESS, Side.SECOND, log, startedByMe = false))
assertTrue(top("a1") < top("a8"), "black's board is not turned round")
square("c2").performClick()
square("b1").performClick()
compose.onNodeWithText("Promote to").assertExists()
compose.onNodeWithTag("promote-n", useUnmergedTree = true).assertExists()
compose.onNodeWithTag("promote-q", useUnmergedTree = true).performClick()
assertEquals(listOf("c2b1q"), moves)
}
@Test
fun `checkers, the second player's board turned round, and a double jump entered square by square`() {
val log = listOf("9-14", "22-17", "6-9", "23-19", "9-13", "19-16", "13x22")
val position = (Games.replay(GameType.CHECKERS, log) as io.flamenet.games.ReplayResult.Replayed).position
assertTrue("25x18x9" in Games.legalMoves(position))
for (dark in listOf(false, true)) {
moves.clear()
show(state(GameType.CHECKERS, Side.SECOND, log, startedByMe = false), dark)
assertTrue(top("1") < top("32"), "light's board is not turned round")
square("25").performClick()
square("18").performClick()
assertEquals("25, light man, selected", said(square("25")))
assertEquals("18, empty, selected", said(square("18")))
assertEquals("9, empty, can move here", said(square("9")))
assertEquals(emptyList(), moves, "half a jump was sent")
shoot(if (dark) "checkers-dark" else "checkers-light")
square("9").performClick()
assertEquals(listOf("25x18x9"), moves)
}
}
@Test
fun `reversi marks the legal squares, and a forced pass is a lone button`() {
for (dark in listOf(false, true)) {
show(state(GameType.REVERSI, Side.FIRST), dark)
assertEquals("d3, empty, can move here", said(square("d3")))
assertEquals("d4, white disc", said(square("d4")))
compose.onNodeWithTag("game-pass").assertDoesNotExist()
shoot(if (dark) "reversi-dark" else "reversi-light")
}
moves.clear()
val toPass = listOf("c4", "e3", "f4", "c5", "d2", "f2", "c6", "d6", "e6", "c1", "g1", "g5", "g3", "b6", "a7")
show(state(GameType.REVERSI, Side.SECOND, toPass))
assertEquals("a8, empty", said(square("a8")))
shoot("reversi-pass")
compose.onNodeWithTag("game-pass").performClick()
assertEquals(listOf("pass"), moves)
}
@Test
fun `tic-tac-toe takes a tapped cell`() {
for (dark in listOf(false, true)) {
moves.clear()
show(state(GameType.TICTACTOE, Side.FIRST, listOf("5", "1")), dark)
assertEquals("5, X", said(square("5")))
assertEquals("1, O", said(square("1")))
shoot(if (dark) "tictactoe-dark" else "tictactoe-light")
square("9").performClick()
assertEquals(listOf("9"), moves)
}
}
/** DESIGN 30: tic-tac-toe is never turned round, so a cell is in the same place on both phones. */
@Test
fun `tic-tac-toe keeps its cells in place for the second player too`() {
show(state(GameType.TICTACTOE, Side.SECOND, listOf("5"), startedByMe = false))
assertTrue(top("1") < top("7"), "the second player's tic-tac-toe board is turned upside down")
assertTrue(left("1") < left("3"), "the second player's tic-tac-toe board is turned round")
assertEquals(false, ChatGames.turnedRound(state(GameType.TICTACTOE, Side.SECOND)))
for (type in listOf(GameType.CHESS, GameType.CHECKERS, GameType.REVERSI)) {
assertEquals(true, ChatGames.turnedRound(state(type, Side.SECOND)), "$type")
assertEquals(false, ChatGames.turnedRound(state(type, Side.FIRST)), "$type")
}
}
// -- The card around the board ----------------------------------------------------.
@Test
fun `an invitation is accepted by playing, or declined`() {
show(state(GameType.CHESS, Side.SECOND, listOf("e2e4"), startedByMe = false))
compose.onNodeWithText("Chess with Sam", useUnmergedTree = true).assertExists()
status().assertTextIs("Your move. Playing it accepts the game")
compose.onNodeWithText("You play black", useUnmergedTree = true).assertExists()
shoot("invite")
compose.onNodeWithTag("game-resign").assertDoesNotExist()
compose.onNodeWithTag("game-decline").performClick()
assertEquals(listOf(ChatGames.DECLINE), endings)
}
@Test
fun `the starter can take it back until the first answer`() {
show(state(GameType.TICTACTOE, Side.FIRST, listOf("5")))
status().assertTextIs("Waiting for them to play or decline")
compose.onNodeWithTag("game-cancel").performClick()
assertEquals(listOf(ChatGames.CANCEL), endings)
}
@Test
fun `resigning asks first, and an offer is answered from one row`() {
show(state(GameType.CHESS, Side.FIRST, listOf("e2e4", "e7e5")))
compose.onNodeWithTag("game-resign").performClick()
compose.onNodeWithText("Resign this game?").assertExists()
compose.onNodeWithTag("game-resign-confirm").performClick()
assertEquals(listOf(ChatGames.RESIGN), endings)
endings.clear()
show(state(GameType.CHESS, Side.FIRST, listOf("e2e4", "e7e5")).copy(offerBy = Side.SECOND))
status().assertTextIs("They offered a draw")
val accept = compose.onNodeWithTag("game-draw-accept").getUnclippedBoundsInRoot()
val decline = compose.onNodeWithTag("game-draw-decline").getUnclippedBoundsInRoot()
assertEquals(accept.top, decline.top, "Accept and Decline are not in one row")
shoot("draw-offer")
compose.onNodeWithTag("game-draw-accept").performClick()
assertEquals(listOf(ChatGames.DRAW_ACCEPT), endings)
}
@Test
fun `a finished game says how it ended and offers nothing more`() {
show(state(GameType.CHESS, Side.FIRST, listOf("f2f3", "e7e5", "g2g4", "d8h4")))
status().assertTextIs("Checkmate. They won")
for (tag in listOf("game-resign", "game-draw-offer", "game-cancel", "game-decline", "game-pass", "game-new")) {
compose.onNodeWithTag(tag).assertDoesNotExist()
}
compose.onNodeWithTag("game-moves-toggle").performClick()
compose.onNodeWithTag("game-moves", useUnmergedTree = true).assertTextIs("1. f2f3 e7e5\n2. g2g4 d8h4")
shoot("finished")
square("e2").performClick()
assertEquals(emptyList(), moves, "a finished board took a tap")
}
@Test
fun `boards that disagree say so and offer a new game`() {
show(state(GameType.CHESS, Side.FIRST, listOf("e2e4", "e7e5")).copy(outOfStep = true))
status().assertTextIs("Your boards disagree")
compose.onNodeWithTag("game-resign").assertDoesNotExist()
shoot("out-of-step")
compose.onNodeWithTag("game-new").performClick()
assertEquals(1, newGames)
}
@Test
fun `starting a game picks the game and who goes first`() {
var started: Pair<GameType, Boolean>? = null
setContent(dark = false) { NewGameDialog(onDismiss = {}, onStart = { type, first -> started = type to first }) }
compose.onNodeWithTag("new-game-reversi").performClick()
compose.onNodeWithTag("new-game-second").performClick()
val first = compose.onNodeWithTag("new-game-first").getUnclippedBoundsInRoot()
val second = compose.onNodeWithTag("new-game-second").getUnclippedBoundsInRoot()
assertEquals(first.top, second.top, "the two choices are not in one row")
compose.onNodeWithTag("new-game-start").performClick()
assertEquals(GameType.REVERSI to false, started)
}
// -- In a conversation --------------------------------------------------------------.
private fun chat(group: Boolean) =
MessengerViewModel.Phase.Chat(
threadId = if (group) "group:g-1" else "user:33",
title = if (group) "Saturday" else "Sam",
peerId = if (group) null else 33,
group = if (group) RelayClient.Group("g-1", "Saturday", 31, listOf(31, 32, 33)) else null,
lines =
if (group) {
emptyList()
} else {
val game = state(GameType.CHESS, Side.SECOND, listOf("e2e4"), startedByMe = false)
listOf(Session.Line("", mine = false, at = 1_790_000_000_000, id = "m-g", author = 33, game = game))
},
)
@Test
fun `games are offered in a one-to-one chat and nowhere else`() {
val app = androidx.test.core.app.ApplicationProvider.getApplicationContext<android.app.Application>()
val model = MessengerViewModel(app)
setContent(dark = false) { ChatScreen(model, chat(group = false), error = null) }
compose.onNodeWithTag("game-g-1").assertExists()
compose.onNodeWithTag("attach").performClick()
compose.onNodeWithTag("attach-game").assertExists()
shoot("chat-with-game")
setContent(dark = false) { ChatScreen(model, chat(group = true), error = null) }
compose.onNodeWithTag("attach").performClick()
compose.onNodeWithTag("attach-card").assertExists()
compose.onNodeWithTag("attach-game").assertDoesNotExist()
}
private fun status() = compose.onNodeWithTag("game-status", useUnmergedTree = true)
private fun SemanticsNodeInteraction.assertTextIs(expected: String) {
val text = fetchSemanticsNode().config[SemanticsProperties.Text].joinToString { it.text }
assertEquals(expected, text)
}
}
@@ -0,0 +1,626 @@
package io.flamenet.messenger.app
import io.flamenet.client.Conversations
import io.flamenet.client.MessengerApi
import io.flamenet.client.RelayClient
import io.flamenet.e2e.BackupPayload
import io.flamenet.e2e.Content
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Vault
import io.flamenet.games.GameType
import io.flamenet.games.Outcome
import io.flamenet.games.Piece
import io.flamenet.games.PieceKind
import io.flamenet.games.Side
import io.flamenet.games.WinReason
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.json.JSONArray
import org.json.JSONObject
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.time.Instant
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
import javax.crypto.KeyGenerator
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Two phones playing each game to its end through real Sessions (§6.33).
*
* Every start, move and ending goes through the real ratchet to a relay
* double that keeps one inbox per device, and the other phone collects it
* with an ordinary poll, so what is checked is what two people's phones would
* hold: the same log, the same ending, and a notice for each move.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class GamesInASessionTest {
/** A vault on a map, so a test can see whether anything was written. */
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
}
private lateinit var server: MockWebServer
/** Every envelope the relay holds, each for one device. */
private val held = Collections.synchronizedList(mutableListOf<JSONObject>())
/** Every send as the relay took it. */
private val posts = Collections.synchronizedList(mutableListOf<JSONObject>())
private val nextEnvelope = AtomicLong(1)
private val phones = ConcurrentHashMap<Long, Phone>()
/** While set, the relay refuses every send. */
@Volatile private var refuseSends = false
private val gid = "g-1"
@BeforeTest fun start() {
server = MockWebServer().also { it.dispatcher = relay() }.also { it.start() }
}
@AfterTest fun stop() = server.shutdown()
private fun newKey() = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
/** One person's phone: an account, a device, a vault and a Session. */
private inner class Phone(
val user: Long,
val name: String,
offTheRecordWith: Long? = null,
) {
val identity: DeviceIdentity = DeviceIdentity.create()
val storage = MapStorage()
val vault =
Vault(storage, newKey()).also { v ->
v.saveIdentity(identity)
v.saveGroupName(gid, "Saturday")
offTheRecordWith?.let { v.saveOffTheRecord(Vault.threadForUser(it), true) }
}
val session = sessionOn(vault)
init {
phones[user] = this
}
fun sessionOn(v: Vault): Session {
val base = server.url("/").toString()
return Session(name, user, identity, v, RelayClient(base, name, "app pass word"), MessengerApi(base, name, "app pass word"))
}
fun threadWith(other: Phone) = Vault.threadForUser(other.user)
fun game(
other: Phone,
id: String,
) = assertNotNull(session.gameState(threadWith(other), id), "$name holds no game $id")
}
private fun json(body: String) = MockResponse().setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
private fun relay() =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val url = request.requestUrl ?: return MockResponse().setResponseCode(400)
// Every route sits under the relay's REST prefix, /api/flamenet/v1.
val path = url.encodedPath.substringAfter("/flamenet/v1")
return when {
path.startsWith("/e2e/log") -> {
MockResponse().setResponseCode(404)
}
path == "/e2e/messages" && request.method == "POST" -> {
accept(JSONObject(request.body.readUtf8()))
}
path == "/e2e/messages" -> {
inbox(url.queryParameter("device_id").orEmpty(), url.queryParameter("after")?.toLongOrNull() ?: 0L)
}
path.startsWith("/e2e/devices/") || path.startsWith("/e2e/keys/") -> {
val phone = path.substringAfterLast('/').toLongOrNull()?.let { phones[it] }
val devices = JSONArray().also { a -> phone?.let { a.put(it.identity.registrationWire()) } }
json(JSONObject().put("devices", devices).toString())
}
path.startsWith("/e2e/groups") -> {
json("""{"groups":[{"gid":"$gid","name":"Saturday","owner":1,"members":[{"user_id":1},{"user_id":2}]}]}""")
}
else -> {
json("{}")
}
}
}
}
/** A send: one envelope into each named device's inbox, sealed or not. */
private fun accept(body: JSONObject): MockResponse {
if (refuseSends) return MockResponse().setResponseCode(500)
posts += body
val fromDevice = body.optString("from_device")
val fromUser = phones.values.firstOrNull { it.identity.deviceId == fromDevice }?.user ?: 0L
val messages = body.getJSONArray("messages")
for (i in 0 until messages.length()) {
val m = messages.getJSONObject(i)
held +=
JSONObject()
.put("id", nextEnvelope.getAndIncrement())
.put("to_device", m.getString("to_device"))
.put("from_user", fromUser)
.put("from_device", fromDevice)
.put("payload", m.getString("payload"))
.put("created_at", Instant.now().toString())
.put("envelope_id", m.optString("envelope_id"))
}
return json("""{"ok":true}""")
}
private fun inbox(
device: String,
after: Long,
): MockResponse {
val out = JSONArray()
synchronized(held) { held.filter { it.getString("to_device") == device && it.getLong("id") > after }.forEach { out.put(it) } }
return json(JSONObject().put("messages", out).toString())
}
// -- Playing ----------------------------------------------------------------------.
private fun begin(
starter: Phone,
invitee: Phone,
type: GameType,
starterGoesFirst: Boolean,
): String {
starter.session.startGame(invitee.user, type, starterGoesFirst)
assertFalse(posts.last().optBoolean("silent", false), "a new game should reach the person like a message")
val mine = starter.session.history(starter.threadWith(invitee)).last()
val id = assertNotNull(mine.game).id
assertTrue(mine.mine)
assertTrue(mine.game!!.startedByMe)
assertEquals("Game: " + ChatGames.name(type), mine.summary)
assertEquals(setOf(invitee.threadWith(starter)), invitee.session.receive(), "an invitation did not count as unread")
val theirs = invitee.session.history(invitee.threadWith(starter)).last()
assertEquals(id, theirs.game!!.id)
assertEquals(mine.id, theirs.id, "the card here and there have different ids, so replies would miss")
assertFalse(theirs.mine)
assertFalse(theirs.game!!.startedByMe)
assertEquals(mine.game!!.me.opponent, theirs.game!!.me)
assertEquals(Session.Notice.GameInvite(starter.user, type), invitee.session.takeNotice(), "an invitation had no words of its own")
return id
}
/**
* The whole of [log], each move by whoever plays it, collected by the
* other phone before the next. Returns both phones' games at the end.
*/
private fun playOut(
starter: Phone,
invitee: Phone,
type: GameType,
starterGoesFirst: Boolean,
log: List<String>,
): Pair<ChatGames.State, ChatGames.State> {
val id = begin(starter, invitee, type, starterGoesFirst)
val first = if (starterGoesFirst) starter else invitee
val second = if (starterGoesFirst) invitee else starter
for ((ply, move) in log.withIndex()) {
val mover = if (type.sideOfPly(ply) == Side.FIRST) first else second
val other = if (mover === first) second else first
val before = mover.game(other, id)
assertTrue(before.myMove, "ply $ply is not ${mover.name}'s to play")
assertFalse(other.game(mover, id).myMove, "ply $ply is ${other.name}'s too")
if (move == "pass") assertEquals(listOf("pass"), before.myMoves, "a pass that was not forced")
assertTrue(move in before.myMoves, "$move is not legal at ply $ply: ${before.myMoves}")
assertTrue(mover.session.playGameMove(mover.threadWith(other), id, move, other.user), "$move did not go out")
assertFalse(posts.last().optBoolean("silent", false), "a move has to wake the other phone (DESIGN item 15)")
assertEquals(log.take(ply + 1), mover.game(other, id).log)
assertEquals(emptySet(), other.session.receive(), "$move counted as unread for ${other.name} (DESIGN 29)")
assertTrue(other.threadWith(mover) in other.session.takeQuietlyChanged(), "$move did not redraw ${other.name}'s board")
val after = other.game(mover, id)
assertEquals(log.take(ply + 1), after.log, "the boards differ after $move")
assertNull(other.session.takeNotice(), "$move was worded like a message")
val notice = other.session.takeGameNews()
if (after.finished) {
assertEquals(Session.Notice.GameOver(mover.user, type, ChatGames.result(after)!!), notice)
} else {
assertEquals(Session.Notice.GameTurn(mover.user, type), notice)
}
}
val a = starter.game(invitee, id)
val b = invitee.game(starter, id)
assertTrue(a.finished && b.finished, "the game did not end")
assertEquals(a.outcome, b.outcome, "the two phones disagree on how it ended")
assertEquals(1, starter.session.history(starter.threadWith(invitee)).size, "a move drew a bubble")
assertEquals(1, invitee.session.history(invitee.threadWith(starter)).size, "a move drew a bubble")
return a to b
}
// -- Each game to its end -------------------------------------------------------.
@Test
fun `tic-tac-toe to three in a row, and the loser hears how`() {
val alice = Phone(1, "alice")
val bob = Phone(2, "bob")
val (a, b) = playOut(alice, bob, GameType.TICTACTOE, starterGoesFirst = true, log = listOf("1", "4", "2", "5", "3"))
assertEquals(Outcome.Win(Side.FIRST, WinReason.THREE_IN_A_ROW), a.outcome)
assertEquals("You won with three in a row", ChatGames.result(a))
assertEquals("They won with three in a row", ChatGames.result(b))
}
@Test
fun `chess with a promotion, to checkmate, the invitee moving first`() {
val alice = Phone(1, "alice")
val bob = Phone(2, "bob")
// Alice asks Bob to go first, so he plays white and his first move accepts.
// Black queens on b1, then mates on h4.
val log =
listOf(
"b2b4", "a7a5", "f2f3", "a5b4", "g2g4", "b4b3", "a2a3", "b3c2", "a3a4", "c2b1q",
"g1h3", "e7e5", "h3g1", "d8h4",
)
val (a, b) = playOut(alice, bob, GameType.CHESS, starterGoesFirst = false, log = log)
assertEquals(Side.SECOND, a.me)
assertEquals(Outcome.Win(Side.SECOND, WinReason.CHECKMATE), a.outcome)
assertEquals("Checkmate. You won", ChatGames.result(a))
assertEquals("Checkmate. They won", ChatGames.result(b))
for (state in listOf(a, b)) {
val board = state.position!!.board
assertEquals(Piece(Side.SECOND, PieceKind.QUEEN), board.cells[board.squares.indexOf("b1")], "the promotion did not happen")
}
}
@Test
fun `checkers with jumps, double jumps and a king, to a side with no move`() {
val alice = Phone(1, "alice")
val bob = Phone(2, "bob")
val log =
listOf(
"9-14", "22-17", "6-9", "23-19", "9-13", "19-16", "13x22", "25x18x9", "5x14", "24-20",
"12x19", "29-25", "19-24", "28x19", "11-15", "26-22", "15x24", "32-28", "14-17", "21x14",
"10x17x26", "30x23", "7-10", "28x19", "2-7", "19-15", "10x19x26", "31x22", "7-11", "27-23",
"11-16", "20x11", "8x15", "22-17", "15-19", "23x16", "1-6", "25-21", "3-8", "16-12",
"8-11", "17-13", "4-8", "12x3", "6-10", "13-9", "10-14", "3-8", "14-18", "8x15x22",
)
val (a, b) = playOut(alice, bob, GameType.CHECKERS, starterGoesFirst = true, log = log)
assertEquals(Outcome.Win(Side.SECOND, WinReason.NO_MOVES), a.outcome)
assertEquals("They won. You have no moves left", ChatGames.result(a))
assertEquals("You won. They have no moves left", ChatGames.result(b))
}
@Test
fun `reversi with a forced pass, to the end`() {
val alice = Phone(1, "alice")
val bob = Phone(2, "bob")
// Bob starts and asks Alice to go first; at ply 15 he has no square and passes.
val log = listOf("c4", "e3", "f4", "c5", "d2", "f2", "c6", "d6", "e6", "c1", "g1", "g5", "g3", "b6", "a7", "pass", "h6")
val (b, a) = playOut(bob, alice, GameType.REVERSI, starterGoesFirst = false, log = log)
assertEquals(Side.FIRST, a.me)
assertEquals(Outcome.Win(Side.FIRST, WinReason.MORE_DISCS), a.outcome)
assertEquals("You won with more discs", ChatGames.result(a))
assertEquals("They won with more discs", ChatGames.result(b))
}
// -- Endings sent by hand ------------------------------------------------------------.
@Test
fun `a draw offer is shown without a sound, a refusal not at all, and an acceptance ends it for both`() {
val alice = Phone(1, "alice")
val bob = Phone(2, "bob")
val id = begin(alice, bob, GameType.CHESS, starterGoesFirst = true)
assertTrue(alice.session.playGameMove(alice.threadWith(bob), id, "e2e4", bob.user))
bob.session.receive()
assertTrue(bob.session.playGameMove(bob.threadWith(alice), id, "e7e5", alice.user))
alice.session.receive()
alice.session.takeGameNews()
// DESIGN item 22: a draw is offered only after one's own move.
assertFalse(alice.session.endGame(alice.threadWith(bob), id, ChatGames.DRAW_OFFER, bob.user), "an offer on her own move")
assertTrue(alice.session.playGameMove(alice.threadWith(bob), id, "g1f3", bob.user))
bob.session.receive()
bob.session.takeGameNews()
assertTrue(alice.session.endGame(alice.threadWith(bob), id, ChatGames.DRAW_OFFER, bob.user))
assertFalse(posts.last().optBoolean("silent", false), "an ending has to wake the other phone")
assertEquals(emptySet(), bob.session.receive(), "a draw offer counted as unread")
val offer = bob.session.takeGameNews()
assertEquals(Session.Notice.GameDrawOffer(alice.user, GameType.CHESS), offer)
assertEquals("Alice offers a draw: Chess", NotificationSettings.noticeText(true, offer!!, "Alice", null))
assertEquals(Side.FIRST, bob.game(alice, id).offerBy)
assertEquals("They offered a draw", ChatGames.status(bob.game(alice, id)))
assertFalse(ChatGames.canEnd(bob.game(alice, id), ChatGames.DRAW_OFFER))
// Moves redraw the same way, so start from nothing redrawn.
alice.session.takeQuietlyChanged()
assertTrue(bob.session.endGame(bob.threadWith(alice), id, ChatGames.DRAW_DECLINE, alice.user))
assertEquals(emptySet(), alice.session.receive(), "a refused offer marked the chat unread")
assertNull(alice.session.takeNotice() ?: alice.session.takeGameNews(), "a refused offer had words for the lock screen")
assertNull(alice.game(bob, id).offerBy)
assertTrue(alice.threadWith(bob) in alice.session.takeQuietlyChanged(), "the card was not redrawn")
assertTrue(alice.session.endGame(alice.threadWith(bob), id, ChatGames.DRAW_OFFER, bob.user))
bob.session.receive()
assertTrue(bob.session.endGame(bob.threadWith(alice), id, ChatGames.DRAW_ACCEPT, alice.user))
assertEquals(emptySet(), alice.session.receive(), "an ending counted as unread")
assertEquals(Session.Notice.GameOver(bob.user, GameType.CHESS, "Draw by agreement"), alice.session.takeGameNews())
assertEquals("Draw by agreement", ChatGames.result(alice.game(bob, id)))
assertEquals("Draw by agreement", ChatGames.result(bob.game(alice, id)))
assertFalse(alice.session.playGameMove(alice.threadWith(bob), id, "b1c3", bob.user), "a move after the end")
}
@Test
fun `a resignation, a decline and a cancel each end the game on both phones`() {
val alice = Phone(1, "alice")
val bob = Phone(2, "bob")
val resigned = begin(alice, bob, GameType.CHECKERS, starterGoesFirst = true)
assertTrue(alice.session.playGameMove(alice.threadWith(bob), resigned, "11-15", bob.user))
bob.session.receive()
assertFalse(bob.session.endGame(bob.threadWith(alice), resigned, ChatGames.RESIGN, alice.user), "resigned before accepting")
assertTrue(bob.session.playGameMove(bob.threadWith(alice), resigned, "22-18", alice.user))
alice.session.receive()
assertTrue(bob.session.endGame(bob.threadWith(alice), resigned, ChatGames.RESIGN, alice.user))
alice.session.receive()
assertEquals("They resigned. You won", ChatGames.result(alice.game(bob, resigned)))
assertEquals("You resigned. They won", ChatGames.result(bob.game(alice, resigned)))
val declined = begin(alice, bob, GameType.REVERSI, starterGoesFirst = true)
assertTrue(bob.session.endGame(bob.threadWith(alice), declined, ChatGames.DECLINE, alice.user))
alice.session.receive()
assertEquals(Session.Notice.GameOver(bob.user, GameType.REVERSI, "They declined"), alice.session.takeGameNews())
assertEquals("You declined", ChatGames.result(bob.game(alice, declined)))
val cancelled = begin(alice, bob, GameType.TICTACTOE, starterGoesFirst = false)
assertTrue(alice.session.endGame(alice.threadWith(bob), cancelled, ChatGames.CANCEL, bob.user))
bob.session.receive()
assertEquals("They cancelled", ChatGames.result(bob.game(alice, cancelled)))
assertFalse(bob.session.playGameMove(bob.threadWith(alice), cancelled, "5", alice.user), "a move on a cancelled game")
assertEquals(3, bob.session.history(bob.threadWith(alice)).size, "three games, three cards")
}
// -- When things go wrong ------------------------------------------------------------.
@Test
fun `a move that reaches nobody is taken back, so the boards never disagree over it`() {
val alice = Phone(1, "alice")
val bob = Phone(2, "bob")
val id = begin(alice, bob, GameType.TICTACTOE, starterGoesFirst = true)
refuseSends = true
assertFalse(alice.session.playGameMove(alice.threadWith(bob), id, "5", bob.user))
assertEquals(emptyList(), alice.game(bob, id).log, "a move that never left was kept")
assertTrue(alice.game(bob, id).myMove)
assertFalse(alice.session.endGame(alice.threadWith(bob), id, ChatGames.CANCEL, bob.user))
assertNull(alice.game(bob, id).ending, "a cancel that never left was kept")
refuseSends = false
assertTrue(alice.session.playGameMove(alice.threadWith(bob), id, "5", bob.user))
bob.session.receive()
assertEquals(listOf("5"), bob.game(alice, id).log)
assertEquals(0, alice.vault.outbox().length(), "a move or an ending was queued")
}
@Test
fun `with somebody who has no device yet the start waits in the outbox and a move is taken back`() {
val alice = Phone(1, "alice")
val nobody = 7L
val thread = Vault.threadForUser(nobody)
alice.session.startGame(nobody, GameType.TICTACTOE, iGoFirst = true)
val card = alice.session.history(thread).single()
assertEquals(Session.Delivery.PENDING, card.delivery)
assertEquals(1, alice.vault.outbox().length(), "the start is queued like a card")
// DESIGN item 24: a move is never queued.
assertFalse(alice.session.playGameMove(thread, card.game!!.id, "5", nobody))
assertEquals(emptyList(), alice.session.gameState(thread, card.game!!.id)!!.log)
assertFalse(alice.session.endGame(thread, card.game!!.id, ChatGames.CANCEL, nobody))
assertNull(alice.session.gameState(thread, card.game!!.id)!!.ending)
assertEquals(1, alice.vault.outbox().length(), "a move or an ending was queued")
assertTrue(posts.isEmpty())
}
@Test
fun `a game deleted here comes back from the next move, rebuilt from its log`() {
val alice = Phone(1, "alice")
val bob = Phone(2, "bob")
val id = begin(alice, bob, GameType.TICTACTOE, starterGoesFirst = true)
assertTrue(alice.session.playGameMove(alice.threadWith(bob), id, "5", bob.user))
bob.session.receive()
assertTrue(bob.session.playGameMove(bob.threadWith(alice), id, "1", alice.user))
alice.session.receive()
val card = bob.session.history(bob.threadWith(alice)).single()
assertTrue(bob.session.deleteMessage(bob.threadWith(alice), card.at, card.mine, card.text))
assertTrue(bob.session.history(bob.threadWith(alice)).isEmpty())
assertTrue(alice.session.playGameMove(alice.threadWith(bob), id, "9", bob.user))
assertEquals(emptySet(), bob.session.receive(), "a move that rebuilt the board counted as unread")
val rebuilt = bob.game(alice, id)
assertEquals(listOf("5", "1", "9"), rebuilt.log)
assertEquals(Side.SECOND, rebuilt.me)
assertTrue(rebuilt.myMove)
assertEquals(Session.Notice.GameTurn(alice.user, GameType.TICTACTOE), bob.session.takeGameNews())
}
@Test
fun `a game in a group, from another server, or from my own device is never a board`() {
val alice = Phone(1, "alice")
val bob = Phone(2, "bob")
val s = alice.session
var envelope = 1L
fun from(
user: Long,
wire: String,
address: String? = null,
) = Conversations.Received(
id = envelope++,
fromUser = user,
fromDevice = "device-$user",
content = Content.parse(wire),
senderProven = true,
at = System.currentTimeMillis(),
fromAddress = address,
)
// A group.
val groupThread = Vault.threadForGroup(gid)
s.absorbForTesting(
listOf(
from(
bob.user,
"""{"fnc":1,"kind":"game","mid":"m-g","game":{"id":"g","t":"chess","mine":"first"},"gid":"$gid","gname":"Saturday"}""",
),
),
)
val line = s.history(groupThread).single()
assertEquals(Session.UNSUPPORTED, line.text)
assertNull(line.game)
val inGroup = """{"fnc":1,"kind":"gmove","ctl":1,"game":{"id":"g","t":"chess","mine":"first","log":["e2e4"]},"gid":"$gid"}"""
val groupMove = s.absorbForTesting(listOf(from(bob.user, inGroup)))
assertTrue(groupMove.isEmpty())
assertEquals(1, s.history(groupThread).size)
assertTrue(s.history(Vault.threadForUser(bob.user)).isEmpty(), "a group move started a game one-to-one")
// Another server.
val address = "ada@relay.example"
val addressThread = Vault.threadForAddress(address)
s.absorbForTesting(listOf(from(0, Content.game(Content.Game("g-x", "chess", "first"), "m-x"), address)))
val far = s.history(addressThread).single()
assertEquals(Session.UNSUPPORTED, far.text)
assertNull(far.game, "a board from another server that no move could answer")
val farMove = Content.gameMove(Content.Game("g-x", "chess", "first", listOf("e2e4")))
assertTrue(s.absorbForTesting(listOf(from(0, farMove, address))).isEmpty())
assertEquals(1, s.history(addressThread).size)
// My own other device: nowhere to put it, so dropped.
assertTrue(s.absorbForTesting(listOf(from(alice.user, Content.game(Content.Game("g-o", "chess", "first"), "m-o")))).isEmpty())
val ownMove = Content.gameMove(Content.Game("g-o", "chess", "first", listOf("e2e4")))
assertTrue(s.absorbForTesting(listOf(from(alice.user, ownMove))).isEmpty())
assertTrue(s.history(Vault.threadForUser(alice.user)).isEmpty())
assertTrue(s.history(Vault.threadForUser(bob.user)).isEmpty())
}
@Test
fun `a decline for a game this phone lost comes back as the game it started, declined`() {
val alice = Phone(1, "alice")
val bob = Phone(2, "bob")
val id = begin(alice, bob, GameType.CHECKERS, starterGoesFirst = true)
val card = alice.session.history(alice.threadWith(bob)).single()
assertTrue(alice.session.deleteMessage(alice.threadWith(bob), card.at, card.mine, card.text))
assertTrue(bob.session.endGame(bob.threadWith(alice), id, ChatGames.DECLINE, alice.user))
assertEquals(emptySet(), alice.session.receive(), "an ending counted as unread")
val back = alice.session.history(alice.threadWith(bob)).single()
assertTrue(back.mine, "the card for a game she started is hers")
assertEquals("They declined", ChatGames.result(back.game!!))
assertEquals(Session.Notice.GameOver(bob.user, GameType.CHECKERS, "They declined"), alice.session.takeGameNews())
}
// -- Off the record and backups -----------------------------------------------------.
@Test
fun `off the record, a game is played in memory and nothing of it reaches the disk`() {
val bob = Phone(2, "bob")
val alice = Phone(1, "alice", offTheRecordWith = 2)
val id = begin(bob, alice, GameType.TICTACTOE, starterGoesFirst = false)
assertTrue(alice.session.playGameMove(alice.threadWith(bob), id, "5", bob.user))
bob.session.receive()
assertTrue(bob.session.playGameMove(bob.threadWith(alice), id, "1", alice.user))
alice.session.receive()
assertTrue(alice.session.playGameMove(alice.threadWith(bob), id, "9", bob.user))
assertTrue(alice.session.endGame(alice.threadWith(bob), id, ChatGames.DRAW_OFFER, bob.user))
assertEquals(listOf("5", "1", "9"), alice.game(bob, id).log)
assertEquals(Side.FIRST, alice.game(bob, id).offerBy)
assertTrue(alice.storage.names().none { it.startsWith("history/") }, "a game reached the vault: ${alice.storage.names()}")
assertEquals(0, alice.vault.outbox().length())
assertTrue(alice.sessionOn(alice.vault).history(alice.threadWith(bob)).isEmpty(), "a restart found the game")
assertTrue(bob.storage.names().any { it.startsWith("history/") }, "bob's side is on the record and should be kept")
}
@Test
fun `a game comes back whole through a backup, finished or still going`() {
val alice = Phone(1, "alice")
val bob = Phone(2, "bob")
val over = begin(alice, bob, GameType.CHESS, starterGoesFirst = true)
assertTrue(alice.session.playGameMove(alice.threadWith(bob), over, "e2e4", bob.user))
bob.session.receive()
assertTrue(bob.session.playGameMove(bob.threadWith(alice), over, "e7e5", alice.user))
assertTrue(bob.session.endGame(bob.threadWith(alice), over, ChatGames.RESIGN, alice.user))
alice.session.receive()
val going = begin(alice, bob, GameType.REVERSI, starterGoesFirst = false)
assertTrue(bob.session.playGameMove(bob.threadWith(alice), going, "d3", alice.user))
alice.session.receive()
val file = BackupPayload.toShared(alice.vault.exportPayload()).toString()
val restoredVault = Vault(MapStorage(), newKey())
restoredVault.mergeImportedPayload(BackupPayload.fromShared(JSONObject(file)), nowSeconds = System.currentTimeMillis() / 1000.0)
val restored = alice.sessionOn(restoredVault)
val before = alice.session.history(alice.threadWith(bob))
val after = restored.history(alice.threadWith(bob))
assertEquals(before.map { it.game }, after.map { it.game })
assertEquals(before.map { it.id }, after.map { it.id })
assertEquals("They resigned. You won", ChatGames.result(after[0].game!!))
val resumed = after[1].game!!
assertEquals(listOf("d3"), resumed.log)
assertTrue(resumed.myMove)
assertNotNull(ChatGames.play(resumed, resumed.myMoves.first()), "the restored game cannot go on")
}
// -- Words on the lock screen -------------------------------------------------------.
@Test
fun `an invitation is said out loud in its own words, a move and an offer without a sound`() {
val invite = Session.Notice.GameInvite(2, GameType.CHESS)
assertEquals("Bob invited you to play chess", NotificationSettings.noticeText(true, invite, "Bob", null))
val tictactoe = Session.Notice.GameInvite(2, GameType.TICTACTOE)
assertEquals("Bob invited you to play tic-tac-toe", NotificationSettings.noticeText(true, tictactoe, "Bob", null))
assertEquals("New message", NotificationSettings.noticeText(false, invite, "Bob", null))
assertFalse(NotificationSettings.isQuiet(invite), "an invitation came without a sound")
assertFalse(NotificationSettings.isQuiet(Session.Notice.GameOver(2, GameType.CHESS, "They resigned. You won")))
assertTrue(NotificationSettings.isQuiet(Session.Notice.GameTurn(2, GameType.CHESS)))
assertTrue(NotificationSettings.isQuiet(Session.Notice.GameDrawOffer(2, GameType.CHESS)))
assertFalse(NotificationSettings.isQuiet(null))
}
@Test
fun `a move and an ending notify in their own words, and nothing with previews off`() {
val turn = Session.Notice.GameTurn(2, GameType.CHESS)
assertEquals("Your move: chess with Bob", NotificationSettings.noticeText(true, turn, "Bob", null))
val nameless = Session.Notice.GameTurn(2, GameType.TICTACTOE)
assertEquals("Your move: tic-tac-toe", NotificationSettings.noticeText(true, nameless, null, null))
val over = Session.Notice.GameOver(2, GameType.CHESS, "Checkmate. They won")
assertEquals("Chess with Bob: Checkmate. They won", NotificationSettings.noticeText(true, over, "Bob", null))
assertEquals("New message", NotificationSettings.noticeText(false, turn, "Bob", null))
assertEquals("New message", NotificationSettings.noticeText(false, over, "Bob", null))
}
}
@@ -0,0 +1,138 @@
package io.flamenet.messenger.app
import android.app.Application
import android.content.Intent
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.core.app.ApplicationProvider
import org.junit.Rule
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Opening `flamenetmessenger://hi#<address>` (SPEC §6.34) fills in Home's add
* screen and stops there: nothing is looked up, nobody is added and nothing is
* sent until the person chooses to.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], qualifiers = "w375dp-h812dp-normal-long-notround-any-440dpi-keyshidden-nonav")
class HelloLinkOpensTheAddScreenTest {
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
private val fnid = "fnid:k68p33sqcbnf1mc2jgzkbfqk7xzx3ykbz0fftg49tb751nrnt290"
private val address = "$fnid@relay.flamenet.io"
private fun model() = MessengerViewModel(ApplicationProvider.getApplicationContext<Application>())
private fun home(model: MessengerViewModel) {
val phase =
MessengerViewModel.Phase.SignedIn(
handle = "alice",
userId = 1,
deviceId = "74d869d7-4667-4d97-854b-80ee684aaacc",
relay = "relay.flamenet.io",
status = "Signed in on this device.",
buddiesLoaded = true,
)
compose.setContent {
MessengerTheme {
Surface(modifier = Modifier.fillMaxSize()) {
Column { SignedIn(model, phase) }
}
}
}
compose.waitForIdle()
}
@Test
fun `only a VIEW of the app's own scheme is taken for a hello link`() {
val link = "flamenetmessenger://hi#$address"
assertEquals(link, helloLinkIn(Intent(Intent.ACTION_VIEW, Uri.parse(link))))
val capitals = "FlamenetMessenger://hi#$address"
assertEquals(capitals, helloLinkIn(Intent(Intent.ACTION_VIEW, Uri.parse(capitals))))
assertNull(helloLinkIn(Intent(Intent.ACTION_VIEW, Uri.parse("https://flamenetmessenger.com/hi#$address"))))
assertNull(helloLinkIn(Intent(Intent.ACTION_MAIN, Uri.parse(link))))
assertNull(helloLinkIn(Intent(Intent.ACTION_VIEW)))
assertNull(helloLinkIn(null))
}
@Test
fun `a hello link fills in the add screen and nothing more`() {
val model = model()
model.openHelloLink("flamenetmessenger://hi#$address")
assertEquals(address, model.helloAddress.value)
home(model)
compose.onNodeWithTag("new-contact", useUnmergedTree = true).assert(hasText(address))
compose.onNodeWithTag("address-recognised", useUnmergedTree = true).assertExists()
// The one way on is a button the person presses. It has not been.
compose.onNodeWithTag("message-address", useUnmergedTree = true).assertExists()
compose.onNodeWithTag("lookup-result", useUnmergedTree = true).assertDoesNotExist()
// Taken by the screen, so it cannot fill the form a second time.
assertNull(model.helloAddress.value)
}
@Test
fun `a link with no address in it fills in nothing`() {
val model = model()
for (bad in listOf(
"flamenetmessenger://hi",
"flamenetmessenger://hi#",
"flamenetmessenger://hi#nobody@relay.flamenet.io",
"flamenetmessenger://hello#$address",
"flamenetmessenger://hi#$address?then=send",
"flamenetmessenger://hi#<script>alert(1)</script>",
)) {
model.openHelloLink(bad)
assertNull(model.helloAddress.value, bad)
}
home(model)
compose.onNodeWithTag("new-contact", useUnmergedTree = true).assertDoesNotExist()
}
/**
* What `openHelloLink` may touch, read off the source: the waiting address
* and which screen shows. A send, a lookup, an add or an open chat there
* would act on a link nobody chose to act on.
*/
@Test
fun `opening a link calls nothing that sends, looks up or adds`() {
val vm = File("src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt").readText()
val body = vm.substringAfter("fun openHelloLink(", "").substringBefore("\n }\n")
assertTrue(body.contains("HelloLink.fromLink("), "openHelloLink no longer parses the link")
for (act in listOf("send", "addBuddy", "lookupMember", "openAddressChat", "session", "api.")) {
assertFalse(body.contains(act), "openHelloLink reaches $act")
}
val activity = File("src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt").readText()
assertTrue(activity.contains("if (savedInstanceState == null) openHelloLinkIfAsked(intent)"))
assertTrue(
activity.substringAfter("override fun onNewIntent(").substringBefore("\n }\n").contains("openHelloLinkIfAsked(intent)"),
"a link opened while the app is running is not read",
)
}
@Test
fun `the manifest claims the scheme for viewing from a browser`() {
val manifest = File("src/main/AndroidManifest.xml").readText()
val filter = manifest.substringAfter("android.intent.action.VIEW", "").substringBefore("</intent-filter>")
assertTrue(filter.contains("android.intent.category.BROWSABLE"), "a browser cannot hand the link over")
assertTrue(filter.contains("android.intent.category.DEFAULT"))
assertTrue(filter.contains("android:scheme=\"flamenetmessenger\""))
}
}
@@ -0,0 +1,245 @@
package io.flamenet.messenger.app
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* Reading an address out of a hello link (SPEC §6.34, DESIGN 21), a scanned
* code or a pasted line. Anything a web page or another app can hand over
* reaches this parser, so it is held to the shape both apps agreed on.
*/
class HelloLinkTest {
private val fnid = "fnid:k68p33sqcbnf1mc2jgzkbfqk7xzx3ykbz0fftg49tb751nrnt290"
private val address = "$fnid@relay.flamenet.io"
@Test
fun `the web link and the app link both carry the address`() {
assertEquals(address, HelloLink.fromLink("https://flamenetmessenger.com/hi#$address"))
assertEquals(address, HelloLink.fromLink("https://flamenetmessenger.com/es/hi#$address"))
assertEquals(address, HelloLink.fromLink("flamenetmessenger://hi#$address"))
}
@Test
fun `the path is exactly hi or es-hi, in that case and with nothing after it (DESIGN 35)`() {
for (head in listOf(
"https://flamenetmessenger.com/HI",
"https://flamenetmessenger.com/Hi",
"https://flamenetmessenger.com/ES/hi",
"https://flamenetmessenger.com/es/HI",
"https://flamenetmessenger.com/hi/",
"https://flamenetmessenger.com/es/hi/",
"https://flamenetmessenger.com/hi?",
"https://flamenetmessenger.com/es/hi?u=someone",
"flamenetmessenger://hi/",
"flamenetmessenger://hi?u=someone",
)) {
assertNull(HelloLink.fromLink("$head#$address"), head)
}
// The scheme and the host are not the path: any case.
assertEquals(address, HelloLink.fromLink("HTTPS://FLAMENETMESSENGER.COM/es/hi#$address"))
assertEquals(address, HelloLink.fromLink("FLAMENETMESSENGER://HI#$address"))
}
@Test
fun `a host is ASCII, an address has no spaces, and a fragment stops at 400 (DESIGN 41)`() {
// Java lowercases a Kelvin sign to an ASCII k; it is refused, never folded.
assertNull(HelloLink.canonical("$fnid@\u212Aelay.example"))
assertNull(HelloLink.address("flamenet:$fnid@relay.\u212Aexample"))
assertNull(HelloLink.fromLink("https://flamenetmessenger.com/hi#$fnid@\u212Aelay.example"))
for (spaced in listOf("$fnid @relay.flamenet.io", "$fnid@ relay.flamenet.io", "$fnid@relay. flamenet.io")) {
assertNull(HelloLink.canonical(spaced), spaced)
}
assertNull(HelloLink.fromLink("flamenetmessenger://hi/#$address"))
val head = "https://flamenetmessenger.com/hi#"
fun fragmentOf(length: Int): String = "$fnid@" + "a".repeat(length - fnid.length - 1 - ".example".length) + ".example"
assertEquals(400, fragmentOf(400).length)
assertEquals(fragmentOf(400), HelloLink.fromLink(head + fragmentOf(400)))
assertNull(HelloLink.fromLink(head + fragmentOf(401)), "a fragment over 400 characters was read")
}
/** hello-link-v1, "whitespace around the link": trimmed, as a scanned code is. */
@Test
fun `whitespace around a link is not part of it`() {
assertEquals(address, HelloLink.fromLink(" https://flamenetmessenger.com/hi#$address\n"))
assertEquals(address, HelloLink.fromLink("\tflamenetmessenger://hi#$address "))
assertNull(HelloLink.fromLink("https://flamenetmessenger.com/hi#$fnid @relay.flamenet.io"), "a space inside is still refused")
}
@Test
fun `the card's link is the https form, and reads back to the same address`() {
val link = HelloLink.forAddress(address)
assertEquals("https://flamenetmessenger.com/hi#$address", link)
assertEquals(address, HelloLink.fromLink(link!!))
assertNull(HelloLink.forAddress("not an address"))
}
@Test
fun `case in the scheme and the host is not a different link`() {
assertEquals(address, HelloLink.fromLink("HTTPS://FlameNetMessenger.com/hi#$address"))
assertEquals(address, HelloLink.fromLink("FlamenetMessenger://HI#$address"))
assertEquals(address, HelloLink.fromLink("flamenetmessenger://hi#$fnid@Relay.FlameNet.IO"))
}
@Test
fun `the fragment is percent-decoded once`() {
val web = "https://flamenetmessenger.com/hi#"
assertEquals(address, HelloLink.fromLink(web + "fnid%3A${fnid.removePrefix("fnid:")}@relay.flamenet.io"))
assertEquals(address, HelloLink.fromLink(web + "$fnid%40relay.flamenet.io"))
assertEquals(address, HelloLink.fromLink(web + "%66nid:${fnid.removePrefix("fnid:")}@relay.flamenet.io"))
// Once, not twice: an escaped escape stays an escape and is refused.
assertNull(HelloLink.fromLink(web + "$fnid%2540relay.flamenet.io"))
for (broken in listOf("%", "%4", "%zz", "%+4", "%E2%80%8B")) {
assertNull(HelloLink.fromLink(web + address + broken), broken)
}
}
@Test
fun `a bare or card-prefixed address is an address, trimmed`() {
assertEquals(address, HelloLink.address(" $address\n"))
assertEquals(address, HelloLink.address("flamenet:$address"))
assertEquals(address, HelloLink.address("FLAMENET:$address"))
assertEquals(address, HelloLink.address("https://flamenetmessenger.com/hi#$address"))
assertEquals("$fnid@127.0.0.1", HelloLink.address("$fnid@127.0.0.1"))
}
@Test
fun `the host is letters, digits, hyphens and dots, with a dot`() {
assertEquals("$fnid@my-relay.example", HelloLink.canonical("$fnid@my-relay.example"))
for (host in listOf("relay", "relay_one.example", "relay.example:443", "relay.example/", "", "rel\u0430y.example", "relay example.com")) {
assertNull(HelloLink.canonical("$fnid@$host"), host)
}
}
@Test
fun `another scheme or another host is not a hello link`() {
for (head in listOf(
"http://flamenetmessenger.com/hi",
"ftp://flamenetmessenger.com/hi",
"https://evil.example/hi",
"https://flamenetmessenger.com.evil.example/hi",
"https://evil.example/flamenetmessenger.com/hi",
"https://flamenetmessenger.com@evil.example/hi",
"https://www.flamenetmessenger.com/hi",
"https://flamenetmessenger.com:8443/hi",
"https://flamenetmessenger.com/hi?u=someone",
"https://flamenetmessenger.com/hello",
"https://flamenetmessenger.com/hi/x",
"flamenetmessenger://hello",
"flamenetmessenger://hi.evil.example",
"flamenetmessenger:hi",
"flamenet://hi",
"intent://hi",
"javascript:alert(1)//",
)) {
assertNull(HelloLink.fromLink("$head#$address"), head)
}
}
@Test
fun `a missing or malformed fragment carries nobody`() {
val web = "https://flamenetmessenger.com/hi"
for (fragment in listOf(
"",
"#",
"#fnid:",
"#$fnid",
"#$fnid@",
"#@relay.flamenet.io",
// One character short, one too many.
"#${fnid.dropLast(1)}@relay.flamenet.io",
"#${fnid}x@relay.flamenet.io",
// Letters the alphabet leaves out so they cannot be misread.
"#${fnid.dropLast(1)}i@relay.flamenet.io",
"#${fnid.dropLast(1)}l@relay.flamenet.io",
"#${fnid.dropLast(1)}o@relay.flamenet.io",
"#${fnid.dropLast(1)}u@relay.flamenet.io",
// The key part is a key; capitals are a different string.
"#${fnid.uppercase()}@relay.flamenet.io",
"#${fnid.removePrefix("fnid:")}@relay.flamenet.io",
"#$fnid@relay",
"#$fnid@relay.flamenet.io:443",
"#$fnid@relay.flamenet.io/",
)) {
assertNull(HelloLink.fromLink(web + fragment), fragment)
}
}
@Test
fun `nothing smuggled into the fragment is taken`() {
val web = "https://flamenetmessenger.com/hi#"
for (attempt in listOf(
"$address?u=someone",
"$address&k=key",
"$address#again",
"$address extra",
" $address",
"$address%0A",
"$address%00",
"$fnid@relay.flamenet.io@evil.example",
"$fnid%40relay.flamenet.io%40evil.example",
"$fnid@evil.example\u0000.relay.flamenet.io",
"$fnid@<script>alert(1)</script>.example",
"$fnid@%3Cscript%3E.example",
"$fnid@relay.flamenet.io\"><img src=x>",
"javascript:alert(1)",
)) {
assertNull(HelloLink.fromLink(web + attempt), attempt)
}
}
@Test
fun `the scanner reads a hello link, an older card and a bare address`() {
assertEquals(
ScannedAddress(fnid, "relay.flamenet.io"),
parseScannedAddress("https://flamenetmessenger.com/hi#$address"),
)
assertEquals(ScannedAddress(fnid, "relay.flamenet.io"), parseScannedAddress(address))
// An older card's handle is not taken (DESIGN 32).
assertEquals(ScannedAddress(fnid, "relay.flamenet.io"), parseScannedAddress("flamenet:$address?u=pat"))
}
/**
* The address card's code holds the hello link now, so the scanner has to
* read it back: drawn by the app's own encoder, decoded by the decoder the
* scanner uses.
*/
@Test
fun `the card's code reads back through the scanner as the same address`() {
val link = HelloLink.forAddress(address)!!
val m = io.flamenet.e2e.QrCode.encode(link)
val scale = 4
val quiet = 4
val side = (m.size + quiet * 2) * scale
val pixels = IntArray(side * side) { 0xFFFFFFFF.toInt() }
for (r in 0 until m.size) {
for (c in 0 until m.size) {
if (!m[r, c]) continue
for (dy in 0 until scale) {
for (dx in 0 until scale) {
pixels[((r + quiet) * scale + dy) * side + (c + quiet) * scale + dx] = 0xFF000000.toInt()
}
}
}
}
val source = com.google.zxing.RGBLuminanceSource(side, side, pixels)
val bitmap = com.google.zxing.BinaryBitmap(com.google.zxing.common.HybridBinarizer(source))
val text = com.google.zxing.qrcode.QRCodeReader().decode(bitmap).text
assertEquals(link, text)
assertEquals(ScannedAddress(fnid, "relay.flamenet.io"), parseScannedAddress(text))
}
@Test
fun `the scanner refuses a code that only looks like an address`() {
for (code in listOf(
"mailto:someone@example.com",
"https://user@example.com/page",
"flamenet:someone@relay.flamenet.io",
"WIFI:S:home;T:WPA;P:secret;;",
"",
)) {
assertNull(parseScannedAddress(code), code)
}
}
}

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