Compare commits
@@ -1 +1 @@
|
||||
be8093f7c76c42cac5b826f40db95db372bdd34b
|
||||
4b4042f72880758ee2dea7713f7a2cb63fa12f73
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Why this repository pins flamenet-e2e
|
||||
|
||||
`.e2e-ref` is the commit of [flamenet-e2e](https://gitlab.com/paulhitt/flamenet-e2e)
|
||||
`.e2e-ref` is the commit of [flamenet-e2e](https://git.extant2000.com/extant2000/flamenet-e2e)
|
||||
whose protocol vectors the `unit-tests` job checks against. `AccountKdfTest`,
|
||||
`PQVectorTest` and `PublishedVectorsTest` all read them.
|
||||
|
||||
@@ -17,7 +17,7 @@ A pin alone is worse than HEAD, though. The argument for HEAD is real: a
|
||||
client tested against a pinned old harness is tested against a pinned old spec,
|
||||
and if the vectors move and nothing says so, this suite quietly stops checking
|
||||
the protocol anybody is actually running. That is why `vectors-head` exists and
|
||||
is `allow_failure: true`.
|
||||
is `continue-on-error: true` in `.gitea/workflows/ci.yml`.
|
||||
|
||||
Being behind is LOUD and NON-BLOCKING. Being broken is a hard stop. A red
|
||||
`vectors-head` means an update is waiting, not that anything is wrong.
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# CI for the Android app on Gitea Actions. Translated from .gitlab-ci.yml on
|
||||
# 2026-10-13; every gate it had is kept. Instrumented tests stay manual: they
|
||||
# need an emulator and a local relay.
|
||||
#
|
||||
# Runs on mac1/mac2 (`ubuntu-latest`) inside the pinned toolchain image built
|
||||
# from ci/android-ci.Dockerfile. Never on pauls-imac: Android unit tests hang
|
||||
# there (2026-10-09).
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
# Bump when ci/android-ci.Dockerfile changes.
|
||||
IMAGE: git.extant2000.com/extant2000/flamenet-android-ci:jdk21.0.5-api35-r1
|
||||
GITEA_BASE: https://git.extant2000.com/extant2000
|
||||
|
||||
# Inside a container job the runner picks `sh -e {0}`, which is dash on this
|
||||
# image, and dash has no `pipefail`. Every `set -euo pipefail` step died on
|
||||
# line 2 before it ran anything. Named here once rather than per step.
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
# The repository is public, so this sweep is the last thing between a pasted
|
||||
# key and the internet. It was red for nine days in 2026-09 because nobody
|
||||
# ran it.
|
||||
secret-sweep:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: bash scripts/secret-sweep.sh
|
||||
|
||||
# Builds and pushes the toolchain image when its tag is not in the registry
|
||||
# yet. A no-op on every other run.
|
||||
image:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build and push the toolchain image if missing
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
JOB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ACTOR: ${{ gitea.actor }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz \
|
||||
| tar -xz -C /usr/local/bin --strip-components=1 docker/docker
|
||||
if [ -n "${REGISTRY_TOKEN:-}" ]; then
|
||||
echo "$REGISTRY_TOKEN" | docker login git.extant2000.com -u extant2000git --password-stdin
|
||||
else
|
||||
echo "$JOB_TOKEN" | docker login git.extant2000.com -u "$ACTOR" --password-stdin
|
||||
fi
|
||||
if docker manifest inspect "$IMAGE" >/dev/null 2>&1; then
|
||||
echo "image $IMAGE exists"; exit 0
|
||||
fi
|
||||
DOCKER_BUILDKIT=0 docker build -t "$IMAGE" -f ci/android-ci.Dockerfile ci
|
||||
docker push "$IMAGE"
|
||||
|
||||
unit-tests:
|
||||
needs: image
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
container:
|
||||
image: git.extant2000.com/extant2000/flamenet-android-ci:jdk21.0.5-api35-r1
|
||||
env:
|
||||
# The interop tests FAIL rather than skip when unconfigured, on purpose.
|
||||
# They need a real lyrebird from the flamenet-elligator2 checkout, so they
|
||||
# are opted out HERE and nowhere else. Run scripts/obfs4-interop.sh
|
||||
# locally before merging any obfs4 change.
|
||||
OBFS4_INTEROP_OPTOUT: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Fetch the pinned protocol vectors
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Pinned to .e2e-ref, not tip (flamenet-relay#56 is what tip cost).
|
||||
# `vectors-head` below is the advisory half that watches HEAD.
|
||||
FLAMENET_E2E_PIN=$(cat .e2e-ref)
|
||||
rm -rf .ci-vectors
|
||||
git clone -q "$GITEA_BASE/flamenet-e2e.git" .ci-vectors
|
||||
git -C .ci-vectors checkout -q "$FLAMENET_E2E_PIN"
|
||||
- name: Unit tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
V="$GITHUB_WORKSPACE/.ci-vectors/test/vectors"
|
||||
export FLAMENET_VECTORS="$V/protocol-v1.json"
|
||||
export FLAMENET_PQ_VECTORS="$V/pqx3dh-v2.json"
|
||||
export FLAMENET_ACCOUNT_VECTORS="$V/account-kdf-v1.json"
|
||||
export FLAMENET_BACKUP_VECTORS="$V/backup-v1.json"
|
||||
export FLAMENET_BACKUP_PAYLOAD_VECTORS="$V/backup-payload-v1.json"
|
||||
export FLAMENET_CONTENT_VECTORS="$V/content-v1.json"
|
||||
export FLAMENET_POLL_VECTORS="$V/poll-tally-v1.json"
|
||||
export FLAMENET_PADDING_VECTORS="$V/padding-v1.json"
|
||||
export FLAMENET_SYNC_VECTORS="$V/sync-merge-v1.json"
|
||||
export FLAMENET_HANDLE_CERT_VECTORS="$V/handle-cert-v1.json"
|
||||
export FLAMENET_LINK_VECTORS="$V/links-v1.json"
|
||||
export FLAMENET_BRIDGE_VECTORS="$V/bridge-offer-v1.json"
|
||||
export FLAMENET_CONTENT_KINDS_VECTORS="$V/content-kinds-v1.json"
|
||||
export FLAMENET_TODAY_VECTORS="$V/today-v1.json"
|
||||
export FLAMENET_HELLO_LINK_VECTORS="$V/hello-link-v1.json"
|
||||
# A path that silently does not exist would read as a protocol bug.
|
||||
for f in "$FLAMENET_VECTORS" "$FLAMENET_PQ_VECTORS" "$FLAMENET_ACCOUNT_VECTORS" \
|
||||
"$FLAMENET_BACKUP_VECTORS" "$FLAMENET_BACKUP_PAYLOAD_VECTORS" \
|
||||
"$FLAMENET_CONTENT_VECTORS" "$FLAMENET_POLL_VECTORS" \
|
||||
"$FLAMENET_PADDING_VECTORS" "$FLAMENET_SYNC_VECTORS" \
|
||||
"$FLAMENET_HANDLE_CERT_VECTORS" "$FLAMENET_LINK_VECTORS" \
|
||||
"$FLAMENET_BRIDGE_VECTORS" "$FLAMENET_CONTENT_KINDS_VECTORS" \
|
||||
"$FLAMENET_TODAY_VECTORS" "$FLAMENET_HELLO_LINK_VECTORS"; do
|
||||
[ -f "$f" ] || { echo "GATE: vector file missing: $f"; exit 1; }
|
||||
done
|
||||
# Clear old results so the gate never counts ghosts.
|
||||
rm -rf ./*/build/test-results
|
||||
# --rerun-tasks: a FROM-CACHE pass prints the same BUILD SUCCESSFUL
|
||||
# as a real one. Both app variants: release is the one that ships.
|
||||
./gradlew --no-daemon --rerun-tasks :engine:test :client:test :obfs4:test \
|
||||
:games:test :app:testDebugUnitTest :app:testReleaseUnitTest
|
||||
- name: Gate on the test results
|
||||
run: |
|
||||
# Assert on the XML, never on Gradle's exit status: `:app` prints no
|
||||
# test names, and an up-to-date task is also "BUILD SUCCESSFUL".
|
||||
python3 - <<'PY'
|
||||
import glob, re, sys
|
||||
|
||||
# Floors near 90% of the suite (663 / 453 / 75 / 572, games 15).
|
||||
# `app` counts both variants.
|
||||
MINIMUMS = {"engine": 595, "client": 405, "obfs4": 68, "games": 13, "app": 515}
|
||||
totals = {}
|
||||
for module in MINIMUMS:
|
||||
tests = failures = skipped = 0
|
||||
for path in glob.glob(f"{module}/build/test-results/*/TEST-*.xml"):
|
||||
text = open(path).read()
|
||||
m = re.search(r'tests="(\d+)" skipped="(\d+)" failures="(\d+)" errors="(\d+)"', text)
|
||||
if not m:
|
||||
continue
|
||||
tests += int(m.group(1)); skipped += int(m.group(2))
|
||||
failures += int(m.group(3)) + int(m.group(4))
|
||||
totals[module] = (tests, failures, skipped)
|
||||
|
||||
bad = False
|
||||
for module, (tests, failures, skipped) in totals.items():
|
||||
print(f"GATE: {module}: {tests} tests, {failures} failures, {skipped} skipped")
|
||||
if tests < MINIMUMS[module]:
|
||||
print(f"GATE: {module} reported {tests} tests, the suite did not run")
|
||||
bad = True
|
||||
if failures:
|
||||
bad = True
|
||||
grand = sum(t for t, _, _ in totals.values())
|
||||
print(f"GATE: {grand} tests in total")
|
||||
if grand < 1580:
|
||||
print(f"GATE: only {grand} tests in the whole build; something is not running")
|
||||
bad = True
|
||||
sys.exit(1 if bad else 0)
|
||||
PY
|
||||
|
||||
# The pin's conscience: the vector suites against flamenet-e2e HEAD.
|
||||
# Allowed to fail. A red run means an update is waiting; see .e2e-ref.md.
|
||||
vectors-head:
|
||||
needs: image
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
continue-on-error: true
|
||||
container:
|
||||
image: git.extant2000.com/extant2000/flamenet-android-ci:jdk21.0.5-api35-r1
|
||||
env:
|
||||
OBFS4_INTEROP_OPTOUT: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Vector suites at flamenet-e2e HEAD
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf .ci-vectors-head
|
||||
git clone --depth 1 "$GITEA_BASE/flamenet-e2e.git" .ci-vectors-head
|
||||
echo "pinned: $(cat .e2e-ref)"
|
||||
echo "at HEAD: $(git -C .ci-vectors-head rev-parse HEAD)"
|
||||
V="$GITHUB_WORKSPACE/.ci-vectors-head/test/vectors"
|
||||
export FLAMENET_VECTORS="$V/protocol-v1.json"
|
||||
export FLAMENET_PQ_VECTORS="$V/pqx3dh-v2.json"
|
||||
export FLAMENET_ACCOUNT_VECTORS="$V/account-kdf-v1.json"
|
||||
export FLAMENET_BACKUP_VECTORS="$V/backup-v1.json"
|
||||
export FLAMENET_BACKUP_PAYLOAD_VECTORS="$V/backup-payload-v1.json"
|
||||
export FLAMENET_CONTENT_VECTORS="$V/content-v1.json"
|
||||
export FLAMENET_POLL_VECTORS="$V/poll-tally-v1.json"
|
||||
export FLAMENET_PADDING_VECTORS="$V/padding-v1.json"
|
||||
export FLAMENET_SYNC_VECTORS="$V/sync-merge-v1.json"
|
||||
export FLAMENET_HANDLE_CERT_VECTORS="$V/handle-cert-v1.json"
|
||||
export FLAMENET_LINK_VECTORS="$V/links-v1.json"
|
||||
export FLAMENET_BRIDGE_VECTORS="$V/bridge-offer-v1.json"
|
||||
export FLAMENET_CONTENT_KINDS_VECTORS="$V/content-kinds-v1.json"
|
||||
export FLAMENET_TODAY_VECTORS="$V/today-v1.json"
|
||||
export FLAMENET_HELLO_LINK_VECTORS="$V/hello-link-v1.json"
|
||||
# Only the tests that read vectors: the engine, :obfs4 (the
|
||||
# bridge-offer vectors, SPEC section 24), and the three 0.32 loaders
|
||||
# in :client and :app.
|
||||
./gradlew --no-daemon --rerun-tasks :engine:test :obfs4:test \
|
||||
:client:test --tests io.flamenet.client.SharedVectorsTodayTest \
|
||||
:app:testDebugUnitTest --tests io.flamenet.messenger.app.SharedVectorsContentKindsTest \
|
||||
--tests io.flamenet.messenger.app.SharedVectorsHelloLinkTest
|
||||
@@ -1,213 +0,0 @@
|
||||
# T4: the Mac is in the merge path. Engine, client, obfs4 and app unit suites on
|
||||
# the project's own runner (tag mac-local). Instrumented tests stay manual:
|
||||
# they need an emulator and a local relay.
|
||||
stages: [test]
|
||||
|
||||
# The repository is public, so this sweep is the last thing between a pasted
|
||||
# key and the internet. Until 2026-09-17 it only ran when somebody remembered
|
||||
# to, and it had been red for nine days without anyone noticing, because a
|
||||
# formatter carried an allow marker off the line it covered. A check nobody
|
||||
# runs protects nothing; here it runs on every merge request and every push
|
||||
# to main, and it finishes in seconds.
|
||||
secret-sweep:
|
||||
stage: test
|
||||
tags: [mac-local]
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
variables:
|
||||
GIT_DEPTH: "1"
|
||||
script:
|
||||
- bash scripts/secret-sweep.sh
|
||||
timeout: 5m
|
||||
|
||||
unit-tests:
|
||||
stage: test
|
||||
tags: [mac-local]
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
variables:
|
||||
GIT_DEPTH: "1"
|
||||
# The interop tests FAIL rather than skip when unconfigured, on purpose,
|
||||
# a silent skip is how a transport suite goes green while proving nothing.
|
||||
# They need a real lyrebird from the flamenet-elligator2 checkout, which is
|
||||
# not in this build directory, so they are opted out HERE and nowhere else.
|
||||
# Run `scripts/obfs4-interop.sh` locally before merging any obfs4 change:
|
||||
# every other test in that module compares against recorded vectors or
|
||||
# against itself, and two identically-wrong ends agree perfectly.
|
||||
OBFS4_INTEROP_OPTOUT: "1"
|
||||
script:
|
||||
- export JAVA_HOME=/opt/homebrew/opt/openjdk@21
|
||||
- export ANDROID_HOME=$HOME/Library/Android/sdk
|
||||
# The protocol vectors are not in this repo, and CI never had them.
|
||||
# `AccountKdfTest`, `PQVectorTest` and `PublishedVectorsTest` look for a
|
||||
# sibling `flamenet-e2e` checkout. That exists on a developer's machine and
|
||||
# has never existed in a build directory, so all twenty of them have been
|
||||
# failing here since they were written, invisibly, because the build cache
|
||||
# replayed a local pass. They fail rather than skip when the file is
|
||||
# missing, exactly so this could not hide; it took `--rerun-tasks` to let
|
||||
# them say so.
|
||||
#
|
||||
# Pinned to a commit, NOT cloned at tip. Tip was the first instinct,
|
||||
# "if the protocol changes this SHOULD go red", and flamenet-relay#56 is
|
||||
# the write-up of what that actually costs. There, a job clones
|
||||
# `flamenet-e2e` at HEAD, so merging anything in that repo turned relay's
|
||||
# `main` red in a pipeline nobody was watching, blocked six MRs sitting on
|
||||
# merge-when-pipeline-succeeds, and was misdiagnosed as runner starvation.
|
||||
# It fired twice in one evening. The person who merged was working in a
|
||||
# different project and had no reason to look.
|
||||
#
|
||||
# A pin makes the same divergence a deliberate, reviewable bump here instead
|
||||
# of a surprise failure in a repo nobody was thinking about. The commit
|
||||
# lives in `.e2e-ref`; `.e2e-ref.md` says how to take an update.
|
||||
#
|
||||
# A pin ALONE is worse than HEAD, and that is what the first version
|
||||
# of this got wrong. Pinning silently means the vectors can move and this
|
||||
# suite goes on checking last month's protocol, reporting green, the exact
|
||||
# "tested against a pinned old spec" failure the relay's own note warns
|
||||
# about. `vectors-head` below is the other half: it runs the same checks at
|
||||
# HEAD and is allowed to fail, so being behind is LOUD and non-blocking
|
||||
# while being broken stays a hard stop.
|
||||
#
|
||||
# `flamenet-e2e` is public, so no credentials are needed.
|
||||
- FLAMENET_E2E_PIN=$(cat "$CI_PROJECT_DIR/.e2e-ref")
|
||||
- rm -rf "$CI_PROJECT_DIR/.ci-vectors"
|
||||
- git clone -q https://gitlab.com/paulhitt/flamenet-e2e.git "$CI_PROJECT_DIR/.ci-vectors"
|
||||
- git -C "$CI_PROJECT_DIR/.ci-vectors" checkout -q "$FLAMENET_E2E_PIN"
|
||||
- export FLAMENET_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/protocol-v1.json"
|
||||
- export FLAMENET_PQ_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/pqx3dh-v2.json"
|
||||
- export FLAMENET_ACCOUNT_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/account-kdf-v1.json"
|
||||
- export FLAMENET_BACKUP_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/backup-v1.json"
|
||||
- export FLAMENET_BACKUP_PAYLOAD_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/backup-payload-v1.json"
|
||||
- export FLAMENET_CONTENT_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/content-v1.json"
|
||||
- export FLAMENET_POLL_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/poll-tally-v1.json"
|
||||
# Checked before the suite runs. A path that silently does not exist puts
|
||||
# the tests straight back where they were, and the failure would read as a
|
||||
# protocol bug rather than a missing file.
|
||||
- |
|
||||
for f in "$FLAMENET_VECTORS" "$FLAMENET_PQ_VECTORS" "$FLAMENET_ACCOUNT_VECTORS" \
|
||||
"$FLAMENET_BACKUP_VECTORS" "$FLAMENET_BACKUP_PAYLOAD_VECTORS" \
|
||||
"$FLAMENET_CONTENT_VECTORS" "$FLAMENET_POLL_VECTORS"; do
|
||||
[ -f "$f" ] || { echo "GATE: vector file missing: $f"; exit 1; }
|
||||
done
|
||||
# Clear old results before running, or the gate counts ghosts. The
|
||||
# runner reuses its build directory, so XML from a previous pipeline
|
||||
# survives, and a suite that stopped running entirely would still be
|
||||
# counted, by the very check meant to notice that it had stopped.
|
||||
- rm -rf ./*/build/test-results
|
||||
# Keep the build output out of Spotlight.
|
||||
#
|
||||
# The module build directories are rewritten every job on a host that is
|
||||
# also the mac-local runner for both app repos. Indexing them drives the
|
||||
# machine to a load average in the hundreds, with `mds_stores` and
|
||||
# `mdworker_shared` at the top of CPU while no build process is near it.
|
||||
# Measured on the iPhone repo: the same from-scratch build peaks at load
|
||||
# ~12 with its output excluded and well over a hundred without.
|
||||
#
|
||||
# Written here rather than committed, because the directories these belong
|
||||
# in are created by the build.
|
||||
- |
|
||||
for m in app engine client obfs4; do
|
||||
mkdir -p "$m/build" && touch "$m/build/.metadata_never_index"
|
||||
done
|
||||
# `:obfs4:test` was missing from this list. The module shipped in !77
|
||||
# with ~100 tests, the Elligator2 port, the framing, the handshake, the
|
||||
# socket factory, and not one of them ran in CI. A break in the transport
|
||||
# would have merged green.
|
||||
#
|
||||
# `--rerun-tasks` because this runner is the developer's own Mac.
|
||||
# Gradle's build cache is shared with local runs, so a task whose inputs a
|
||||
# local run already covered comes back FROM-CACHE and CI replays a result it
|
||||
# never computed. That is not independent verification, and it is invisible:
|
||||
# a cached pass and a real pass print the same "BUILD SUCCESSFUL".
|
||||
# Both app variants, not just debug. The release variant was never run
|
||||
# here, so four tests that die on it -- the merged manifest has no
|
||||
# ComponentActivity, which is a debug-only dependency -- were red from the
|
||||
# day they landed and CI never said a word. A variant nobody tests is a
|
||||
# variant nobody knows about, and release is the one that ships.
|
||||
- ./gradlew --no-daemon --rerun-tasks :engine:test :client:test :obfs4:test :app:testDebugUnitTest :app:testReleaseUnitTest
|
||||
- |
|
||||
# Assert on the RESULTS, never on Gradle's exit status. `:app` prints
|
||||
# no per-test names at all, so a filter matching nothing and a full green
|
||||
# run are indistinguishable in the log, and a skipped-as-up-to-date task
|
||||
# is likewise just "BUILD SUCCESSFUL". The XML is the only thing that says
|
||||
# what actually executed.
|
||||
python3 - <<'PY'
|
||||
import glob, re, sys
|
||||
|
||||
# Floors, not exact counts. Set around 90% of the suite as it stands
|
||||
# (663 / 453 / 75 / 572) so a module that loses most of its
|
||||
# tests to a bad filter is caught, without a gate that has to be edited
|
||||
# every time somebody adds one.
|
||||
#
|
||||
# `app` counts both variants, because the glob takes every
|
||||
# `test-results/*` directory and both now run.
|
||||
MINIMUMS = {"engine": 595, "client": 405, "obfs4": 68, "app": 515}
|
||||
totals = {}
|
||||
for module in MINIMUMS:
|
||||
tests = failures = skipped = 0
|
||||
for path in glob.glob(f"{module}/build/test-results/*/TEST-*.xml"):
|
||||
text = open(path).read()
|
||||
m = re.search(r'tests="(\d+)" skipped="(\d+)" failures="(\d+)" errors="(\d+)"', text)
|
||||
if not m:
|
||||
continue
|
||||
tests += int(m.group(1)); skipped += int(m.group(2))
|
||||
failures += int(m.group(3)) + int(m.group(4))
|
||||
totals[module] = (tests, failures, skipped)
|
||||
|
||||
bad = False
|
||||
for module, (tests, failures, skipped) in totals.items():
|
||||
print(f"GATE: {module}: {tests} tests, {failures} failures, {skipped} skipped")
|
||||
if tests < MINIMUMS[module]:
|
||||
print(f"GATE: {module} reported {tests} tests, the suite did not run")
|
||||
bad = True
|
||||
if failures:
|
||||
bad = True
|
||||
# A floor across the whole build as well as per module: a module can be
|
||||
# present and still have lost most of its suite to a bad filter.
|
||||
grand = sum(t for t, _, _ in totals.values())
|
||||
print(f"GATE: {grand} tests in total")
|
||||
if grand < 1580:
|
||||
print(f"GATE: only {grand} tests in the whole build; something is not running")
|
||||
bad = True
|
||||
sys.exit(1 if bad else 0)
|
||||
PY
|
||||
timeout: 30m
|
||||
|
||||
# The pin's conscience. Runs the vector suites against flamenet-e2e's
|
||||
# actual HEAD, and is allowed to fail. Without it, `.e2e-ref` is permission to
|
||||
# fall behind forever with a green board, which is the failure mode a pin
|
||||
# introduces and the reason the relay pairs its pin with the same job.
|
||||
#
|
||||
# A red run here means an update is waiting. Take it per `.e2e-ref.md`.
|
||||
vectors-head:
|
||||
stage: test
|
||||
tags: [mac-local]
|
||||
allow_failure: true
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
variables:
|
||||
GIT_DEPTH: "1"
|
||||
OBFS4_INTEROP_OPTOUT: "1"
|
||||
script:
|
||||
- export JAVA_HOME=/opt/homebrew/opt/openjdk@21
|
||||
- export ANDROID_HOME=$HOME/Library/Android/sdk
|
||||
- rm -rf "$CI_PROJECT_DIR/.ci-vectors-head"
|
||||
- git clone --depth 1 https://gitlab.com/paulhitt/flamenet-e2e.git "$CI_PROJECT_DIR/.ci-vectors-head"
|
||||
- |
|
||||
echo "pinned: $(cat "$CI_PROJECT_DIR/.e2e-ref")"
|
||||
echo "at HEAD: $(git -C "$CI_PROJECT_DIR/.ci-vectors-head" rev-parse HEAD)"
|
||||
- export FLAMENET_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/protocol-v1.json"
|
||||
- export FLAMENET_PQ_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/pqx3dh-v2.json"
|
||||
- export FLAMENET_ACCOUNT_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/account-kdf-v1.json"
|
||||
- export FLAMENET_BACKUP_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/backup-v1.json"
|
||||
- export FLAMENET_BACKUP_PAYLOAD_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/backup-payload-v1.json"
|
||||
- export FLAMENET_CONTENT_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/content-v1.json"
|
||||
- export FLAMENET_POLL_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/poll-tally-v1.json"
|
||||
# Only the module that reads vectors. The rest of the suite has nothing
|
||||
# to say about whether flamenet-e2e moved, and running it here would double
|
||||
# the board's cost to answer a question it cannot answer.
|
||||
- ./gradlew --no-daemon --rerun-tasks :engine:test
|
||||
timeout: 20m
|
||||
@@ -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` |
|
||||
|
||||
@@ -47,7 +47,7 @@ less than the two existing engines quietly agreed on, but only if it is
|
||||
written against the spec and the published vectors rather than against a
|
||||
reading of our own Swift and JavaScript. So the engine is held to
|
||||
`test/vectors/protocol-v1.json` in
|
||||
[flamenet-e2e](https://gitlab.com/paulhitt/flamenet-e2e), the same file the
|
||||
[flamenet-e2e](https://git.extant2000.com/extant2000/flamenet-e2e), the same file the
|
||||
other two engines are checked against, read from that repository rather than
|
||||
copied here, because a copied vector file drifts and a drifting vector file
|
||||
stops being a check. `PublishedVectorsTest` looks for it beside this checkout
|
||||
|
||||
@@ -43,14 +43,18 @@ android {
|
||||
// is a label. Code 24 with name 0.24 closes it by skipping the name
|
||||
// 0.23, which costs nothing because a name is a label and may skip.
|
||||
// From here they move together and match: 25 with 0.25, 26 with 0.26.
|
||||
versionCode = 30
|
||||
versionName = "0.30"
|
||||
versionCode = 32
|
||||
versionName = "0.32"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") { java.srcDir("src/main/kotlin") }
|
||||
getByName("androidTest") { java.srcDir("src/androidTest/kotlin") }
|
||||
// Code that differs between the two builds. The Today dev key is
|
||||
// compiled into debug builds and is absent from release.
|
||||
getByName("debug") { java.srcDir("src/debug/kotlin") }
|
||||
getByName("release") { java.srcDir("src/release/kotlin") }
|
||||
}
|
||||
|
||||
buildFeatures { compose = true }
|
||||
@@ -198,6 +202,8 @@ android {
|
||||
dependencies {
|
||||
implementation(project(":engine"))
|
||||
implementation(project(":client"))
|
||||
// The rules of the games played in chats (§6.33). Plain JVM, no Android.
|
||||
implementation(project(":games"))
|
||||
// A direct dependency rather than leaning on `:client` to re-export it.
|
||||
// The settings screen reads bridge lines and the handshake counter itself,
|
||||
// so the app uses these types; borrowing them through another
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTextClearance
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.test.printToString
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import io.flamenet.obfs4.Obfs4Stats
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* The app's own message, carried by an obfs4 bridge.
|
||||
*
|
||||
* 🔴 The claim this exists to back. The answers sent on 2026-09-28 said bridge
|
||||
* users can reach the relay from anywhere. They could not: the bridge
|
||||
* forwarded to plain HTTP while the app speaks TLS through the tunnel, so the
|
||||
* handshake completed and every app session behind it died. The upstream was
|
||||
* fixed on 2026-10-07 and checked with one GET, and flamenet-e2e's
|
||||
* `test/bridge-message.mjs` later carried a real message through it from Node.
|
||||
* None of that exercises THIS code: `Obfs4SocketFactory` under the app's own
|
||||
* OkHttp, the sign-up, the device registration and the send.
|
||||
*
|
||||
* So the bridge goes into force before anything touches the network, and the
|
||||
* handshake counter is asserted at zero first. A test that only checks the
|
||||
* message arrived would pass with the bridge quietly unused, which is the
|
||||
* exact failure the 09-28 answer made in prose.
|
||||
*
|
||||
* Needs a live bridge, a reachable relay and a peer account that already has a
|
||||
* device, so it is opt-in:
|
||||
*
|
||||
* ./gradlew :app:connectedDebugAndroidTest \
|
||||
* -Pandroid.testInstrumentationRunnerArguments.class=io.flamenet.messenger.app.BridgeMessageTest \
|
||||
* -Pandroid.testInstrumentationRunnerArguments.bridge="obfs4 HOST:PORT FINGERPRINT cert=CERT iat-mode=0" \
|
||||
* -Pandroid.testInstrumentationRunnerArguments.peer=<handle>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class BridgeMessageTest {
|
||||
/**
|
||||
* Granted BEFORE the activity launches, and that ordering is the point.
|
||||
*/
|
||||
@get:Rule(order = 0)
|
||||
val permissions: GrantPermissionRule =
|
||||
GrantPermissionRule.grant(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
|
||||
@get:Rule(order = 1)
|
||||
val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
private val args get() = InstrumentationRegistry.getArguments()
|
||||
|
||||
private val bridgeLine: String? get() = args.getString("bridge")
|
||||
|
||||
private val relay: String get() = args.getString("relay") ?: "relay1.flamenet.io"
|
||||
|
||||
/**
|
||||
* Somebody with a published device, so a deposit has a destination.
|
||||
*/
|
||||
private val peerHandle: String? get() = args.getString("peer")
|
||||
|
||||
private lateinit var handle: String
|
||||
|
||||
/**
|
||||
* The control. `-e direct true` runs the same path with no bridge, which
|
||||
* is the only way to tell a flaky bridge from a flaky sign-up.
|
||||
*/
|
||||
private val direct: Boolean get() = args.getString("direct") == "true"
|
||||
|
||||
@Before
|
||||
fun putTheBridgeInForceFirst() {
|
||||
assumeTrue(
|
||||
"pass -e bridge \"<obfs4 line>\" to run this; it needs a live bridge",
|
||||
direct || !bridgeLine.isNullOrBlank(),
|
||||
)
|
||||
assumeTrue(
|
||||
"pass -e peer <handle> to run this; the peer needs a published device",
|
||||
!peerHandle.isNullOrBlank(),
|
||||
)
|
||||
handle = "andb" + (System.currentTimeMillis() % 100_000_000)
|
||||
|
||||
// The same call the Network screen's own button makes, so this is the
|
||||
// app's setting path and not a test-only hook.
|
||||
val settings = Obfs4Settings(InstrumentationRegistry.getInstrumentation().targetContext)
|
||||
settings.clear()
|
||||
if (!direct) {
|
||||
val added = settings.add(bridgeLine!!)
|
||||
if (added !is Obfs4Settings.AddResult.Added) {
|
||||
throw AssertionError("the bridge line was refused: ${added.message}")
|
||||
}
|
||||
}
|
||||
|
||||
signOutIfSignedIn()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theAppSendsAMessageThroughTheBridge() {
|
||||
assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable())
|
||||
|
||||
// Zeroed here and not in @Before, because signing out of whatever the
|
||||
// last run left behind is itself a request, and with the bridge
|
||||
// already in force that request is a handshake. The first run of this
|
||||
// test failed on "the counter started at 1" for exactly that reason.
|
||||
Obfs4Stats.reset()
|
||||
assert(Obfs4Stats.completedHandshakes() == 0) {
|
||||
"the counter would not zero: ${Obfs4Stats.completedHandshakes()}"
|
||||
}
|
||||
|
||||
signUp()
|
||||
|
||||
val afterSignUp = Obfs4Stats.completedHandshakes()
|
||||
if (direct) {
|
||||
assert(afterSignUp == 0) { "the control run went through a bridge anyway" }
|
||||
} else {
|
||||
assert(afterSignUp > 0) {
|
||||
"the sign-up and device registration completed without one obfs4 handshake, " +
|
||||
"so the bridge was saved and bypassed"
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag("add-contact").performClick()
|
||||
compose.onNodeWithTag("new-contact").performTextInput(peerHandle!!)
|
||||
// Adding is gated on a lookup, so a typo cannot become a contact. The
|
||||
// lookup is itself a request through the bridge.
|
||||
compose.onNodeWithTag("lookup-contact").performClick()
|
||||
awaitTag("lookup-result", 30_000)
|
||||
compose.onNodeWithTag("confirm-contact").performClick()
|
||||
|
||||
awaitNode(hasText(peerHandle!!, substring = true), 60_000)
|
||||
compose.onNode(hasText(peerHandle!!, substring = true)).performClick()
|
||||
awaitTag("draft", 20_000)
|
||||
|
||||
val text = "through a bridge from the app at ${System.currentTimeMillis() % 100000}"
|
||||
compose.onNodeWithTag("draft").performTextInput(text)
|
||||
compose.onNodeWithTag("send").performClick()
|
||||
|
||||
// The line appearing is NOT delivery. The app renders an outgoing
|
||||
// message as soon as it is composed: a run that asserted only this
|
||||
// reported OK while the relay never saw a POST /e2e/messages at all.
|
||||
awaitNode(hasText(text, substring = true), 90_000)
|
||||
compose.onNode(hasText(text, substring = true)).assertIsDisplayed()
|
||||
|
||||
// Delivery is the absence of both markers. The row carries `pending`
|
||||
// while the message is still queued and `undelivered` when it failed,
|
||||
// so a row with neither is one the relay accepted.
|
||||
val deadline = System.currentTimeMillis() + 150_000
|
||||
var queued = true
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
settle(1_000)
|
||||
if (nodesWithTag("undelivered").isNotEmpty()) {
|
||||
throw AssertionError("the message is marked undelivered. On screen: ${visibleText()}")
|
||||
}
|
||||
if (nodesWithTag("pending").isEmpty()) {
|
||||
queued = false
|
||||
break
|
||||
}
|
||||
}
|
||||
assert(!queued) { "the message was still pending after 150 seconds" }
|
||||
|
||||
// The counter is the whole evidence, so say what it was at each
|
||||
// stage rather than only that it moved.
|
||||
val afterSend = Obfs4Stats.completedHandshakes()
|
||||
assert(afterSend >= afterSignUp) {
|
||||
"the handshake count went backwards, $afterSignUp then $afterSend"
|
||||
}
|
||||
|
||||
android.util.Log.i(
|
||||
"fnbridge",
|
||||
"sent from $handle to ${peerHandle!!} through the bridge: $text " +
|
||||
"($afterSend obfs4 handshakes, $afterSignUp of them before the send)",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One whole sign-up, driven the way a person drives it.
|
||||
*/
|
||||
private fun signUp() {
|
||||
compose.onNodeWithTag("relay").performTextClearance()
|
||||
compose.onNodeWithTag("relay").performTextInput(relay)
|
||||
|
||||
// The toggle is a toggle, so clicking it blindly is a coin flip, and
|
||||
// one blind click put this test into sign-in mode: it filled a handle
|
||||
// and a passphrase, pressed a button labelled "Sign in", and read the
|
||||
// relay's refusal as "Your session expired". Drive it by what the
|
||||
// screen says instead, and scroll first, because the keyboard opened
|
||||
// by the relay field can leave the toggle outside the window where a
|
||||
// tap lands nowhere and nothing reports it.
|
||||
var attempts = 0
|
||||
while (nodesWithText("Create an account").isNotEmpty() && attempts < 4) {
|
||||
attempts++
|
||||
compose.onNodeWithTag("toggle-register").performScrollTo()
|
||||
compose.onNodeWithTag("toggle-register").performClick()
|
||||
settle(2_000)
|
||||
}
|
||||
if (nodesWithText("Create an account").isNotEmpty()) {
|
||||
throw AssertionError(
|
||||
"the app stayed in sign-in mode after $attempts taps on the toggle. " +
|
||||
"On screen: ${visibleText()}",
|
||||
)
|
||||
}
|
||||
|
||||
// Wait for discovery BEFORE touching the form. The relay says whether
|
||||
// it keeps its own accounts, and the email field appears or disappears
|
||||
// on that answer. Filling the form first put text into a field that
|
||||
// then vanished, left a required field empty, and the submit button
|
||||
// stayed disabled; a disabled submit swallows the tap in silence, so
|
||||
// the first run of this test sat on the sign-up screen for its whole
|
||||
// 180 seconds with nothing to show for it.
|
||||
awaitTag("identity-mode", 60_000)
|
||||
|
||||
// Which fields exist is the relay's decision, so fill what is there.
|
||||
// relay1 asks for neither an email nor a birth year, and typing into a
|
||||
// field that is not on screen is an immediate "Failed to perform text
|
||||
// input" that reads like a broken test.
|
||||
typeIfPresent("email", "$handle@example.invalid")
|
||||
compose.onNodeWithTag("handle").performTextInput(handle)
|
||||
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
|
||||
typeIfPresent("birth-year", "1990")
|
||||
// Scroll first: the app sits inside imePadding(), so an open keyboard
|
||||
// shortens the viewport and the submit button is laid out below it.
|
||||
compose.onNodeWithTag("submit").performScrollTo()
|
||||
compose.onNodeWithTag("submit").performClick()
|
||||
|
||||
// Wait for the signed-in screen for the whole window, and read the
|
||||
// error node only if it never comes.
|
||||
//
|
||||
// 🔴 An earlier version stopped at the first "error" node and reported
|
||||
// a refusal. The relay's own log showed the opposite: signup 200,
|
||||
// token 200, devices 200, identity 200. The app's first attempt
|
||||
// through a cold obfs4 tunnel passed the 25-second callTimeout, put
|
||||
// "The server did not answer" on screen, retried, and finished 97
|
||||
// seconds later. Treating that message as final called a working
|
||||
// sign-up a failure.
|
||||
try {
|
||||
awaitTag("nav-contacts", 240_000)
|
||||
} catch (e: AssertionError) {
|
||||
val shown =
|
||||
runCatching {
|
||||
compose.onAllNodes(hasTestTag("error")).fetchSemanticsNodes().size
|
||||
}.getOrDefault(0)
|
||||
throw AssertionError(
|
||||
"sign-up never reached the signed-in screen ($shown error nodes on screen). " +
|
||||
"On screen: ${visibleText()}",
|
||||
e,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Let recomposition happen, with the clock driven by hand. */
|
||||
private fun settle(millis: Long) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
try {
|
||||
val deadline = System.currentTimeMillis() + millis
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
compose.mainClock.advanceTimeBy(100)
|
||||
Thread.sleep(50)
|
||||
}
|
||||
} finally {
|
||||
compose.mainClock.autoAdvance = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Fill a field the relay may or may not be asking for. */
|
||||
private fun typeIfPresent(
|
||||
tag: String,
|
||||
text: String,
|
||||
) {
|
||||
if (nodesWithTag(tag).isNotEmpty()) {
|
||||
compose.onNodeWithTag(tag).performTextInput(text)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get to a signed-out app.
|
||||
*/
|
||||
private fun signOutIfSignedIn() {
|
||||
awaitAny(listOf("relay", "nav-contacts", "nav-account", "back"), 30_000)
|
||||
if (nodesWithTag("back").isNotEmpty()) {
|
||||
compose.onNodeWithTag("back").performClick()
|
||||
awaitTag("nav-contacts", 20_000)
|
||||
}
|
||||
if (nodesWithTag("nav-account").isEmpty()) return
|
||||
compose.onNodeWithTag("nav-account").performClick()
|
||||
awaitTag("sign-out", 20_000)
|
||||
compose.onNodeWithTag("sign-out").performScrollTo()
|
||||
compose.onNodeWithTag("sign-out").performClick()
|
||||
awaitTag("relay", 30_000)
|
||||
}
|
||||
|
||||
private fun awaitAny(
|
||||
tags: List<String>,
|
||||
timeoutMillis: Long,
|
||||
) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
try {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
compose.mainClock.advanceTimeBy(100)
|
||||
if (tags.any { nodesWithTag(it).isNotEmpty() }) return
|
||||
Thread.sleep(100)
|
||||
}
|
||||
} finally {
|
||||
compose.mainClock.autoAdvance = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun nodesWithText(text: String): List<Any> =
|
||||
runCatching {
|
||||
compose.onAllNodes(hasText(text, substring = true)).fetchSemanticsNodes()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun nodesWithTag(tag: String): List<Any> =
|
||||
runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun awaitTag(
|
||||
tag: String,
|
||||
timeoutMillis: Long,
|
||||
) = awaitNode(hasTestTag(tag), timeoutMillis)
|
||||
|
||||
private fun awaitNode(
|
||||
matcher: androidx.compose.ui.test.SemanticsMatcher,
|
||||
timeoutMillis: Long,
|
||||
) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
try {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
compose.mainClock.advanceTimeBy(100)
|
||||
val found =
|
||||
runCatching {
|
||||
compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()
|
||||
}.getOrDefault(false)
|
||||
if (found) return
|
||||
Thread.sleep(100)
|
||||
}
|
||||
throw AssertionError("nothing matched within ${timeoutMillis}ms. On screen: ${visibleText()}")
|
||||
} finally {
|
||||
compose.mainClock.autoAdvance = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun visibleText(): String =
|
||||
runCatching {
|
||||
compose.onRoot().printToString(maxDepth = 30)
|
||||
}.getOrElse { "(could not read the screen: $it)" }
|
||||
|
||||
private fun relayReachable(): Boolean =
|
||||
runCatching {
|
||||
(java.net.URL("https://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection)
|
||||
.apply {
|
||||
connectTimeout = 8000
|
||||
readTimeout = 8000
|
||||
}.responseCode in 200..299
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import io.flamenet.obfs4.BridgeLine
|
||||
import io.flamenet.obfs4.BridgeLineResult
|
||||
import io.flamenet.obfs4.Obfs4SocketFactory
|
||||
import io.flamenet.obfs4.Obfs4Stats
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
|
||||
/**
|
||||
* Just the transport, so a failure says which layer failed.
|
||||
*
|
||||
* The whole-app test goes through the sign-up screen, and a refusal there
|
||||
* reads as "The server did not answer", which covers a bridge that would not
|
||||
* handshake and a relay that is down with the same sentence. This opens the
|
||||
* tunnel by hand and speaks one HTTP request through it.
|
||||
*/
|
||||
class BridgeTransportProbeTest {
|
||||
private val args get() = androidx.test.platform.app.InstrumentationRegistry.getArguments()
|
||||
|
||||
@Test
|
||||
fun theDeviceCanReachTheRelayThroughTheBridge() {
|
||||
val line = args.getString("bridge")
|
||||
assumeTrue("pass -e bridge \"<obfs4 line>\" to run this", !line.isNullOrBlank())
|
||||
val host = args.getString("relay") ?: "relay1.flamenet.io"
|
||||
|
||||
val parsed = BridgeLine.parse(line!!)
|
||||
if (parsed !is BridgeLineResult.Ok) throw AssertionError("the line was refused: $parsed")
|
||||
|
||||
Obfs4Stats.reset()
|
||||
val plain = Obfs4SocketFactory(parsed.bridge).createSocket()
|
||||
plain.soTimeout = 30_000
|
||||
plain.connect(java.net.InetSocketAddress(host, 443), 30_000)
|
||||
assert(Obfs4Stats.completedHandshakes() == 1) {
|
||||
"the socket connected without an obfs4 handshake"
|
||||
}
|
||||
|
||||
// The app speaks TLS inside the tunnel, so this does too.
|
||||
val tls =
|
||||
(SSLSocketFactory.getDefault() as SSLSocketFactory)
|
||||
.createSocket(plain, host, 443, true) as javax.net.ssl.SSLSocket
|
||||
tls.soTimeout = 30_000
|
||||
tls.startHandshake()
|
||||
val cert = tls.session.peerCertificates.first() as java.security.cert.X509Certificate
|
||||
assert(cert.subjectX500Principal.name.contains(host.substringAfter('.'))) {
|
||||
"the certificate inside the tunnel was for ${cert.subjectX500Principal.name}"
|
||||
}
|
||||
|
||||
tls.outputStream.write(
|
||||
"GET /health HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n".toByteArray(),
|
||||
)
|
||||
tls.outputStream.flush()
|
||||
val status = tls.inputStream.bufferedReader().readLine() ?: ""
|
||||
tls.close()
|
||||
|
||||
assert(status.contains("200")) { "the relay answered: $status" }
|
||||
android.util.Log.i(
|
||||
"fnbridge",
|
||||
"probe: $status through ${parsed.bridge.host}:${parsed.bridge.port}, " +
|
||||
"cert ${cert.subjectX500Principal.name}",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Several tunnels at once, because the app opens several.
|
||||
*
|
||||
* 🔴 Why this case exists. One hand-made socket through the bridge is
|
||||
* reliable: 1.3 seconds, every time. The app's sign-up through the same
|
||||
* bridge is not. One run carried the whole chain and the relay logged
|
||||
* signup, token, devices, identity, buddies and groups all 200. The next
|
||||
* run put nothing on the relay and showed "The server did not answer".
|
||||
* The app's OkHttp client opens more than one connection and each one
|
||||
* needs its own obfs4 handshake, so this is the difference worth testing.
|
||||
*/
|
||||
@Test
|
||||
fun fourTunnelsAtOnceAllReachTheRelay() {
|
||||
val line = args.getString("bridge")
|
||||
assumeTrue("pass -e bridge \"<obfs4 line>\" to run this", !line.isNullOrBlank())
|
||||
val host = args.getString("relay") ?: "relay1.flamenet.io"
|
||||
val parsed = BridgeLine.parse(line!!)
|
||||
if (parsed !is BridgeLineResult.Ok) throw AssertionError("the line was refused: $parsed")
|
||||
|
||||
Obfs4Stats.reset()
|
||||
val factory = Obfs4SocketFactory(parsed.bridge)
|
||||
val results = java.util.Collections.synchronizedList(mutableListOf<String>())
|
||||
val threads =
|
||||
(1..4).map { n ->
|
||||
Thread {
|
||||
results +=
|
||||
runCatching {
|
||||
val plain = factory.createSocket()
|
||||
plain.soTimeout = 40_000
|
||||
plain.connect(java.net.InetSocketAddress(host, 443), 40_000)
|
||||
val tls =
|
||||
(SSLSocketFactory.getDefault() as SSLSocketFactory)
|
||||
.createSocket(plain, host, 443, true) as javax.net.ssl.SSLSocket
|
||||
tls.soTimeout = 40_000
|
||||
tls.startHandshake()
|
||||
tls.outputStream.write(
|
||||
"GET /health HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n"
|
||||
.toByteArray(),
|
||||
)
|
||||
tls.outputStream.flush()
|
||||
val status = tls.inputStream.bufferedReader().readLine() ?: "(no line)"
|
||||
tls.close()
|
||||
"$n: $status"
|
||||
}.getOrElse { "$n: FAILED ${it.javaClass.simpleName}: ${it.message}" }
|
||||
}
|
||||
}
|
||||
threads.forEach { it.start() }
|
||||
threads.forEach { it.join(90_000) }
|
||||
|
||||
android.util.Log.i("fnbridge", "four at once: ${results.joinToString(" | ")}")
|
||||
val bad = results.filter { !it.contains("200") }
|
||||
assert(bad.isEmpty()) {
|
||||
"${bad.size} of 4 concurrent tunnels failed: ${bad.joinToString(" | ")}. " +
|
||||
"${Obfs4Stats.completedHandshakes()} handshakes completed."
|
||||
}
|
||||
assert(Obfs4Stats.completedHandshakes() == 4) {
|
||||
"4 sockets but ${Obfs4Stats.completedHandshakes()} handshakes"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,14 +54,21 @@ class ChatFlowTest {
|
||||
} else {
|
||||
"https"
|
||||
}
|
||||
// The contact on the other side. `peerjvmone` is a standing account on
|
||||
// relay1; the reply case needs somebody answering AS it, which is the
|
||||
// interop Peer in `autoreply` (see PROVEN below). Override with
|
||||
// -Pandroid.testInstrumentationRunnerArguments.peer=<handle>, and run the
|
||||
// reply case ALONE: one autoreply peer pins the first fresh account it
|
||||
// sees and drops a second one, so two ChatFlow methods cannot share it.
|
||||
// The contacts on the other side, both created for this run by
|
||||
// scripts/instrumented.sh. They used to be `peerjvmone`, one standing
|
||||
// account on relay1 that ran out of prekeys and filled with messages nobody
|
||||
// collected, so adding it and delivering to it failed in some runs and not
|
||||
// others. The reply case gets its own peer, running `autoreply`, because
|
||||
// one autoreply peer answers the first new account that writes to it and
|
||||
// drops the rest.
|
||||
private val peerHandle: String
|
||||
get() = InstrumentationRegistry.getArguments().getString("peer") ?: "peerjvmone"
|
||||
get() =
|
||||
InstrumentationRegistry.getArguments().getString("contactPeer")
|
||||
?: error("no contactPeer: run scripts/instrumented.sh, which creates a fresh one")
|
||||
private val echoPeer: String
|
||||
get() =
|
||||
InstrumentationRegistry.getArguments().getString("echoPeer")
|
||||
?: error("no echoPeer: run scripts/instrumented.sh, which creates one and runs autoreply")
|
||||
private lateinit var handle: String
|
||||
|
||||
@Before
|
||||
@@ -117,14 +124,14 @@ class ChatFlowTest {
|
||||
|
||||
signUp()
|
||||
compose.onNodeWithTag("add-contact").performClick()
|
||||
compose.onNodeWithTag("new-contact").performTextInput(peerHandle)
|
||||
compose.onNodeWithTag("new-contact").performTextInput(echoPeer)
|
||||
// Adding is gated on a lookup: the button stays disabled until the
|
||||
// handle resolves, so a typo cannot become a contact.
|
||||
compose.onNodeWithTag("lookup-contact").performClick()
|
||||
awaitTag("lookup-result", 30_000)
|
||||
compose.onNodeWithTag("confirm-contact").performClick()
|
||||
awaitNode(hasText(peerHandle, substring = true), 60_000)
|
||||
compose.onNode(hasText(peerHandle, substring = true)).performClick()
|
||||
awaitNode(hasText(echoPeer, substring = true), 60_000)
|
||||
compose.onNode(hasText(echoPeer, substring = true)).performClick()
|
||||
awaitTag("draft", 20_000)
|
||||
|
||||
val text = "ping ${System.currentTimeMillis() % 100000}"
|
||||
@@ -134,15 +141,10 @@ class ChatFlowTest {
|
||||
|
||||
// The app polls every few seconds; the peer echoes every couple.
|
||||
val echo = "echo: $text"
|
||||
val arrived =
|
||||
runCatching {
|
||||
awaitNode(hasText(echo, substring = true), 90_000)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
if (!arrived) {
|
||||
android.util.Log.w("fnchat", "no echo for '$text', is the peer running autoreply?")
|
||||
return
|
||||
}
|
||||
// A missing echo is a failure. This used to log a warning and return,
|
||||
// which passed the test while proving nothing; with the script running
|
||||
// the echo peer, silence means the round trip is broken.
|
||||
awaitNode(hasText(echo, substring = true), 90_000)
|
||||
compose.onNode(hasText(echo, substring = true)).assertIsDisplayed()
|
||||
android.util.Log.i("fnchat", "round trip complete: $echo")
|
||||
}
|
||||
@@ -150,9 +152,14 @@ class ChatFlowTest {
|
||||
// -- Helpers -------------------------------------------------------------.
|
||||
|
||||
private fun signUp() {
|
||||
// Switched to sign-up BEFORE the relay is typed. Typing raises the keyboard,
|
||||
// which slides in after performScrollTo has measured, so the tap landed on
|
||||
// the keyboard and the form stayed on sign-in. Nothing is moving yet here.
|
||||
compose.onNodeWithTag("toggle-register").performScrollTo().performClick()
|
||||
compose.onNodeWithTag("relay").performTextClearance()
|
||||
compose.onNodeWithTag("relay").performTextInput(relay)
|
||||
compose.onNodeWithTag("toggle-register").performClick()
|
||||
// Waited for, not asserted: the probe decides whether this relay asks for one.
|
||||
awaitTag("email", 30_000)
|
||||
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
|
||||
compose.onNodeWithTag("handle").performTextInput(handle)
|
||||
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
|
||||
|
||||
@@ -44,9 +44,21 @@ class CrossPlatformTest {
|
||||
settle()
|
||||
if (nodesWithTag("nav-contacts").isNotEmpty()) return // already signed in
|
||||
|
||||
// Switched to sign-up BEFORE the relay is typed. Typing raises the keyboard,
|
||||
|
||||
// which slides in after performScrollTo has measured, so the tap landed on
|
||||
|
||||
// the keyboard and the form stayed on sign-in. Nothing is moving yet here.
|
||||
|
||||
compose.onNodeWithTag("toggle-register").performScrollTo().performClick()
|
||||
|
||||
compose.onNodeWithTag("relay").performTextClearance()
|
||||
|
||||
compose.onNodeWithTag("relay").performTextInput(relay)
|
||||
compose.onNodeWithTag("toggle-register").performClick()
|
||||
|
||||
// Waited for, not asserted: the probe decides whether this relay asks for one.
|
||||
|
||||
awaitTag("email", 30_000)
|
||||
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
|
||||
compose.onNodeWithTag("handle").performTextInput(handle)
|
||||
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
|
||||
|
||||
@@ -56,7 +56,11 @@ class CrudSurfaceTest {
|
||||
} else {
|
||||
"https"
|
||||
}
|
||||
private val peerHandle = "peerjvmone"
|
||||
/** Created for this run by scripts/instrumented.sh; see FeatureUseTest. */
|
||||
private val peerHandle: String
|
||||
get() =
|
||||
InstrumentationRegistry.getArguments().getString("contactPeer")
|
||||
?: error("no contactPeer: run scripts/instrumented.sh, which creates a fresh one")
|
||||
private lateinit var handle: String
|
||||
|
||||
@Before
|
||||
@@ -83,9 +87,14 @@ class CrudSurfaceTest {
|
||||
compose.onNodeWithTag("toggle-reset").performClick()
|
||||
|
||||
// Registering a local account asks for an email; signing in does not.
|
||||
// Switched to sign-up BEFORE the relay is typed. Typing raises the keyboard,
|
||||
// which slides in after performScrollTo has measured, so the tap landed on
|
||||
// the keyboard and the form stayed on sign-in. Nothing is moving yet here.
|
||||
compose.onNodeWithTag("toggle-register").performScrollTo().performClick()
|
||||
compose.onNodeWithTag("relay").performTextClearance()
|
||||
compose.onNodeWithTag("relay").performTextInput(relay)
|
||||
compose.onNodeWithTag("toggle-register").performClick()
|
||||
// Waited for, not asserted: the probe decides whether this relay asks for one.
|
||||
awaitTag("email", 30_000)
|
||||
compose.onNodeWithTag("email").assertExists()
|
||||
}
|
||||
|
||||
@@ -218,9 +227,14 @@ class CrudSurfaceTest {
|
||||
}
|
||||
|
||||
private fun signUp() {
|
||||
// Switched to sign-up BEFORE the relay is typed. Typing raises the keyboard,
|
||||
// which slides in after performScrollTo has measured, so the tap landed on
|
||||
// the keyboard and the form stayed on sign-in. Nothing is moving yet here.
|
||||
compose.onNodeWithTag("toggle-register").performScrollTo().performClick()
|
||||
compose.onNodeWithTag("relay").performTextClearance()
|
||||
compose.onNodeWithTag("relay").performTextInput(relay)
|
||||
compose.onNodeWithTag("toggle-register").performClick()
|
||||
// Waited for, not asserted: the probe decides whether this relay asks for one.
|
||||
awaitTag("email", 30_000)
|
||||
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
|
||||
compose.onNodeWithTag("handle").performTextInput(handle)
|
||||
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
|
||||
|
||||
@@ -48,6 +48,16 @@ class FeatureUseTest {
|
||||
InstrumentationRegistry.getArguments().getString("relay")
|
||||
?: "relay1.flamenet.io"
|
||||
|
||||
/**
|
||||
* The contact, an account scripts/instrumented.sh creates for this run.
|
||||
* Not a standing account: the one these suites used to share had run out of
|
||||
* prekeys and filled with messages nobody collected.
|
||||
*/
|
||||
private val peer: String
|
||||
get() =
|
||||
InstrumentationRegistry.getArguments().getString("contactPeer")
|
||||
?: error("no contactPeer: run scripts/instrumented.sh, which creates a fresh one")
|
||||
|
||||
/** A local relay is plain HTTP; anything else is not. */
|
||||
private val relayScheme: String get() =
|
||||
if (relay.startsWith("10.0.2.2") ||
|
||||
@@ -120,8 +130,13 @@ class FeatureUseTest {
|
||||
compose.onNodeWithTag("create-group").performClick()
|
||||
|
||||
// The group list carries a `group-<id>` row once the relay answers.
|
||||
// The id is the relay's randomHexToken(), not a number. Matching
|
||||
// `group-\d+` only worked when the hex happened to start with a digit,
|
||||
// ten times in sixteen, so the test failed a third of the time on a
|
||||
// group that was plainly there. Sixteen or more hex characters keeps
|
||||
// `group-name` and `group-member-N` from matching instead.
|
||||
awaitNodeMatching(
|
||||
Regex("group-\\d+"),
|
||||
Regex("group-[0-9a-f]{16,}"),
|
||||
150_000,
|
||||
"the group never appeared in the list after Create",
|
||||
)
|
||||
@@ -191,18 +206,22 @@ class FeatureUseTest {
|
||||
compose.onNodeWithTag("add-contact").performScrollTo()
|
||||
compose.onNodeWithTag("add-contact").performClick()
|
||||
awaitTag("new-contact", 20_000)
|
||||
compose.onNodeWithTag("new-contact").performTextInput(PEER)
|
||||
compose.onNodeWithTag("new-contact").performTextInput(peer)
|
||||
// Look up, THEN add.
|
||||
compose.onNodeWithTag("lookup-contact").performScrollTo()
|
||||
compose.onNodeWithTag("lookup-contact").performClick()
|
||||
awaitTag("confirm-contact", 30_000)
|
||||
// Wait for the RESULT, not the button. `confirm-contact` is on screen,
|
||||
// disabled, until the handle resolves, so waiting for it returned at
|
||||
// once and the tap landed on a disabled button. ChatFlowTest and
|
||||
// CrudSurfaceTest already wait for this.
|
||||
awaitTag("lookup-result", 30_000)
|
||||
compose.onNodeWithTag("confirm-contact").performScrollTo()
|
||||
compose.onNodeWithTag("confirm-contact").performClick()
|
||||
// `buddy-circle-N` as well as `buddy-N`.
|
||||
awaitNodeMatching(
|
||||
Regex("buddy-(circle-)?\\d+"),
|
||||
150_000,
|
||||
"'$PEER' never became a contact, does that account exist on $relay?",
|
||||
"'$peer' never became a contact, does that account exist on $relay?",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -216,9 +235,14 @@ class FeatureUseTest {
|
||||
}
|
||||
|
||||
private fun signUp() {
|
||||
// Switched to sign-up BEFORE the relay is typed. Typing raises the keyboard,
|
||||
// which slides in after performScrollTo has measured, so the tap landed on
|
||||
// the keyboard and the form stayed on sign-in. Nothing is moving yet here.
|
||||
compose.onNodeWithTag("toggle-register").performScrollTo().performClick()
|
||||
compose.onNodeWithTag("relay").performTextClearance()
|
||||
compose.onNodeWithTag("relay").performTextInput(relay)
|
||||
compose.onNodeWithTag("toggle-register").performClick()
|
||||
// Waited for, not asserted: the probe decides whether this relay asks for one.
|
||||
awaitTag("email", 30_000)
|
||||
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
|
||||
compose.onNodeWithTag("handle").performTextInput(handle)
|
||||
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
|
||||
@@ -329,9 +353,4 @@ class FeatureUseTest {
|
||||
readTimeout = 8000
|
||||
}.responseCode in 200..299
|
||||
}.getOrDefault(false)
|
||||
|
||||
private companion object {
|
||||
/** A standing account on relay1 that other on-device tests also use. */
|
||||
const val PEER = "peerjvmone"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,9 +85,11 @@ class LiveSignUpFleetTest {
|
||||
if (!reachable(relay)) return
|
||||
|
||||
val name = "$handle$tag"
|
||||
// Switched to sign-up BEFORE the relay is typed: the keyboard slides in after
|
||||
// performScrollTo has measured, and a tap under it misses without an error.
|
||||
compose.onNodeWithTag("toggle-register").performScrollTo().performClick()
|
||||
compose.onNodeWithTag("relay").performTextClearance()
|
||||
compose.onNodeWithTag("relay").performTextInput(relay)
|
||||
compose.onNodeWithTag("toggle-register").performClick()
|
||||
|
||||
// Wait for discovery BEFORE deciding anything about the form.
|
||||
awaitTag("identity-mode", 30_000)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
/**
|
||||
* DEBUG BUILDS ONLY. The release source set has its own copy of this object
|
||||
* with no keys in it, so a release APK never trusts a file signed for testing.
|
||||
*/
|
||||
internal object TodayDevKey {
|
||||
/** The 0.32 dev key, kid a9a94e74. Public, and for local testing only. */
|
||||
val KEYS: List<String> = listOf("o6fwSoRjw0zldgUOpSClF9/Z2ySQcf0bTDtmtoDgFoA=")
|
||||
}
|
||||
@@ -103,6 +103,49 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!--
|
||||
Two kinds of link open this activity.
|
||||
|
||||
Contact links (spec 8.12): the QR form,
|
||||
flamenet:<fnid>@<relay>?u=..., and the web form,
|
||||
https://<relay>/add#..., whose payload is in the fragment, which
|
||||
no server ever receives. Not verified app links: any relay can
|
||||
issue one, so Android asks which app to open it with.
|
||||
|
||||
Hello links (SPEC 6.34): flamenetmessenger://hi#<address>, which the
|
||||
relay's /hi page links to. Any page or app can open one, so all it
|
||||
does is fill in the add screen; nothing is sent until the person
|
||||
chooses to write. The whole scheme is claimed so that a malformed
|
||||
link reaches the app and gets a plain answer.
|
||||
-->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="flamenet" />
|
||||
<data android:scheme="flamenetmessenger" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="*.flamenet.io" android:pathPrefix="/add" />
|
||||
</intent-filter>
|
||||
<!--
|
||||
The relays the app ships pointing at, verified: each serves
|
||||
/.well-known/assetlinks.json naming this package and the release
|
||||
certificate, so their links open here without asking. Separate
|
||||
from the wildcard filter above, which cannot be verified.
|
||||
-->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="relay.flamenet.io" />
|
||||
<data android:host="relay1.flamenet.io" />
|
||||
<data android:pathPrefix="/add" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
@@ -0,0 +1,28 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import io.flamenet.e2e.Vault
|
||||
|
||||
/** What happens to the vault when somebody signs in. */
|
||||
object AccountSwitch {
|
||||
/**
|
||||
* Before a different account takes over this install, forget the last
|
||||
* one's records: its conversations, sessions, delivery keys, drafts and
|
||||
* handle certificate. A plain sign-out keeps the vault so the SAME person
|
||||
* can sign back in; another account on the same install got a new device
|
||||
* but inherited the rest, saw the last account's conversations and sent
|
||||
* on its ratchet sessions, which the far end could not open. The last
|
||||
* account's device was already replaced at this point, so what is
|
||||
* forgotten could not have been used by it again.
|
||||
*
|
||||
* True when something was forgotten.
|
||||
*/
|
||||
fun forgetPreviousAccountIfSwitching(
|
||||
vault: Vault,
|
||||
previous: Vault.Credential?,
|
||||
sameAccount: Boolean,
|
||||
): Boolean {
|
||||
if (previous == null || sameAccount) return false
|
||||
vault.forgetEverything()
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package io.flamenet.messenger.app
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -24,33 +25,34 @@ import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.e2e.QrCode
|
||||
import io.flamenet.messenger.R
|
||||
import android.graphics.Color as AndroidColor
|
||||
|
||||
/**
|
||||
* Your address, as a code somebody can scan and a string they can copy.
|
||||
* Your address, as a code somebody can scan and a string they can copy, and
|
||||
* the hello link (§6.34) that carries it.
|
||||
*/
|
||||
@Composable
|
||||
fun AddressCard(
|
||||
fnid: String?,
|
||||
relay: String,
|
||||
handle: String? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text("Your address", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.social_your_address), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
|
||||
if (fnid == null) {
|
||||
// Said plainly rather than hidden.
|
||||
Text(
|
||||
"This device could not derive your address. You can still message " +
|
||||
"people on $relay, but nobody on another relay can reach you.",
|
||||
stringResource(R.string.social_address_not_derived, relay),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.testTag("address-missing"),
|
||||
)
|
||||
@@ -58,13 +60,11 @@ fun AddressCard(
|
||||
}
|
||||
|
||||
val address = "$fnid@$relay"
|
||||
// The handle rides in the CODE only, never in the copyable string, it
|
||||
// lets a same-relay scanner resolve the account through the ordinary
|
||||
// username lookup.
|
||||
val encoded =
|
||||
"flamenet:$address" +
|
||||
(handle?.takeIf { it.isNotBlank() }?.let { "?u=$it" } ?: "")
|
||||
val bitmap = remember(encoded) { qrBitmap(encoded) }
|
||||
// The code holds the hello link, so a phone's own camera opens the
|
||||
// relay's page for it and this app's scanner reads the address out of
|
||||
// it. Null only for an address that is not one, which nothing derives.
|
||||
val hello = HelloLink.forAddress(address)
|
||||
val bitmap = remember(hello, address) { qrBitmap(hello ?: address) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -73,7 +73,12 @@ fun AddressCard(
|
||||
if (bitmap != null) {
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = "Your address as a QR code",
|
||||
contentDescription =
|
||||
if (hello != null) {
|
||||
stringResource(R.string.social_address_qr_hello_desc)
|
||||
} else {
|
||||
stringResource(R.string.social_address_qr_desc)
|
||||
},
|
||||
// None, or the modules blur when scaled up and the code
|
||||
// stops scanning, the default filtering is smooth.
|
||||
filterQuality = FilterQuality.None,
|
||||
@@ -104,20 +109,75 @@ fun AddressCard(
|
||||
OutlinedButton(
|
||||
onClick = { copy(context, "Flamenet address", address) },
|
||||
modifier = Modifier.testTag("copy-address"),
|
||||
) { Text("Copy address") }
|
||||
) { Text(stringResource(R.string.social_copy_address)) }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Text(
|
||||
"Derived from your passphrase, not issued by a server. Sign in anywhere " +
|
||||
"with the same passphrase and this stays the same, which is what " +
|
||||
"lets it outlive any one relay.",
|
||||
stringResource(R.string.social_address_explainer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
if (hello != null) {
|
||||
Spacer(Modifier.height(Space.lg))
|
||||
HelloLinkSection(hello)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The hello link: your address as a link anyone can open, to put on a FlameNet
|
||||
* profile or anywhere else.
|
||||
*/
|
||||
@Composable
|
||||
private fun HelloLinkSection(link: String) {
|
||||
val context = LocalContext.current
|
||||
Text("Hello link", style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
Text(
|
||||
link,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.testTag("hello-link"),
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||
OutlinedButton(
|
||||
onClick = { copy(context, "Flamenet hello link", link) },
|
||||
modifier = Modifier.testTag("copy-hello-link"),
|
||||
) { Text("Copy link") }
|
||||
OutlinedButton(
|
||||
onClick = { share(context, link) },
|
||||
modifier = Modifier.testTag("share-hello-link"),
|
||||
) { Text("Share link") }
|
||||
}
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Text(
|
||||
HELLO_LINK_EXPLAINED,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.testTag("hello-link-explained"),
|
||||
)
|
||||
}
|
||||
|
||||
/** What a hello link does, and what it does not. */
|
||||
internal const val HELLO_LINK_EXPLAINED =
|
||||
"Put it on your FlameNet profile, or anywhere you like. Whoever opens it can " +
|
||||
"ask to message you. FlameNet doesn't learn anything from the messenger, and " +
|
||||
"the messenger doesn't learn your FlameNet name."
|
||||
|
||||
/** The system's share sheet. Nothing is sent by this app. */
|
||||
private fun share(
|
||||
context: Context,
|
||||
link: String,
|
||||
) {
|
||||
val send =
|
||||
Intent(Intent.ACTION_SEND)
|
||||
.setType("text/plain")
|
||||
.putExtra(Intent.EXTRA_TEXT, link)
|
||||
runCatching { context.startActivity(Intent.createChooser(send, "Share your hello link")) }
|
||||
}
|
||||
|
||||
private fun copy(
|
||||
context: Context,
|
||||
label: String,
|
||||
@@ -130,7 +190,7 @@ private fun copy(
|
||||
/**
|
||||
* One module per pixel plus a quiet zone; Compose scales it up.
|
||||
*/
|
||||
private fun qrBitmap(
|
||||
internal fun qrBitmap(
|
||||
text: String,
|
||||
quiet: Int = 4,
|
||||
): Bitmap? {
|
||||
|
||||
@@ -15,10 +15,12 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import io.flamenet.messenger.R
|
||||
|
||||
/**
|
||||
* A fingerprint, a face, or the device PIN in front of the app.
|
||||
@@ -160,9 +162,9 @@ class AppLock(
|
||||
if (seconds <= 0) return null
|
||||
return if (seconds >= 60) {
|
||||
val minutes = (seconds + 59) / 60
|
||||
"Too many attempts. Try again in $minutes ${if (minutes == 1) "minute" else "minutes"}."
|
||||
context.resources.getQuantityString(R.plurals.set_lock_wait_minutes, minutes, minutes)
|
||||
} else {
|
||||
"Too many attempts. Try again in $seconds ${if (seconds == 1) "second" else "seconds"}."
|
||||
context.resources.getQuantityString(R.plurals.set_lock_wait_seconds, seconds, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,8 +220,8 @@ class AppLock(
|
||||
prompt.authenticate(
|
||||
BiometricPrompt.PromptInfo
|
||||
.Builder()
|
||||
.setTitle("Unlock Flamenet")
|
||||
.setSubtitle("Your messages are locked on this device.")
|
||||
.setTitle(activity.getString(R.string.set_lock_prompt_title))
|
||||
.setSubtitle(activity.getString(R.string.set_lock_prompt_subtitle))
|
||||
.setAllowedAuthenticators(AUTHENTICATORS)
|
||||
.build(),
|
||||
)
|
||||
@@ -243,14 +245,14 @@ fun LockScreen(onUnlock: () -> Unit) {
|
||||
modifier = Modifier.size(Space.xxl),
|
||||
)
|
||||
Spacer(Modifier.height(Space.lg))
|
||||
Text("Flamenet is locked", fontSize = 20.sp, fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.set_lock_locked_title), fontSize = 20.sp, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Text(
|
||||
"Unlock this device to read your messages.",
|
||||
stringResource(R.string.set_lock_locked_body),
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.xl))
|
||||
KitPrimaryButton(text = "Unlock", onClick = onUnlock, tag = "unlock")
|
||||
KitPrimaryButton(text = stringResource(R.string.common_unlock), onClick = onUnlock, tag = "unlock")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,11 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.client.UpdateCheck
|
||||
import io.flamenet.messenger.R
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -195,19 +197,11 @@ fun UpdateAvailableDialog(baseUrl: String?) {
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { showing = false },
|
||||
title = { Text("Version ${release.versionName} is out") },
|
||||
title = { Text(stringResource(R.string.set_update_version_out, release.versionName)) },
|
||||
text = {
|
||||
val newerBuild = stringResource(R.string.set_update_newer_build)
|
||||
Text(
|
||||
buildString {
|
||||
append(
|
||||
release.notes.ifBlank {
|
||||
"A newer build of Flamenet Messenger is published."
|
||||
},
|
||||
)
|
||||
append("\n\nThis app installs by hand rather than through a store, ")
|
||||
append("so it cannot update itself. Downloading opens your browser; ")
|
||||
append("installing over the top keeps your messages and your account.")
|
||||
},
|
||||
stringResource(R.string.set_update_body, release.notes.ifBlank { newerBuild }),
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
},
|
||||
@@ -218,7 +212,7 @@ fun UpdateAvailableDialog(baseUrl: String?) {
|
||||
showing = false
|
||||
},
|
||||
modifier = Modifier.testTag("update-download"),
|
||||
) { Text("Download", fontWeight = FontWeight.Medium) }
|
||||
) { Text(stringResource(R.string.set_update_download), fontWeight = FontWeight.Medium) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
@@ -228,7 +222,7 @@ fun UpdateAvailableDialog(baseUrl: String?) {
|
||||
showing = false
|
||||
},
|
||||
modifier = Modifier.testTag("update-later"),
|
||||
) { Text("Not now", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
) { Text(stringResource(R.string.common_not_now), color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
|
||||
/**
|
||||
* Pictures shipped in the app's assets (sticker art, card fronts), decoded
|
||||
* once and kept. A path that will not load is remembered as missing, so the
|
||||
* caller draws its fallback without trying again on every frame.
|
||||
*/
|
||||
object AssetImages {
|
||||
private val cache = java.util.concurrent.ConcurrentHashMap<String, java.util.Optional<ImageBitmap>>()
|
||||
|
||||
fun load(
|
||||
context: Context,
|
||||
path: String,
|
||||
): ImageBitmap? =
|
||||
cache
|
||||
.getOrPut(path) {
|
||||
java.util.Optional.ofNullable(
|
||||
runCatching {
|
||||
context.assets.open(path).use { BitmapFactory.decodeStream(it)?.asImageBitmap() }
|
||||
}.getOrNull(),
|
||||
)
|
||||
}.orElse(null)
|
||||
}
|
||||
@@ -357,6 +357,8 @@ class CallManager(
|
||||
}
|
||||
}
|
||||
|
||||
// The one place every call path builds its connection (§24.6).
|
||||
if (CallPolicy.bridgeRefusal() != null) return null
|
||||
val pc = ensureFactory().createPeerConnection(config, observer) ?: return null
|
||||
val source = ensureFactory().createAudioSource(MediaConstraints())
|
||||
val track = ensureFactory().createAudioTrack("fnaudio", source)
|
||||
@@ -413,6 +415,7 @@ class CallManager(
|
||||
// Supervision: enforced where the call would start, not where the
|
||||
// button is drawn, every entry point hits the same wall.
|
||||
if (ParentalControls.callsDisabled(context)) return
|
||||
if (CallPolicy.bridgeRefusal() != null) return
|
||||
if (state != State.IDLE && state != State.ENDED) return
|
||||
val callId = UUID.randomUUID().toString()
|
||||
// `current` BEFORE the state change.
|
||||
@@ -494,7 +497,7 @@ class CallManager(
|
||||
Content.CallSignal.OFFER -> {
|
||||
// Supervision: a device with calls off does not ring, the
|
||||
// caller is answered busy, like any other unavailable state.
|
||||
if (ParentalControls.callsDisabled(context)) {
|
||||
if (ParentalControls.callsDisabled(context) || CallPolicy.bridgeRefusal() != null) {
|
||||
runCatching {
|
||||
signal(
|
||||
fromUser,
|
||||
@@ -967,6 +970,7 @@ class CallManager(
|
||||
}
|
||||
|
||||
fun accept(turn: JSONObject?) {
|
||||
if (CallPolicy.bridgeRefusal() != null) return
|
||||
val (fromUser, offer) = pendingOffer ?: return
|
||||
pendingOffer = null
|
||||
tellOwnDevices?.invoke(
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import io.flamenet.client.AroundTheBridge
|
||||
|
||||
/**
|
||||
* Whether a call may connect now. A call's media goes to the other person or
|
||||
* a TURN server, never through a bridge, so while one is saved calls are off
|
||||
* (SPEC §24.6).
|
||||
*/
|
||||
object CallPolicy {
|
||||
/** Why a call cannot connect now, or null. */
|
||||
fun bridgeRefusal(): String? = if (AroundTheBridge.active) AroundTheBridge.CALLS_OFF else null
|
||||
}
|
||||
@@ -30,12 +30,14 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.messenger.R
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
@@ -98,7 +100,7 @@ fun CallScreenBody(
|
||||
Text(status, fontSize = 14.sp, color = Palette.SecondaryText)
|
||||
// Said plainly and always: it is the reason to use this at all.
|
||||
Text(
|
||||
"End-to-end encrypted",
|
||||
stringResource(R.string.set_call_e2e),
|
||||
fontSize = 10.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
@@ -215,23 +217,23 @@ fun LiveCallScreen(
|
||||
val status =
|
||||
when (call.state) {
|
||||
CallManager.State.RINGING -> {
|
||||
if (call.incoming) "Incoming call" else "Ringing…"
|
||||
if (call.incoming) stringResource(R.string.set_call_incoming) else stringResource(R.string.set_call_ringing)
|
||||
}
|
||||
|
||||
CallManager.State.DIALLING -> {
|
||||
"Calling…"
|
||||
stringResource(R.string.set_call_calling)
|
||||
}
|
||||
|
||||
CallManager.State.CONNECTING -> {
|
||||
"Connecting…"
|
||||
stringResource(R.string.common_connecting)
|
||||
}
|
||||
|
||||
CallManager.State.ACTIVE -> {
|
||||
"On a call"
|
||||
stringResource(R.string.set_call_on_a_call)
|
||||
}
|
||||
|
||||
else -> {
|
||||
"Call ended"
|
||||
stringResource(R.string.set_call_ended)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +244,7 @@ fun LiveCallScreen(
|
||||
CallAction(
|
||||
"decline",
|
||||
KitIcon.CallEnd,
|
||||
"Decline",
|
||||
stringResource(R.string.common_decline),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
iconColor = Color.White,
|
||||
run = model::declineCall,
|
||||
@@ -250,7 +252,7 @@ fun LiveCallScreen(
|
||||
CallAction(
|
||||
"accept",
|
||||
KitIcon.Call,
|
||||
"Answer",
|
||||
stringResource(R.string.common_answer),
|
||||
isPrimary = true,
|
||||
tint = Palette.Teal,
|
||||
iconColor = Color.White,
|
||||
@@ -264,7 +266,7 @@ fun LiveCallScreen(
|
||||
CallAction(
|
||||
"mute",
|
||||
if (call.muted) KitIcon.MicOff else KitIcon.Mic,
|
||||
if (call.muted) "Unmute" else "Mute",
|
||||
if (call.muted) stringResource(R.string.common_unmute) else stringResource(R.string.common_mute),
|
||||
tint = if (call.muted) MaterialTheme.colorScheme.primary else null,
|
||||
iconColor = if (call.muted) Color.White else null,
|
||||
run = { model.setCallMuted(!call.muted) },
|
||||
@@ -272,7 +274,7 @@ fun LiveCallScreen(
|
||||
CallAction(
|
||||
"end",
|
||||
KitIcon.CallEnd,
|
||||
"End",
|
||||
stringResource(R.string.set_call_end),
|
||||
isPrimary = true,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
iconColor = Color.White,
|
||||
@@ -287,7 +289,7 @@ fun LiveCallScreen(
|
||||
CallAction(
|
||||
"handoff",
|
||||
KitIcon.Handoff,
|
||||
"Move",
|
||||
stringResource(R.string.set_call_move),
|
||||
run = model::offerHandoff,
|
||||
),
|
||||
)
|
||||
@@ -301,7 +303,7 @@ fun LiveCallScreen(
|
||||
CallAction(
|
||||
"end",
|
||||
KitIcon.CallEnd,
|
||||
"Cancel",
|
||||
stringResource(R.string.common_cancel),
|
||||
isPrimary = true,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
iconColor = Color.White,
|
||||
@@ -346,8 +348,7 @@ fun LiveCallScreen(
|
||||
floating = floating,
|
||||
warning =
|
||||
if (call.relayless && call.state != CallManager.State.ACTIVE) {
|
||||
"This server has no call relay, so the call only connects if a " +
|
||||
"direct path exists between the two networks."
|
||||
stringResource(R.string.set_call_no_relay_warning)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
@@ -381,7 +382,7 @@ private fun VideoCallBody(
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
"Waiting for their camera…",
|
||||
stringResource(R.string.set_call_waiting_camera),
|
||||
color = Color.White,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
@@ -558,9 +559,9 @@ fun ColumnScope.HandoffBanner(
|
||||
horizontalArrangement = Arrangement.spacedBy(Space.sm),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Take the call?", fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(R.string.set_call_take_title), fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"On your other device, with ${phase.call.peerName}",
|
||||
stringResource(R.string.set_call_take_other_device, phase.call.peerName),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -568,7 +569,7 @@ fun ColumnScope.HandoffBanner(
|
||||
TextButton(
|
||||
onClick = { model.takeHandoff() },
|
||||
modifier = Modifier.testTag("handoff-take"),
|
||||
) { Text("Take") }
|
||||
TextButton(onClick = { model.dismissHandoff() }) { Text("Not now") }
|
||||
) { Text(stringResource(R.string.set_call_take)) }
|
||||
TextButton(onClick = { model.dismissHandoff() }) { Text(stringResource(R.string.common_not_now)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,12 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.messenger.R
|
||||
|
||||
/**
|
||||
* Calls placed and received, the kit's `08 Calls-Listing`.
|
||||
@@ -41,11 +43,11 @@ fun ColumnScope.CallsScreen(
|
||||
androidx.compose.runtime.mutableStateOf(false)
|
||||
}
|
||||
|
||||
PortalHeader(title = "Calls") {
|
||||
PortalHeader(title = stringResource(R.string.set_calls_title)) {
|
||||
// The way IN.
|
||||
PortalHeaderIcon(
|
||||
icon = KitIcon.Call,
|
||||
contentDescription = "New call",
|
||||
contentDescription = stringResource(R.string.set_calls_new_call),
|
||||
modifier = Modifier.testTag("new-call"),
|
||||
onClick = { showNewCall.value = true },
|
||||
)
|
||||
@@ -54,10 +56,10 @@ fun ColumnScope.CallsScreen(
|
||||
if (showNewCall.value) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { showNewCall.value = false },
|
||||
title = { Text("New call") },
|
||||
title = { Text(stringResource(R.string.set_calls_new_call)) },
|
||||
text = {
|
||||
if (phase.buddies.isEmpty()) {
|
||||
Text("Add a contact first, calls go to people on your list.")
|
||||
Text(stringResource(R.string.set_calls_add_contact_first))
|
||||
} else {
|
||||
Column {
|
||||
phase.buddies.forEach { buddy ->
|
||||
@@ -87,7 +89,7 @@ fun ColumnScope.CallsScreen(
|
||||
Spacer(Modifier.width(Space.md))
|
||||
Icon(
|
||||
KitIcon.Video,
|
||||
contentDescription = "Video call",
|
||||
contentDescription = stringResource(R.string.set_calls_video_call),
|
||||
tint = Palette.Accent,
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -105,7 +107,7 @@ fun ColumnScope.CallsScreen(
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(
|
||||
onClick = { showNewCall.value = false },
|
||||
) { Text("Cancel") }
|
||||
) { Text(stringResource(R.string.common_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -123,12 +125,9 @@ fun ColumnScope.CallsScreen(
|
||||
tint = Palette.SecondaryText,
|
||||
modifier = Modifier.size(Space.xxl),
|
||||
)
|
||||
Text("No calls yet", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.set_calls_empty_title), fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
"Calls you place and receive appear here. Start one from a " +
|
||||
"conversation, they go directly between the two phones, " +
|
||||
"so this list is the only record of them and it is on " +
|
||||
"this device.",
|
||||
stringResource(R.string.set_calls_empty_body),
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -142,7 +141,7 @@ fun ColumnScope.CallsScreen(
|
||||
phase.buddies
|
||||
.firstOrNull { it.userId == entry.peerId }
|
||||
?.let { it.displayName.ifBlank { it.handle } }
|
||||
?: "user ${entry.peerId}"
|
||||
?: stringResource(R.string.set_calls_user_fallback, entry.peerId)
|
||||
PortalRow(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -176,12 +175,15 @@ fun ColumnScope.CallsScreen(
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
buildString {
|
||||
append(if (entry.incoming) "Incoming" else "Outgoing")
|
||||
if (!entry.connected) append(" · missed")
|
||||
append(" · ")
|
||||
append(relativeTime(entry.at))
|
||||
},
|
||||
stringResource(
|
||||
when {
|
||||
entry.incoming && entry.connected -> R.string.set_calls_line_incoming
|
||||
entry.incoming -> R.string.set_calls_line_incoming_missed
|
||||
entry.connected -> R.string.set_calls_line_outgoing
|
||||
else -> R.string.set_calls_line_outgoing_missed
|
||||
},
|
||||
relativeTime(entry.at),
|
||||
),
|
||||
fontSize = 12.sp,
|
||||
color = if (entry.connected) Palette.SecondaryText else Palette.Pink,
|
||||
maxLines = 1,
|
||||
@@ -201,7 +203,7 @@ fun ColumnScope.CallsScreen(
|
||||
) {
|
||||
Icon(
|
||||
KitIcon.Video,
|
||||
contentDescription = "Video call $name",
|
||||
contentDescription = stringResource(R.string.set_calls_video_call_name, name),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(Kit.icon),
|
||||
)
|
||||
@@ -219,7 +221,7 @@ fun ColumnScope.CallsScreen(
|
||||
) {
|
||||
Icon(
|
||||
KitIcon.Call,
|
||||
contentDescription = "Call $name",
|
||||
contentDescription = stringResource(R.string.set_calls_call_name, name),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(Kit.icon),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import io.flamenet.e2e.Content
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* The nine card fronts (§6.31): the flamenet.io drawings, shipped as
|
||||
* `assets/cards/<design>.png`, with the title drawn by the app as text, the
|
||||
* same titles as the site.
|
||||
*/
|
||||
object CardArt {
|
||||
private val TITLES =
|
||||
mapOf(
|
||||
"birthday" to "Happy Birthday",
|
||||
"thanks" to "Thank You",
|
||||
"thinking" to "Thinking of You",
|
||||
"congrats" to "Congratulations",
|
||||
"getwell" to "Get Well Soon",
|
||||
"love" to "With Love",
|
||||
"season" to "Season's Greetings",
|
||||
"hello" to "Hello!",
|
||||
"sympathy" to "With Sympathy",
|
||||
)
|
||||
|
||||
/** What each drawing shows, for TalkBack. */
|
||||
private val DRAWINGS =
|
||||
mapOf(
|
||||
"birthday" to "three balloons and confetti",
|
||||
"thanks" to "three flowers",
|
||||
"thinking" to "a crescent moon and stars",
|
||||
"congrats" to "a bright star burst",
|
||||
"getwell" to "the sun and a cloud",
|
||||
"love" to "a big heart and small hearts",
|
||||
"season" to "a tree in the snow",
|
||||
"hello" to "a paper plane",
|
||||
"sympathy" to "a lit candle",
|
||||
)
|
||||
|
||||
/** The front's title. A design this build does not know is titled as `hello`. */
|
||||
fun title(design: String): String = TITLES[design] ?: TITLES.getValue(Content.Card.FALLBACK_DESIGN)
|
||||
|
||||
fun drawing(design: String): String = DRAWINGS[design] ?: DRAWINGS.getValue(Content.Card.FALLBACK_DESIGN)
|
||||
|
||||
/** The front's picture in the app's assets. */
|
||||
fun asset(design: String): String = "cards/${if (design in TITLES) design else Content.Card.FALLBACK_DESIGN}.png"
|
||||
|
||||
/**
|
||||
* What a quote, a pin or the conversation list says about a card: what is
|
||||
* on the front, never the words inside, which may still be sealed.
|
||||
*/
|
||||
fun summary(card: Content.Card): String = "Card: " + title(card.shownDesign)
|
||||
|
||||
/** A date as the reader's calendar writes it: "January 1, 2026". */
|
||||
fun day(
|
||||
millis: Long,
|
||||
zone: TimeZone = TimeZone.getDefault(),
|
||||
locale: Locale = Locale.getDefault(),
|
||||
): String =
|
||||
DateFormat
|
||||
.getDateInstance(DateFormat.LONG, locale)
|
||||
.apply { timeZone = zone }
|
||||
.format(Date(millis))
|
||||
|
||||
/**
|
||||
* The words above a card that is still closed. The receiver is told when
|
||||
* it opens; the sender is told that their copy is open and the other one
|
||||
* is not.
|
||||
*/
|
||||
fun sealedLine(
|
||||
mine: Boolean,
|
||||
opensOn: String,
|
||||
forName: String,
|
||||
): String =
|
||||
if (mine) {
|
||||
"Sealed until $opensOn for $forName"
|
||||
} else {
|
||||
"Opens on $opensOn"
|
||||
}
|
||||
|
||||
/**
|
||||
* What sealing is and is not, said where the date is chosen and again on
|
||||
* a closed card. The words are already on the other phone; the app keeps
|
||||
* the card closed, the way a story's app hides it after a day.
|
||||
*/
|
||||
const val SEALED_HONESTY =
|
||||
"The words are already on their phone when it arrives. Their app keeps " +
|
||||
"the card closed until that day. It is a courtesy between apps, not a lock."
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SelectableDates
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.e2e.Content
|
||||
import kotlinx.coroutines.delay
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
|
||||
/** The longest a closed card waits before looking at the clock again, in millis. */
|
||||
private const val SEALED_RECHECK_MS = 60_000L
|
||||
|
||||
/**
|
||||
* A card in a conversation (§6.31): its front and title, and while it is
|
||||
* sealed, when it opens. It opens by itself when the day comes; nothing has to
|
||||
* arrive for that.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun CardBubble(
|
||||
line: Session.Line,
|
||||
card: Content.Card,
|
||||
/** Whom the sender's copy names: the card's own `to`, else the chat. */
|
||||
forName: String,
|
||||
onOpen: () -> Unit,
|
||||
onSealed: (opensOn: String) -> Unit,
|
||||
onLongPress: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val opensAt = remember(card, line.at) { card.opensAtMillis(line.at) }
|
||||
var now by remember(line.at) { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
// Looked at again at least once a minute, so a phone that slept through
|
||||
// the moment still opens the card within a minute of being looked at.
|
||||
val waitingFor = if (line.mine) card.open?.let { it * 1000 } else opensAt
|
||||
if (waitingFor != null && now < waitingFor) {
|
||||
LaunchedEffect(waitingFor) {
|
||||
while (now < waitingFor) {
|
||||
delay(minOf(waitingFor - now, SEALED_RECHECK_MS).coerceAtLeast(1))
|
||||
now = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
}
|
||||
val sealed = card.isSealed(line.mine, line.at, now)
|
||||
val title = CardArt.title(card.shownDesign)
|
||||
val status =
|
||||
when {
|
||||
sealed && opensAt != null -> CardArt.sealedLine(mine = false, opensOn = CardArt.day(opensAt), forName = forName)
|
||||
line.mine && waitingFor != null && now < waitingFor ->
|
||||
CardArt.sealedLine(mine = true, opensOn = CardArt.day(waitingFor), forName = forName)
|
||||
else -> null
|
||||
}
|
||||
val front = remember(card.shownDesign) { AssetImages.load(context, CardArt.asset(card.shownDesign)) }
|
||||
val shape = RoundedCornerShape(Kit.bubbleRadius)
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.width(240.dp)
|
||||
.clip(shape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.border(Kit.hairline, Portal.rule(), shape)
|
||||
.combinedClickable(
|
||||
onClick = { if (sealed && opensAt != null) onSealed(CardArt.day(opensAt)) else onOpen() },
|
||||
onLongClick = onLongPress,
|
||||
).semantics {
|
||||
contentDescription =
|
||||
"Card, $title, a drawing of ${CardArt.drawing(card.shownDesign)}. " +
|
||||
(status ?: "Double tap to open.")
|
||||
}.testTag("card-${line.id ?: line.at}"),
|
||||
) {
|
||||
if (front != null) {
|
||||
Image(
|
||||
bitmap = front,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(4f / 3f),
|
||||
)
|
||||
} else {
|
||||
Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).background(Palette.SurfaceAlt))
|
||||
}
|
||||
Column(modifier = Modifier.padding(Kit.bubblePadding)) {
|
||||
Text(title, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
status ?: "Tap to open",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.testTag(if (sealed) "card-sealed" else "card-status"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The inside of an open card, the way the site lays it out. */
|
||||
@Composable
|
||||
fun CardInsideDialog(
|
||||
card: Content.Card,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(CardArt.title(card.shownDesign)) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).testTag("card-inside"),
|
||||
verticalArrangement = Arrangement.spacedBy(Space.sm),
|
||||
) {
|
||||
card.shownTo?.let { Text("Dear $it,", fontSize = 15.sp, modifier = Modifier.testTag("card-to")) }
|
||||
if (card.shownMsg.isNotBlank()) {
|
||||
Text(card.shownMsg, fontSize = 16.sp, modifier = Modifier.testTag("card-msg"))
|
||||
}
|
||||
card.shownFrom?.let {
|
||||
Text(
|
||||
"From $it",
|
||||
fontSize = 15.sp,
|
||||
fontStyle = FontStyle.Italic,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.fillMaxWidth().testTag("card-from"),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss, modifier = Modifier.testTag("card-close")) { Text("Close") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** What a closed card says when somebody taps it. */
|
||||
@Composable
|
||||
fun SealedCardDialog(
|
||||
opensOn: String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Opens on $opensOn") },
|
||||
text = {
|
||||
Text(
|
||||
"Its words are already on this phone. The app keeps the card closed until " +
|
||||
"that day, as a courtesy to whoever sent it. It is not a lock.",
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.testTag("card-sealed-note"),
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a card: a design, the words, and optionally a day before which the
|
||||
* other app keeps it closed (§6.31, at most 400 days ahead).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NewCardDialog(
|
||||
defaultTo: String,
|
||||
defaultFrom: String,
|
||||
onDismiss: () -> Unit,
|
||||
onSend: (Content.Card) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var design by remember { mutableStateOf(Content.Card.DESIGNS.first()) }
|
||||
var to by remember { mutableStateOf(defaultTo.take(Content.Card.MAX_TO)) }
|
||||
var msg by remember { mutableStateOf("") }
|
||||
var from by remember { mutableStateOf(defaultFrom.take(Content.Card.MAX_FROM)) }
|
||||
var sealed by remember { mutableStateOf(false) }
|
||||
var sealDay by remember { mutableStateOf<LocalDate?>(null) }
|
||||
var choosingDay by remember { mutableStateOf(false) }
|
||||
|
||||
val today = LocalDate.now()
|
||||
val firstDay = today.plusDays(1)
|
||||
val lastDay = today.plusDays(Content.Card.MAX_SEAL_DAYS)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Send a card") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).testTag("new-card"),
|
||||
verticalArrangement = Arrangement.spacedBy(Space.sm),
|
||||
) {
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||
items(Content.Card.DESIGNS, key = { it }) { d ->
|
||||
val picture = remember(d) { AssetImages.load(context, CardArt.asset(d)) }
|
||||
val chosen = d == design
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier =
|
||||
Modifier
|
||||
.width(96.dp)
|
||||
.clickable { design = d }
|
||||
.semantics {
|
||||
contentDescription = "${CardArt.title(d)}, ${CardArt.drawing(d)}"
|
||||
selected = chosen
|
||||
}.testTag("card-design-$d"),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(4f / 3f)
|
||||
.border(
|
||||
if (chosen) 3.dp else Kit.hairline,
|
||||
if (chosen) Palette.Accent else Portal.rule(),
|
||||
),
|
||||
) {
|
||||
if (picture != null) {
|
||||
Image(picture, null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
Text(
|
||||
CardArt.title(d),
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1,
|
||||
fontWeight = if (chosen) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = to,
|
||||
onValueChange = { to = it.take(Content.Card.MAX_TO) },
|
||||
label = { Text("To") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("card-to-field"),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = msg,
|
||||
onValueChange = { msg = it.take(Content.Card.MAX_MSG) },
|
||||
label = { Text("Message") },
|
||||
minLines = 3,
|
||||
modifier = Modifier.fillMaxWidth().testTag("card-msg-field"),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = from,
|
||||
onValueChange = { from = it.take(Content.Card.MAX_FROM) },
|
||||
label = { Text("From") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("card-from-field"),
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Switch(
|
||||
checked = sealed,
|
||||
onCheckedChange = {
|
||||
sealed = it
|
||||
if (it && sealDay == null) choosingDay = true
|
||||
},
|
||||
modifier = Modifier.testTag("card-seal-switch"),
|
||||
)
|
||||
Spacer(Modifier.width(Space.sm))
|
||||
Text("Keep it sealed until", fontSize = 14.sp, modifier = Modifier.weight(1f))
|
||||
}
|
||||
if (sealed) {
|
||||
TextButton(
|
||||
onClick = { choosingDay = true },
|
||||
modifier = Modifier.testTag("card-seal-day"),
|
||||
) {
|
||||
Text(sealDay?.let { CardArt.day(it.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()) } ?: "Choose a day")
|
||||
}
|
||||
Text(CardArt.SEALED_HONESTY, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = !sealed || sealDay != null,
|
||||
onClick = {
|
||||
val now = System.currentTimeMillis()
|
||||
val open =
|
||||
if (sealed) {
|
||||
sealDay?.let { Content.Card.openValue(it.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(), now) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
onSend(
|
||||
Content.Card(
|
||||
design = design,
|
||||
msg = msg.trim(),
|
||||
to = to.trim().takeIf { it.isNotEmpty() },
|
||||
from = from.trim().takeIf { it.isNotEmpty() },
|
||||
open = open,
|
||||
),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.testTag("card-send"),
|
||||
) { Text("Send") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
|
||||
if (choosingDay) {
|
||||
val state =
|
||||
rememberDatePickerState(
|
||||
initialSelectedDateMillis = (sealDay ?: firstDay).toEpochDay() * DAY_MILLIS,
|
||||
selectableDates =
|
||||
object : SelectableDates {
|
||||
override fun isSelectableDate(utcTimeMillis: Long): Boolean =
|
||||
(utcTimeMillis / DAY_MILLIS) in firstDay.toEpochDay()..lastDay.toEpochDay()
|
||||
|
||||
override fun isSelectableYear(year: Int): Boolean = year in firstDay.year..lastDay.year
|
||||
},
|
||||
)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = {
|
||||
choosingDay = false
|
||||
if (sealDay == null) sealed = false
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
state.selectedDateMillis?.let { sealDay = LocalDate.ofEpochDay(it / DAY_MILLIS) }
|
||||
choosingDay = false
|
||||
if (sealDay == null) sealed = false
|
||||
},
|
||||
modifier = Modifier.testTag("card-seal-day-ok"),
|
||||
) { Text("OK") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
choosingDay = false
|
||||
if (sealDay == null) sealed = false
|
||||
}) { Text("Cancel") }
|
||||
},
|
||||
) {
|
||||
DatePicker(state = state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val DAY_MILLIS = 86_400_000L
|
||||
@@ -0,0 +1,444 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import io.flamenet.e2e.Content
|
||||
import io.flamenet.games.DrawReason
|
||||
import io.flamenet.games.GamePosition
|
||||
import io.flamenet.games.GameType
|
||||
import io.flamenet.games.Games
|
||||
import io.flamenet.games.MoveResult
|
||||
import io.flamenet.games.Outcome
|
||||
import io.flamenet.games.ReplayResult
|
||||
import io.flamenet.games.Side
|
||||
import io.flamenet.games.WinReason
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Games in a one-to-one chat (§6.33), as rules a conversation applies.
|
||||
*
|
||||
* Only the log travels. Each side replays it with the capped engine replay
|
||||
* and checks that whatever a peer added is legal and is theirs to play, so a
|
||||
* game can be rebuilt from any one envelope and a peer cannot move for you.
|
||||
*/
|
||||
object ChatGames {
|
||||
const val RESIGN = "resign"
|
||||
const val DRAW_OFFER = "draw-offer"
|
||||
const val DRAW_ACCEPT = "draw-accept"
|
||||
const val DRAW_DECLINE = "draw-decline"
|
||||
const val DECLINE = "decline"
|
||||
const val CANCEL = "cancel"
|
||||
|
||||
/** The endings that finish a game. An offer and its refusal do not. */
|
||||
val FINISHING = setOf(RESIGN, DRAW_ACCEPT, DECLINE, CANCEL)
|
||||
|
||||
/** One game, as this person's phone holds it. Everything else is replayed from [log]. */
|
||||
data class State(
|
||||
val id: String,
|
||||
val type: GameType,
|
||||
/** The side this person plays. */
|
||||
val me: Side,
|
||||
val log: List<String> = emptyList(),
|
||||
/** Whether this person started the game. */
|
||||
val startedByMe: Boolean,
|
||||
/** The ending that finished it, one of [FINISHING]. */
|
||||
val ending: String? = null,
|
||||
/** Who sent [ending]. Null when not known, as from a backup that did not say. */
|
||||
val endingBy: Side? = null,
|
||||
/** A draw offered and not yet answered, by this side. */
|
||||
val offerBy: Side? = null,
|
||||
/** The two boards stopped agreeing. Nothing more is taken for this game. */
|
||||
val outOfStep: Boolean = false,
|
||||
) {
|
||||
val them: Side get() = me.opponent
|
||||
|
||||
/** The position the log reaches, or null when it does not replay. */
|
||||
val position: GamePosition? by lazy { (Games.replay(type, log) as? ReplayResult.Replayed)?.position }
|
||||
|
||||
val outcome: Outcome get() = position?.let(Games::outcome) ?: Outcome.Ongoing
|
||||
|
||||
val finished: Boolean get() = ending in FINISHING || outcome.isOver
|
||||
|
||||
/** The person who did not start it. Their first move accepts the game. */
|
||||
val invitee: Side get() = if (startedByMe) them else me
|
||||
|
||||
val accepted: Boolean get() = hasMoved(invitee)
|
||||
|
||||
/** Whether the game waits on this person now. */
|
||||
val myMove: Boolean get() = !finished && !outOfStep && position?.sideToMove == me
|
||||
|
||||
/** The moves this person may make now, empty when it is not their move. */
|
||||
val myMoves: List<String> get() = if (myMove) position?.let(Games::legalMoves).orEmpty() else emptyList()
|
||||
|
||||
fun hasMoved(side: Side): Boolean = log.indices.any { type.sideOfPly(it) == side }
|
||||
}
|
||||
|
||||
/** What one move or ending from the other side did. */
|
||||
sealed interface Received {
|
||||
/** Their move; the game goes on and it is this person's turn. */
|
||||
data class Moved(
|
||||
val state: State,
|
||||
) : Received
|
||||
|
||||
/** This envelope finished the game. */
|
||||
data class Ended(
|
||||
val state: State,
|
||||
) : Received
|
||||
|
||||
/** The other side offers a draw. Shown without a sound (§6.33). */
|
||||
data class Offered(
|
||||
val state: State,
|
||||
) : Received
|
||||
|
||||
/** One of ours turned down. Redrawn, not news. */
|
||||
data class Quiet(
|
||||
val state: State,
|
||||
) : Received
|
||||
|
||||
/** The boards no longer agree. */
|
||||
data class OutOfStep(
|
||||
val state: State,
|
||||
) : Received
|
||||
|
||||
/** A duplicate, a stale log, or nothing this side may send now. */
|
||||
data object Ignored : Received
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a `gmove` or `gend` from the other side, whose side is the
|
||||
* envelope's `mine` (§6.33, receiving, rules 1 to 4).
|
||||
*
|
||||
* An ending is judged at the log it carries, once that log is in step
|
||||
* (DESIGN items 22 and 23). Moves it carries are taken even when the
|
||||
* ending itself does not count.
|
||||
*
|
||||
* @param current what this chat holds for the game id, or null when it
|
||||
* holds nothing, in which case the game is rebuilt from the log.
|
||||
*/
|
||||
fun receive(
|
||||
current: State?,
|
||||
kind: String,
|
||||
game: Content.Game,
|
||||
): Received {
|
||||
val type = GameType.fromWire(game.type) ?: return Received.Ignored
|
||||
val sender = Side.fromWire(game.mine) ?: return Received.Ignored
|
||||
// DESIGN 37: a move carries the log it reaches, never none or an
|
||||
// empty one; an ending carries the log it was sent at, which may be
|
||||
// empty, but never none.
|
||||
val log = game.log ?: return Received.Ignored
|
||||
if (kind == Content.Kind.GAME_MOVE && log.isEmpty()) return Received.Ignored
|
||||
val ending = if (kind == Content.Kind.GAME_END) game.ending ?: return Received.Ignored else null
|
||||
|
||||
// Rule 1: an unknown game is rebuilt from this envelope. Only the
|
||||
// invitee can decline, so a decline makes this person the starter.
|
||||
if (current == null) {
|
||||
val fresh = State(game.id, type, me = sender.opponent, startedByMe = ending == DECLINE)
|
||||
if (Games.replay(type, log) !is ReplayResult.Replayed) return Received.OutOfStep(fresh.copy(outOfStep = true))
|
||||
return withEnding(fresh.copy(log = log), sender, ending, log)
|
||||
}
|
||||
if (current.outOfStep || current.finished) return Received.Ignored
|
||||
// The other side must play the other side, in the same game.
|
||||
if (type != current.type || sender != current.them) return Received.OutOfStep(current.copy(outOfStep = true))
|
||||
|
||||
val known = current.log
|
||||
// Rule 2: no longer than what is known.
|
||||
if (log.size <= known.size) {
|
||||
if (log != known.subList(0, log.size)) return Received.OutOfStep(current.copy(outOfStep = true))
|
||||
return if (ending == null) Received.Ignored else applyEnding(current, sender, ending, log)
|
||||
}
|
||||
// Rule 3: what is known has to be where the new log starts.
|
||||
if (log.subList(0, known.size) != known) return Received.OutOfStep(current.copy(outOfStep = true))
|
||||
// Rule 4: every added ply is theirs, and the whole log replays within the cap.
|
||||
for (index in known.size until log.size) {
|
||||
if (type.sideOfPly(index) != sender) return Received.OutOfStep(current.copy(outOfStep = true))
|
||||
}
|
||||
if (Games.replay(type, log) !is ReplayResult.Replayed) return Received.OutOfStep(current.copy(outOfStep = true))
|
||||
// Their move lapses any draw that was offered.
|
||||
return withEnding(current.copy(log = log, offerBy = null), sender, ending, log)
|
||||
}
|
||||
|
||||
/** [moved], with [ending] applied when it counts. */
|
||||
private fun withEnding(
|
||||
moved: State,
|
||||
sender: Side,
|
||||
ending: String?,
|
||||
carried: List<String>,
|
||||
): Received {
|
||||
if (ending != null) {
|
||||
val ended = applyEnding(moved, sender, ending, carried)
|
||||
if (ended != Received.Ignored) return ended
|
||||
}
|
||||
return if (moved.finished) Received.Ended(moved) else Received.Moved(moved)
|
||||
}
|
||||
|
||||
/**
|
||||
* One ending from [sender], judged at [carried], the log its `gend` held,
|
||||
* which is in step with [state] and a prefix of its log.
|
||||
*/
|
||||
private fun applyEnding(
|
||||
state: State,
|
||||
sender: Side,
|
||||
ending: String,
|
||||
carried: List<String>,
|
||||
): Received {
|
||||
if (state.finished) return Received.Ignored
|
||||
val atWholeLog = carried == state.log
|
||||
|
||||
fun movedBy(side: Side) = carried.indices.any { state.type.sideOfPly(it) == side }
|
||||
return when (ending) {
|
||||
RESIGN -> {
|
||||
Received.Ended(state.copy(ending = RESIGN, endingBy = sender, offerBy = null))
|
||||
}
|
||||
|
||||
// DESIGN item 22: only from the side that has just moved, at this
|
||||
// phone's whole log, so no answer can race a move.
|
||||
DRAW_OFFER -> {
|
||||
val justMoved = state.position?.sideToMove == sender.opponent
|
||||
if (atWholeLog && justMoved && state.accepted && state.offerBy == null) {
|
||||
Received.Offered(state.copy(offerBy = sender))
|
||||
} else {
|
||||
Received.Ignored
|
||||
}
|
||||
}
|
||||
|
||||
// DESIGN 37: only at this phone's whole log, where the offer was made.
|
||||
DRAW_ACCEPT -> {
|
||||
if (atWholeLog && state.offerBy == state.me) {
|
||||
Received.Ended(state.copy(ending = DRAW_ACCEPT, endingBy = sender, offerBy = null))
|
||||
} else {
|
||||
Received.Ignored
|
||||
}
|
||||
}
|
||||
|
||||
DRAW_DECLINE -> {
|
||||
if (state.offerBy == state.me) Received.Quiet(state.copy(offerBy = null)) else Received.Ignored
|
||||
}
|
||||
|
||||
// DESIGN item 23: the invitee, before their first move in the log
|
||||
// the decline was sent at.
|
||||
DECLINE -> {
|
||||
if (sender == state.invitee && !movedBy(sender)) {
|
||||
Received.Ended(state.copy(ending = DECLINE, endingBy = sender, offerBy = null))
|
||||
} else {
|
||||
Received.Ignored
|
||||
}
|
||||
}
|
||||
|
||||
// The starter, before the invitee's first move in that log.
|
||||
CANCEL -> {
|
||||
if (sender != state.invitee && !movedBy(state.invitee)) {
|
||||
Received.Ended(state.copy(ending = CANCEL, endingBy = sender, offerBy = null))
|
||||
} else {
|
||||
Received.Ignored
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Received.Ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- This person's own moves and endings -------------------------------------.
|
||||
|
||||
/** The game after this person plays [move], or null when it is not theirs to play. */
|
||||
fun play(
|
||||
state: State,
|
||||
move: String,
|
||||
): State? {
|
||||
if (move !in state.myMoves || state.log.size >= state.type.maxPlies) return null
|
||||
val position = state.position ?: return null
|
||||
if (Games.apply(position, move) !is MoveResult.Applied) return null
|
||||
return state.copy(log = state.log + move, offerBy = null)
|
||||
}
|
||||
|
||||
/** Whether this person may send [ending] now. */
|
||||
fun canEnd(
|
||||
state: State,
|
||||
ending: String,
|
||||
): Boolean {
|
||||
if (state.finished || state.outOfStep) return false
|
||||
return when (ending) {
|
||||
RESIGN -> state.accepted
|
||||
// DESIGN item 22: only once this person has moved and it is theirs.
|
||||
DRAW_OFFER -> state.accepted && state.offerBy == null && state.position?.sideToMove == state.them
|
||||
DRAW_ACCEPT, DRAW_DECLINE -> state.offerBy == state.them
|
||||
DECLINE -> state.invitee == state.me && !state.hasMoved(state.me)
|
||||
CANCEL -> state.startedByMe && !state.accepted
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/** The game after this person sends [ending]. Only call when [canEnd]. */
|
||||
fun ended(
|
||||
state: State,
|
||||
ending: String,
|
||||
): State =
|
||||
when (ending) {
|
||||
DRAW_OFFER -> state.copy(offerBy = state.me)
|
||||
DRAW_DECLINE -> state.copy(offerBy = null)
|
||||
else -> state.copy(ending = ending, endingBy = state.me, offerBy = null)
|
||||
}
|
||||
|
||||
/** The envelope body for this person's side of [state]. */
|
||||
fun wire(
|
||||
state: State,
|
||||
ending: String? = null,
|
||||
): Content.Game = Content.Game(state.id, state.type.wire, state.me.wire, state.log, ending)
|
||||
|
||||
/**
|
||||
* Whether this person's board is drawn turned round. Chess, checkers and
|
||||
* reversi turn for the second player; tic-tac-toe never does, so its cells
|
||||
* keep their places on both phones (DESIGN 30).
|
||||
*/
|
||||
fun turnedRound(state: State): Boolean = state.me == Side.SECOND && state.type != GameType.TICTACTOE
|
||||
|
||||
// -- Words -------------------------------------------------------------------.
|
||||
|
||||
fun name(type: GameType): String =
|
||||
when (type) {
|
||||
GameType.CHESS -> "Chess"
|
||||
GameType.CHECKERS -> "Checkers"
|
||||
GameType.REVERSI -> "Reversi"
|
||||
GameType.TICTACTOE -> "Tic-tac-toe"
|
||||
}
|
||||
|
||||
/** The name in running text, as §6.33's notification writes it: "chess". */
|
||||
fun lowerName(type: GameType): String = name(type).lowercase()
|
||||
|
||||
/** What each side is called in each game. */
|
||||
fun sideName(
|
||||
type: GameType,
|
||||
side: Side,
|
||||
): String =
|
||||
when (type) {
|
||||
GameType.CHESS -> if (side == Side.FIRST) "white" else "black"
|
||||
GameType.CHECKERS -> if (side == Side.FIRST) "dark" else "light"
|
||||
GameType.REVERSI -> if (side == Side.FIRST) "black" else "white"
|
||||
GameType.TICTACTOE -> if (side == Side.FIRST) "X" else "O"
|
||||
}
|
||||
|
||||
/** How a finished game ended, from this person's side. Null while it goes on. */
|
||||
fun result(state: State): String? {
|
||||
val by = state.endingBy
|
||||
when (state.ending) {
|
||||
RESIGN -> return when (by) {
|
||||
state.me -> "You resigned. They won"
|
||||
state.them -> "They resigned. You won"
|
||||
else -> "Resigned"
|
||||
}
|
||||
|
||||
DRAW_ACCEPT -> return "Draw by agreement"
|
||||
|
||||
DECLINE -> return when (by) {
|
||||
state.me -> "You declined"
|
||||
state.them -> "They declined"
|
||||
else -> "Declined"
|
||||
}
|
||||
|
||||
CANCEL -> return when (by) {
|
||||
state.me -> "You cancelled"
|
||||
state.them -> "They cancelled"
|
||||
else -> "Cancelled"
|
||||
}
|
||||
}
|
||||
return outcomeWords(state.outcome, state.me)
|
||||
}
|
||||
|
||||
/** A natural end in words, from [me]'s side. Null while the game goes on. */
|
||||
internal fun outcomeWords(
|
||||
outcome: Outcome,
|
||||
me: Side,
|
||||
): String? =
|
||||
when (outcome) {
|
||||
is Outcome.Win -> {
|
||||
val mine = outcome.side == me
|
||||
when (outcome.reason) {
|
||||
WinReason.CHECKMATE -> if (mine) "Checkmate. You won" else "Checkmate. They won"
|
||||
WinReason.NO_MOVES -> if (mine) "You won. They have no moves left" else "They won. You have no moves left"
|
||||
WinReason.MORE_DISCS -> if (mine) "You won with more discs" else "They won with more discs"
|
||||
WinReason.THREE_IN_A_ROW -> if (mine) "You won with three in a row" else "They won with three in a row"
|
||||
}
|
||||
}
|
||||
|
||||
is Outcome.Draw -> {
|
||||
"Draw by " +
|
||||
when (outcome.reason) {
|
||||
DrawReason.STALEMATE -> "stalemate"
|
||||
DrawReason.INSUFFICIENT_MATERIAL -> "insufficient material"
|
||||
DrawReason.FIFTY_MOVES -> "fifty moves"
|
||||
DrawReason.FORTY_MOVES -> "forty moves each without a capture"
|
||||
DrawReason.REPETITION -> "repetition"
|
||||
DrawReason.EQUAL_DISCS -> "equal discs"
|
||||
DrawReason.FULL_BOARD -> "full board"
|
||||
}
|
||||
}
|
||||
|
||||
Outcome.Ongoing -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** The line under the board. */
|
||||
fun status(state: State): String =
|
||||
when {
|
||||
state.outOfStep -> "Your boards disagree"
|
||||
state.finished -> result(state).orEmpty()
|
||||
state.offerBy == state.them -> "They offered a draw"
|
||||
state.offerBy == state.me -> "You offered a draw"
|
||||
state.myMove && !state.accepted && state.invitee == state.me -> "Your move. Playing it accepts the game"
|
||||
state.myMove -> "Your move"
|
||||
!state.accepted && state.invitee == state.them -> "Waiting for them to play or decline"
|
||||
else -> "Their move"
|
||||
}
|
||||
|
||||
// -- On a history row --------------------------------------------------------.
|
||||
|
||||
/** `{"id","t","me","log","r","rby"}`: §6.33's backup shape, `rby` the side that sent `r`. */
|
||||
const val ROW_GAME = "game"
|
||||
|
||||
/** Present, and true, while the boards disagree. Local to this phone. */
|
||||
const val ROW_OUT_OF_STEP = "gameOutOfStep"
|
||||
|
||||
/** The side with a draw offer open. Local to this phone. */
|
||||
const val ROW_OFFER = "gameOffer"
|
||||
|
||||
fun toRow(
|
||||
state: State,
|
||||
row: JSONObject,
|
||||
): JSONObject {
|
||||
val game =
|
||||
JSONObject()
|
||||
.put("id", state.id)
|
||||
.put("t", state.type.wire)
|
||||
.put("me", state.me.wire)
|
||||
.put("log", JSONArray().also { array -> state.log.forEach { array.put(it) } })
|
||||
state.ending?.let { game.put("r", it) }
|
||||
state.endingBy?.let { if (state.ending != null) game.put("rby", it.wire) }
|
||||
row.put(ROW_GAME, game)
|
||||
if (state.outOfStep) row.put(ROW_OUT_OF_STEP, true) else row.remove(ROW_OUT_OF_STEP)
|
||||
state.offerBy?.let { row.put(ROW_OFFER, it.wire) } ?: row.remove(ROW_OFFER)
|
||||
return row
|
||||
}
|
||||
|
||||
/** A game back off a history row, or null when the row holds none it can read. */
|
||||
fun fromRow(row: JSONObject): State? {
|
||||
val game = row.optJSONObject(ROW_GAME) ?: return null
|
||||
// As on the wire: any id that is there (DESIGN 36).
|
||||
val id = game.optString("id").takeIf { it.isNotEmpty() } ?: return null
|
||||
val type = GameType.fromWire(game.optString("t")) ?: return null
|
||||
val me = Side.fromWire(game.optString("me")) ?: return null
|
||||
val array = game.optJSONArray("log") ?: JSONArray()
|
||||
val log = (0 until array.length()).map { array.opt(it) as? String ?: return null }
|
||||
return State(
|
||||
id = id,
|
||||
type = type,
|
||||
me = me,
|
||||
log = log,
|
||||
startedByMe = row.optBoolean("mine", false),
|
||||
ending = game.optString("r").takeIf { it in FINISHING },
|
||||
endingBy = Side.fromWire(game.optString("rby")),
|
||||
offerBy = Side.fromWire(row.optString(ROW_OFFER)),
|
||||
outOfStep = row.optBoolean(ROW_OUT_OF_STEP, false),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -27,12 +27,14 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.client.MessengerApi
|
||||
import io.flamenet.messenger.R
|
||||
|
||||
/**
|
||||
* One contact, as the kit draws it: a cover, a name plate, and a rail of
|
||||
@@ -47,7 +49,9 @@ fun ColumnScope.ContactDetail(
|
||||
) {
|
||||
var confirmingRemove by remember { mutableStateOf(false) }
|
||||
val isBlocked = buddy.userId in phase.blocked
|
||||
val name = buddy.displayName.ifBlank { buddy.handle }.ifBlank { "user ${buddy.userId}" }
|
||||
val unknownName = stringResource(R.string.social_user_fallback, buddy.userId)
|
||||
val backLabel = stringResource(R.string.common_back)
|
||||
val name = buddy.displayName.ifBlank { buddy.handle }.ifBlank { unknownName }
|
||||
val initials =
|
||||
remember(name) {
|
||||
name
|
||||
@@ -71,7 +75,7 @@ fun ColumnScope.ContactDetail(
|
||||
.size(Kit.icon + Space.md)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.clickable(onClick = onClose)
|
||||
.semantics { contentDescription = "Back" }
|
||||
.semantics { contentDescription = backLabel }
|
||||
.testTag("contact-back"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -137,7 +141,7 @@ fun ColumnScope.ContactDetail(
|
||||
buddy.handle
|
||||
.takeIf { it.isNotBlank() && !it.equals(name, true) }
|
||||
?.let { "@$it" },
|
||||
buddy.presence.takeIf { it.isNotBlank() },
|
||||
presenceLabel(buddy.presence).takeIf { it.isNotBlank() },
|
||||
).joinToString(" · "),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
@@ -163,18 +167,18 @@ fun ColumnScope.ContactDetail(
|
||||
if (isBlocked) Palette.SecondaryText else MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
) {
|
||||
RailButton(KitIcon.Chats, "Message", "contact-message", enabled = !isBlocked) {
|
||||
RailButton(KitIcon.Chats, stringResource(R.string.social_contact_message), "contact-message", enabled = !isBlocked) {
|
||||
model.openChat(buddy.userId, buddy.displayName)
|
||||
onClose()
|
||||
}
|
||||
RailDivider()
|
||||
RailButton(KitIcon.Call, "Call", "contact-call", enabled = !isBlocked) {
|
||||
RailButton(KitIcon.Call, stringResource(R.string.common_call), "contact-call", enabled = !isBlocked) {
|
||||
model.requestCall(buddy.userId)
|
||||
}
|
||||
RailDivider()
|
||||
RailButton(
|
||||
if (isBlocked) KitIcon.PersonAdd else KitIcon.Block,
|
||||
if (isBlocked) "Unblock" else "Block",
|
||||
if (isBlocked) stringResource(R.string.social_unblock) else stringResource(R.string.common_block),
|
||||
"contact-block",
|
||||
) { model.setBlocked(buddy.userId, !isBlocked) }
|
||||
}
|
||||
@@ -182,18 +186,19 @@ fun ColumnScope.ContactDetail(
|
||||
|
||||
Spacer(Modifier.height(Space.xl))
|
||||
|
||||
PortalBand("Their details")
|
||||
PortalBand(stringResource(R.string.social_their_details))
|
||||
Column(Modifier.padding(vertical = Space.sm)) {
|
||||
DetailRow("Username", buddy.handle.ifBlank { ", " })
|
||||
DetailRow("User ID", buddy.userId.toString())
|
||||
DetailRow("Status", buddy.presence.ifBlank { "unknown" })
|
||||
DetailRow(stringResource(R.string.social_detail_username), buddy.handle.ifBlank { ", " })
|
||||
DetailRow(stringResource(R.string.social_detail_user_id), buddy.userId.toString())
|
||||
val unknownStatus = stringResource(R.string.social_status_unknown)
|
||||
DetailRow(stringResource(R.string.social_detail_status), presenceLabel(buddy.presence).ifBlank { unknownStatus })
|
||||
}
|
||||
|
||||
PortalBand("Managing this contact")
|
||||
PortalBand(stringResource(R.string.social_managing_contact))
|
||||
Column(Modifier.padding(vertical = Space.sm)) {
|
||||
DetailRow(
|
||||
"Clear this conversation",
|
||||
"Removes it from this phone only. Their copy is theirs.",
|
||||
stringResource(R.string.social_clear_conversation),
|
||||
stringResource(R.string.social_clear_conversation_note),
|
||||
tag = "contact-clear",
|
||||
) {
|
||||
model.clearHistory(
|
||||
@@ -204,8 +209,8 @@ fun ColumnScope.ContactDetail(
|
||||
|
||||
// Confirms in place rather than in a dialog.
|
||||
DetailRow(
|
||||
if (confirmingRemove) "Really remove, this cannot be undone" else "Remove contact",
|
||||
"Takes them off your list. It does not block them.",
|
||||
if (confirmingRemove) stringResource(R.string.social_really_remove) else stringResource(R.string.social_remove_contact),
|
||||
stringResource(R.string.social_remove_contact_note),
|
||||
tag = "contact-remove",
|
||||
danger = true,
|
||||
) {
|
||||
|
||||
@@ -29,11 +29,13 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.messenger.R
|
||||
|
||||
/**
|
||||
* Crashes and freezes this phone recorded about this app.
|
||||
@@ -62,12 +64,9 @@ fun ColumnScope.DiagnosticsSection() {
|
||||
internal fun ColumnScope.DiagnosticsSection(reports: List<AppDiagnostics.Report>) {
|
||||
var open by remember { mutableStateOf<AppDiagnostics.Report?>(null) }
|
||||
|
||||
Text("App diagnostics", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.set_diag_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Nothing here has left your phone. Android records a report when the app " +
|
||||
"crashes or freezes, and this screen shows what it kept, so there is " +
|
||||
"something to send if you ever want to. Sending one is something you do, " +
|
||||
"not something that happens.",
|
||||
stringResource(R.string.set_diag_intro),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -75,7 +74,7 @@ internal fun ColumnScope.DiagnosticsSection(reports: List<AppDiagnostics.Report>
|
||||
|
||||
if (reports.isEmpty()) {
|
||||
Text(
|
||||
"No crashes or freezes recorded.",
|
||||
stringResource(R.string.set_diag_empty),
|
||||
fontSize = 13.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.testTag("diagnostics-empty"),
|
||||
@@ -92,7 +91,7 @@ internal fun ColumnScope.DiagnosticsSection(reports: List<AppDiagnostics.Report>
|
||||
.padding(vertical = Space.xs)
|
||||
.testTag("diagnostic-${report.id}"),
|
||||
) {
|
||||
Text(report.kind.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text(kindTitle(report.kind), fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
report.summary,
|
||||
fontSize = 13.sp,
|
||||
@@ -100,7 +99,7 @@ internal fun ColumnScope.DiagnosticsSection(reports: List<AppDiagnostics.Report>
|
||||
maxLines = 2,
|
||||
)
|
||||
Text(
|
||||
"${AppDiagnostics.stamp(report.at)} · build ${report.appVersion}",
|
||||
stringResource(R.string.set_diag_stamp_build, AppDiagnostics.stamp(report.at), report.appVersion),
|
||||
fontSize = 11.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -130,6 +129,7 @@ private fun ReportDialog(
|
||||
val clipboard = LocalClipboardManager.current
|
||||
var saved by remember { mutableStateOf<String?>(null) }
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
val savedMessage = stringResource(R.string.set_diag_saved)
|
||||
|
||||
val saveLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
@@ -142,14 +142,14 @@ private fun ReportDialog(
|
||||
it.write(report.detail.toByteArray(Charsets.UTF_8))
|
||||
} ?: error("nothing to write to")
|
||||
}.fold(
|
||||
onSuccess = { "Saved. Attach it to a message to the address above." },
|
||||
onFailure = { e -> "Couldn't write the file: ${e.message}" },
|
||||
onSuccess = { savedMessage },
|
||||
onFailure = { e -> context.getString(R.string.set_diag_write_failed, e.message) },
|
||||
)
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(report.kind.title) },
|
||||
title = { Text(kindTitle(report.kind)) },
|
||||
// Everything except the trace is fixed, and only the trace scrolls.
|
||||
// With the whole dialog in one scrolling column the address and the
|
||||
// save button sat below the explanation and off the bottom of a small
|
||||
@@ -158,7 +158,7 @@ private fun ReportDialog(
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"${AppDiagnostics.stamp(report.at)} · build ${report.appVersion}",
|
||||
stringResource(R.string.set_diag_stamp_build, AppDiagnostics.stamp(report.at), report.appVersion),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -168,9 +168,7 @@ private fun ReportDialog(
|
||||
// which is the difference between an informed share and a
|
||||
// hopeful one.
|
||||
Text(
|
||||
"This report names the parts of Flamenet that were running and the kind " +
|
||||
"of phone you have. It does not contain your messages, your contacts " +
|
||||
"or your keys.",
|
||||
stringResource(R.string.set_diag_report_contents),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -182,12 +180,12 @@ private fun ReportDialog(
|
||||
copied = true
|
||||
},
|
||||
modifier = Modifier.testTag("diagnostics-address"),
|
||||
) { Text(if (copied) "Address copied" else AppUpdate.SUPPORT_EMAIL) }
|
||||
) { Text(if (copied) stringResource(R.string.set_diag_address_copied) else AppUpdate.SUPPORT_EMAIL) }
|
||||
Spacer(Modifier.width(Space.sm))
|
||||
OutlinedButton(
|
||||
onClick = { saveLauncher.launch(fileName(report)) },
|
||||
modifier = Modifier.testTag("diagnostics-save"),
|
||||
) { Text("Save…") }
|
||||
) { Text(stringResource(R.string.set_diag_save)) }
|
||||
}
|
||||
saved?.let {
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
@@ -212,10 +210,27 @@ private fun ReportDialog(
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } },
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) } },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind's name on screen. [AppDiagnostics.Kind.title] stays English
|
||||
* because it also heads the saved report that goes to support.
|
||||
*/
|
||||
@Composable
|
||||
private fun kindTitle(kind: AppDiagnostics.Kind): String =
|
||||
stringResource(
|
||||
when (kind) {
|
||||
AppDiagnostics.Kind.Crash -> R.string.set_diag_kind_crash
|
||||
AppDiagnostics.Kind.NativeCrash -> R.string.set_diag_kind_native_crash
|
||||
AppDiagnostics.Kind.Frozen -> R.string.set_diag_kind_frozen
|
||||
AppDiagnostics.Kind.OutOfMemory -> R.string.set_diag_kind_out_of_memory
|
||||
AppDiagnostics.Kind.Resources -> R.string.set_diag_kind_resources
|
||||
AppDiagnostics.Kind.StartupFailure -> R.string.set_diag_kind_startup_failure
|
||||
},
|
||||
)
|
||||
|
||||
/** A name somebody can find again in their downloads. */
|
||||
internal fun fileName(report: AppDiagnostics.Report): String =
|
||||
"flamenet-${report.kind.name.lowercase()}-${report.at}.txt"
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONObject
|
||||
import java.text.Normalizer
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The small-web directory, shipped inside the app (DESIGN 5).
|
||||
*
|
||||
* FlameNet's public, approved listings as one file in the APK. Browsing and
|
||||
* searching it happen on the phone and need no network, and nothing about
|
||||
* what is looked at leaves the phone.
|
||||
*/
|
||||
class Directory private constructor(
|
||||
/** The day the file was exported, `yyyy-MM-dd`. */
|
||||
val made: String,
|
||||
/** Where the listings come from, for the page's footer. */
|
||||
val from: String,
|
||||
/**
|
||||
* The file's own label for the descriptions: who wrote them and how.
|
||||
* Shown once in the directory, never on a site and never in a share.
|
||||
*/
|
||||
val note: String?,
|
||||
val categories: List<Category>,
|
||||
val sites: List<Site>,
|
||||
) {
|
||||
data class Category(
|
||||
val id: String,
|
||||
val name: String,
|
||||
/** The parent's id, empty at the top. */
|
||||
val parent: String,
|
||||
)
|
||||
|
||||
data class Site(
|
||||
val title: String,
|
||||
val url: String,
|
||||
/** One line about the site, written for FlameNet, not by the site. Optional. */
|
||||
val description: String?,
|
||||
/** Empty for a site that is found by search only, as on flamenet.io. */
|
||||
val categories: List<String>,
|
||||
) {
|
||||
/** The host, shown beside every link so a title cannot pass for a destination. */
|
||||
val host: String
|
||||
get() = runCatching { java.net.URI(url).host }.getOrNull()?.lowercase(Locale.ROOT) ?: url
|
||||
}
|
||||
|
||||
private val byId = categories.associateBy { it.id }
|
||||
private val childrenOf = categories.groupBy { it.parent }
|
||||
private val sitesOf: Map<String, List<Site>> =
|
||||
buildMap<String, MutableList<Site>> {
|
||||
for (site in sites) for (c in site.categories) getOrPut(c) { mutableListOf() } += site
|
||||
}.mapValues { (_, list) -> list.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.title }) }
|
||||
|
||||
/** Lower case, accents off, per site: what a search is compared against. */
|
||||
private val folded: List<Triple<String, String, String>> =
|
||||
sites.map { Triple(fold(it.title), fold(it.host), fold(it.description.orEmpty())) }
|
||||
|
||||
/** The top of the tree, in the file's order. */
|
||||
val top: List<Category> get() = childrenOf[""].orEmpty()
|
||||
|
||||
fun category(id: String): Category? = byId[id]
|
||||
|
||||
fun children(id: String): List<Category> = childrenOf[id].orEmpty()
|
||||
|
||||
/** The sites filed directly under [id], by title. */
|
||||
fun sitesIn(id: String): List<Site> = sitesOf[id].orEmpty()
|
||||
|
||||
/** Sites under [id] and everything beneath it, each counted once. */
|
||||
fun count(id: String): Int {
|
||||
val seen = HashSet<Site>()
|
||||
fun walk(c: String) {
|
||||
seen += sitesIn(c)
|
||||
children(c).forEach { walk(it.id) }
|
||||
}
|
||||
walk(id)
|
||||
return seen.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Sites matching every word of [query] in the title, the host or the
|
||||
* description, ignoring case and accents. Title matches come first, then
|
||||
* host matches, then the rest, each by title.
|
||||
*/
|
||||
fun search(
|
||||
query: String,
|
||||
limit: Int = SEARCH_LIMIT,
|
||||
): List<Site> {
|
||||
val words = fold(query).split(' ').filter { it.isNotEmpty() }
|
||||
if (words.isEmpty()) return emptyList()
|
||||
return sites.indices
|
||||
.mapNotNull { i ->
|
||||
val (title, host, desc) = folded[i]
|
||||
if (!words.all { w -> w in title || w in host || w in desc }) return@mapNotNull null
|
||||
val rank =
|
||||
when {
|
||||
title.startsWith(words.first()) -> 0
|
||||
words.all { it in title } -> 1
|
||||
words.all { it in host } -> 2
|
||||
else -> 3
|
||||
}
|
||||
rank to sites[i]
|
||||
}.sortedWith(compareBy<Pair<Int, Site>> { it.first }.thenBy(String.CASE_INSENSITIVE_ORDER) { it.second.title })
|
||||
.take(limit)
|
||||
.map { it.second }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ASSET = "directory/directory-v1.json"
|
||||
|
||||
/** Results shown at once. Typing more narrows them. */
|
||||
const val SEARCH_LIMIT = 100
|
||||
|
||||
const val MAX_TITLE = 120
|
||||
const val MAX_DESC = 300
|
||||
|
||||
@Volatile private var shipped: Directory? = null
|
||||
|
||||
/** The copy shipped in the APK, read once. Blocking; call it off the main thread. */
|
||||
fun load(context: Context): Directory =
|
||||
shipped ?: synchronized(this) {
|
||||
shipped ?: parse(context.assets.open(ASSET).use { String(it.readBytes(), Charsets.UTF_8) })
|
||||
.also { shipped = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a directory file. A site without a title or an http(s) address
|
||||
* is left out, a category it names that does not exist is ignored, and
|
||||
* text is cut to the file's own bounds.
|
||||
*/
|
||||
fun parse(text: String): Directory {
|
||||
val o = JSONObject(text)
|
||||
require(o.optInt("v") == 1) { "directory file version ${o.opt("v")}, this build reads 1" }
|
||||
val cats =
|
||||
o.optJSONArray("cats").let { a ->
|
||||
(0 until (a?.length() ?: 0)).mapNotNull { i ->
|
||||
val c = a!!.optJSONObject(i) ?: return@mapNotNull null
|
||||
val id = c.optString("id").takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
val name = c.optString("name").trim().takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
Category(id, name, c.optString("parent"))
|
||||
}
|
||||
}
|
||||
val ids = cats.map { it.id }.toSet()
|
||||
val sites =
|
||||
o.optJSONArray("sites").let { a ->
|
||||
(0 until (a?.length() ?: 0)).mapNotNull { i ->
|
||||
val s = a!!.optJSONObject(i) ?: return@mapNotNull null
|
||||
val title = (s.opt("t") as? String)?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
val url = (s.opt("u") as? String)?.trim()?.takeIf(::isWebAddress) ?: return@mapNotNull null
|
||||
val cs = s.optJSONArray("c")
|
||||
Site(
|
||||
title = io.flamenet.client.TodayFile.bounded(title, MAX_TITLE),
|
||||
url = url,
|
||||
description =
|
||||
(s.opt("d") as? String)
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { io.flamenet.client.TodayFile.bounded(it, MAX_DESC) },
|
||||
categories =
|
||||
(0 until (cs?.length() ?: 0))
|
||||
.mapNotNull { cs!!.opt(it) as? String }
|
||||
.filter { it in ids }
|
||||
.distinct(),
|
||||
)
|
||||
}
|
||||
}
|
||||
return Directory(
|
||||
made = o.optString("made"),
|
||||
from = o.optString("from"),
|
||||
note = (o.opt("note") as? String)?.trim()?.takeIf { it.isNotEmpty() },
|
||||
categories = cats,
|
||||
sites = sites,
|
||||
)
|
||||
}
|
||||
|
||||
/** An http or https address with a host: the only kind a tap may open. */
|
||||
fun isWebAddress(url: String): Boolean {
|
||||
val uri = runCatching { java.net.URI(url) }.getOrNull() ?: return false
|
||||
val scheme = uri.scheme?.lowercase(Locale.ROOT)
|
||||
return (scheme == "https" || scheme == "http") && !uri.host.isNullOrBlank()
|
||||
}
|
||||
|
||||
/** Lower case with the accents taken off, so "cafe" finds "Café". */
|
||||
fun fold(s: String): String =
|
||||
Normalizer
|
||||
.normalize(s.lowercase(Locale.ROOT), Normalizer.Form.NFD)
|
||||
.replace(MARKS, "")
|
||||
.replace(SPACES, " ")
|
||||
.trim()
|
||||
|
||||
private val MARKS = Regex("\\p{Mn}+")
|
||||
private val SPACES = Regex("\\s+")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.client.MessengerApi
|
||||
import java.text.NumberFormat
|
||||
|
||||
/**
|
||||
* The small-web directory (DESIGN 5), reached from the Today tab. Everything
|
||||
* here comes from the file shipped in the app; nothing is fetched to browse,
|
||||
* search or share it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ColumnScope.DirectoryScreen(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
today: TodayModel,
|
||||
ui: TodayModel.Ui,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var sending by remember { mutableStateOf<Directory.Site?>(null) }
|
||||
// What happened to the send, said in the dialog the person is looking at.
|
||||
var outcome by remember { mutableStateOf<String?>(null) }
|
||||
var said by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
BackHandler { today.back() }
|
||||
|
||||
DirectoryPage(
|
||||
directory = ui.directory,
|
||||
category = ui.category,
|
||||
query = ui.query,
|
||||
said = said,
|
||||
onQuery = {
|
||||
said = null
|
||||
today.search(it)
|
||||
},
|
||||
onBrowse = {
|
||||
said = null
|
||||
today.browse(it)
|
||||
},
|
||||
onOpen = { url -> said = if (openLink(context, url)) null else NO_BROWSER },
|
||||
onSend = {
|
||||
outcome = null
|
||||
sending = it
|
||||
},
|
||||
onClose = { today.closeDirectory() },
|
||||
)
|
||||
|
||||
sending?.let { site ->
|
||||
SendSiteDialog(
|
||||
site = site,
|
||||
people = phase.buddies,
|
||||
outcome = outcome,
|
||||
onSend = { buddy ->
|
||||
outcome = "Sending…"
|
||||
model.shareSite(site, buddy.userId, buddy.displayName.ifBlank { buddy.handle }) { outcome = it }
|
||||
},
|
||||
onDismiss = {
|
||||
sending = null
|
||||
outcome = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The directory from plain values, so it can be drawn without a session. */
|
||||
@Composable
|
||||
internal fun ColumnScope.DirectoryPage(
|
||||
directory: Directory?,
|
||||
category: String?,
|
||||
query: String,
|
||||
said: String? = null,
|
||||
onQuery: (String) -> Unit,
|
||||
onBrowse: (String?) -> Unit,
|
||||
onOpen: (String) -> Unit,
|
||||
onSend: (Directory.Site) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
PortalHeader(title = "The small web", subtitle = "From the FlameNet directory") {
|
||||
PortalHeaderIcon(
|
||||
icon = KitIcon.Close,
|
||||
contentDescription = "Back to Today",
|
||||
onClick = onClose,
|
||||
modifier = Modifier.testTag("directory-close"),
|
||||
)
|
||||
}
|
||||
|
||||
if (directory == null) {
|
||||
PortalBody {
|
||||
Text("Opening the directory.", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
PortalBody {
|
||||
// Shown once, here, and never on a site or in anything sent.
|
||||
directory.note?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.testTag("directory-note"),
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQuery,
|
||||
placeholder = { Text("Search ${NumberFormat.getIntegerInstance().format(directory.sites.size)} sites", fontSize = 14.sp) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("directory-search"),
|
||||
)
|
||||
said?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, modifier = Modifier.testTag("directory-said"))
|
||||
}
|
||||
}
|
||||
|
||||
if (query.isNotBlank()) {
|
||||
val found = directory.search(query)
|
||||
PortalBand(
|
||||
when (found.size) {
|
||||
0 -> "No sites match"
|
||||
1 -> "1 site"
|
||||
Directory.SEARCH_LIMIT -> "The first ${Directory.SEARCH_LIMIT} sites"
|
||||
else -> "${found.size} sites"
|
||||
},
|
||||
)
|
||||
for (site in found) SiteRow(site, onOpen, onSend)
|
||||
return
|
||||
}
|
||||
|
||||
val here = category?.let(directory::category)
|
||||
if (here == null) {
|
||||
PortalBand("Categories")
|
||||
for (c in directory.top) CategoryRow(c, directory.count(c.id)) { onBrowse(c.id) }
|
||||
return
|
||||
}
|
||||
|
||||
// Where this category sits, and the way back up.
|
||||
val parent = here.parent.takeIf { it.isNotEmpty() }?.let(directory::category)
|
||||
PortalRow(modifier = Modifier.clickable { onBrowse(parent?.id) }.testTag("directory-up")) {
|
||||
Icon(KitIcon.Back, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||
Text(
|
||||
parent?.name ?: "All categories",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
PortalBand(here.name)
|
||||
for (c in directory.children(here.id)) CategoryRow(c, directory.count(c.id)) { onBrowse(c.id) }
|
||||
val sites = directory.sitesIn(here.id)
|
||||
if (sites.isNotEmpty() && directory.children(here.id).isNotEmpty()) PortalBand("Sites")
|
||||
for (site in sites) SiteRow(site, onOpen, onSend)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoryRow(
|
||||
category: Directory.Category,
|
||||
count: Int,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
PortalRow(modifier = Modifier.clickable(onClick = onClick).testTag("directory-category-${category.id}")) {
|
||||
Text(category.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
Text(NumberFormat.getIntegerInstance().format(count), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SiteRow(
|
||||
site: Directory.Site,
|
||||
onOpen: (String) -> Unit,
|
||||
onSend: (Directory.Site) -> Unit,
|
||||
) {
|
||||
LinkRow(
|
||||
title = site.title,
|
||||
detail = site.description,
|
||||
url = site.url,
|
||||
onOpen = onOpen,
|
||||
tag = "directory-site",
|
||||
trailing = {
|
||||
// 44dp, the floor a thumb needs, in the screen's ink, not the
|
||||
// chrome's white.
|
||||
androidx.compose.foundation.layout.Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.heightIn(min = 44.dp)
|
||||
.clickable(onClickLabel = "Send to a chat") { onSend(site) }
|
||||
.padding(Space.md)
|
||||
.testTag("directory-send"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
KitIcon.Send,
|
||||
contentDescription = "Send ${site.title} to a chat",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Who to send a site to. One person at a time, through the ordinary send path
|
||||
* with a preview made from the directory entry. Once sent, the dialog says
|
||||
* what happened and offers nothing else.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SendSiteDialog(
|
||||
site: Directory.Site,
|
||||
people: List<MessengerApi.Buddy>,
|
||||
outcome: String?,
|
||||
onSend: (MessengerApi.Buddy) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sorted = remember(people) { people.sortedBy { it.displayName.ifBlank { it.handle }.lowercase() } }
|
||||
var chosen by remember { mutableStateOf<MessengerApi.Buddy?>(null) }
|
||||
val context = LocalContext.current
|
||||
val withPreview = remember { MediaSettings(context).linkPreviews }
|
||||
if (outcome != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Send to a chat") },
|
||||
text = {
|
||||
Text(outcome, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.testTag("send-outcome"))
|
||||
},
|
||||
confirmButton = { KitPrimaryButton(text = "Done", onClick = onDismiss, tag = "send-done") },
|
||||
)
|
||||
return
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Send to a chat") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||
Text(
|
||||
if (withPreview) {
|
||||
"${site.title} goes as its address, with a preview that shows its name. " +
|
||||
"Nothing is fetched to make the preview."
|
||||
} else {
|
||||
"${site.title} goes as its address. Link previews are off, so no preview goes with it."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.testTag("send-explained"),
|
||||
)
|
||||
if (sorted.isEmpty()) {
|
||||
Text(
|
||||
"Add someone to your contacts first. A site goes to one person at a time.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.testTag("send-nobody"),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.heightIn(max = 300.dp)) {
|
||||
items(sorted, key = { it.userId }) { buddy ->
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { chosen = buddy }
|
||||
.testTag("send-to-${buddy.userId}"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = chosen == buddy, onClick = { chosen = buddy })
|
||||
Column {
|
||||
Text(
|
||||
buddy.displayName.ifBlank { buddy.handle },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text("@${buddy.handle}", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
KitPrimaryButton(
|
||||
text = "Send",
|
||||
onClick = { chosen?.let(onSend) },
|
||||
enabled = chosen != null,
|
||||
tag = "send-site",
|
||||
)
|
||||
},
|
||||
dismissButton = {
|
||||
KitChip(text = "Cancel", selected = false, onClick = onDismiss, tag = "send-cancel")
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import io.flamenet.client.Conversations
|
||||
import io.flamenet.e2e.Content
|
||||
|
||||
/**
|
||||
* Sending a directory site to a chat (SPEC §6.12, DESIGN 18).
|
||||
*
|
||||
* An ordinary text through the ordinary 1:1 send path: the site's address as
|
||||
* the words, and a link preview made from the directory entry. Nothing is
|
||||
* fetched to make the preview, not by the sender and not by anyone who
|
||||
* receives it.
|
||||
*/
|
||||
object DirectoryShare {
|
||||
/** The words of the message: the address and nothing else. */
|
||||
fun text(site: Directory.Site): String = site.url
|
||||
|
||||
/**
|
||||
* The preview the message carries: the title, and an empty description.
|
||||
* `d` is FlameNet's line about the site, and a preview reads as the
|
||||
* site's own words, so it stays out. The key itself stays in, empty,
|
||||
* because older builds require it.
|
||||
*/
|
||||
fun preview(site: Directory.Site): Content.LinkPreview = Content.LinkPreview.of(site.url, site.title, "")
|
||||
|
||||
/**
|
||||
* Send [site] to one person, the way a typed message with a preview goes.
|
||||
* With link previews turned off the address goes alone, as it would if
|
||||
* they had typed it.
|
||||
*/
|
||||
fun send(
|
||||
session: Session,
|
||||
peerUserId: Long,
|
||||
site: Directory.Site,
|
||||
withPreview: Boolean,
|
||||
): Conversations.Sent = session.send(peerUserId, text(site), if (withPreview) preview(site) else null)
|
||||
}
|
||||
@@ -18,7 +18,9 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.messenger.R
|
||||
|
||||
/**
|
||||
* How this phone draws things.
|
||||
@@ -32,7 +34,7 @@ fun ColumnScope.DisplaySection() {
|
||||
var larger by remember { mutableStateOf(settings.largerType) }
|
||||
var appearance by remember { mutableStateOf(settings.appearance) }
|
||||
|
||||
Text("Display", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.set_display_title), style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
@Composable
|
||||
fun row(
|
||||
@@ -54,25 +56,33 @@ fun ColumnScope.DisplaySection() {
|
||||
}
|
||||
}
|
||||
|
||||
row("Show whether contacts are online", "display-online", online) { on ->
|
||||
row(stringResource(R.string.set_display_show_online), "display-online", online) { on ->
|
||||
settings.showOnlineStatus = on
|
||||
online = on
|
||||
}
|
||||
row("Show the time on messages", "display-timestamps", times) { on ->
|
||||
row(stringResource(R.string.set_display_show_times), "display-timestamps", times) { on ->
|
||||
settings.showTimestamps = on
|
||||
times = on
|
||||
}
|
||||
row("Larger message text", "display-larger-type", larger) { on ->
|
||||
row(stringResource(R.string.set_display_larger_text), "display-larger-type", larger) { on ->
|
||||
settings.largerType = on
|
||||
larger = on
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Text("Appearance", fontSize = 14.sp)
|
||||
Text(stringResource(R.string.set_display_appearance), fontSize = 14.sp)
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = Space.xs)) {
|
||||
DisplaySettings.Appearance.entries.forEach { option ->
|
||||
val label =
|
||||
stringResource(
|
||||
when (option) {
|
||||
DisplaySettings.Appearance.SYSTEM -> R.string.set_display_appearance_system
|
||||
DisplaySettings.Appearance.LIGHT -> R.string.set_display_appearance_light
|
||||
DisplaySettings.Appearance.DARK -> R.string.set_display_appearance_dark
|
||||
},
|
||||
)
|
||||
KitSecondaryButton(
|
||||
text = if (option == appearance) "✓ ${option.label}" else option.label,
|
||||
text = if (option == appearance) stringResource(R.string.set_display_selected, label) else label,
|
||||
onClick = {
|
||||
settings.appearance = option
|
||||
appearance = option
|
||||
@@ -87,10 +97,7 @@ fun ColumnScope.DisplaySection() {
|
||||
// Says the thing the label alone cannot: this hides them from you, not you
|
||||
// from them.
|
||||
Text(
|
||||
"These change what this phone shows you. Your contacts are still told when " +
|
||||
"you are online whichever way this is set, hiding yourself is not " +
|
||||
"something the app can do yet.\n\nAppearance takes effect next time the " +
|
||||
"app starts.",
|
||||
stringResource(R.string.set_display_footer),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import io.flamenet.e2e.Content
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* How an event's time reads (§6.32), worked out from the event and the
|
||||
* reader's zone and locale alone, so it can be checked without a device.
|
||||
*/
|
||||
object EventText {
|
||||
/** The most days an all-day event is drawn across, whatever it says. */
|
||||
private const val MAX_DAYS = 366
|
||||
|
||||
/** When it is, in the reader's own zone. */
|
||||
fun whenLine(
|
||||
event: Content.Event,
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
locale: Locale = Locale.getDefault(),
|
||||
): String {
|
||||
val day = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
|
||||
val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||
val start = event.start
|
||||
if (start == null) {
|
||||
val first = event.day?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: return ""
|
||||
val span = (event.days ?: 1).coerceIn(1, MAX_DAYS)
|
||||
if (span == 1) return "${first.format(day)}, all day"
|
||||
return "${first.format(day)} to ${first.plusDays(span - 1L).format(day)}, all day"
|
||||
}
|
||||
val from = Instant.ofEpochSecond(start).atZone(zone)
|
||||
val opening = "${from.format(day)}, ${from.format(time)}"
|
||||
val end = event.end?.takeIf { it >= start } ?: return opening
|
||||
val to = Instant.ofEpochSecond(end).atZone(zone)
|
||||
return if (to.toLocalDate() == from.toLocalDate()) {
|
||||
"$opening to ${to.format(time)}"
|
||||
} else {
|
||||
"$opening to ${to.format(day)}, ${to.format(time)}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "7:00 PM New York time", when the organizer's clock reads differently
|
||||
* from the reader's at the start, and null when it reads the same or the
|
||||
* event has no zone. Compared by offset, not by name: a reader in Toronto
|
||||
* does not need New York's time spelled out.
|
||||
*/
|
||||
fun organizerLine(
|
||||
event: Content.Event,
|
||||
readerZone: ZoneId = ZoneId.systemDefault(),
|
||||
locale: Locale = Locale.getDefault(),
|
||||
): String? {
|
||||
val start = event.start ?: return null
|
||||
val name = event.tz ?: return null
|
||||
val theirs = runCatching { ZoneId.of(name) }.getOrNull() ?: return null
|
||||
val instant = Instant.ofEpochSecond(start)
|
||||
if (theirs.rules.getOffset(instant) == readerZone.rules.getOffset(instant)) return null
|
||||
val there = instant.atZone(theirs)
|
||||
val here = instant.atZone(readerZone)
|
||||
val time = there.format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale))
|
||||
// The weekday too when the two clocks are on different days.
|
||||
val prefix =
|
||||
if (there.toLocalDate() != here.toLocalDate()) {
|
||||
there.format(DateTimeFormatter.ofPattern("EEE", locale)) + " "
|
||||
} else {
|
||||
""
|
||||
}
|
||||
return "$prefix$time ${city(name)} time"
|
||||
}
|
||||
|
||||
/** "New York" for `America/New_York`, the last part of the zone's name. */
|
||||
fun city(tz: String): String = tz.substringAfterLast('/').replace('_', ' ')
|
||||
|
||||
/** What the system's add-event sheet is handed. Times in epoch millis. */
|
||||
data class CalendarSpan(
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long?,
|
||||
val allDay: Boolean,
|
||||
/** The organizer's zone for a timed event, which the calendar keeps. */
|
||||
val zone: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* An all-day event runs from midnight UTC on its first day to midnight
|
||||
* UTC after its last, which is how the calendar provider stores all-day
|
||||
* events. Null when the event has no time it could be put at.
|
||||
*/
|
||||
fun calendarSpan(event: Content.Event): CalendarSpan? {
|
||||
val start = event.start
|
||||
if (start != null) {
|
||||
return CalendarSpan(
|
||||
beginMillis = start * 1000,
|
||||
endMillis = event.end?.takeIf { it >= start }?.let { it * 1000 },
|
||||
allDay = false,
|
||||
zone = event.tz?.takeIf { runCatching { ZoneId.of(it) }.isSuccess },
|
||||
)
|
||||
}
|
||||
val first = event.day?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: return null
|
||||
val span = (event.days ?: 1).coerceIn(1, MAX_DAYS)
|
||||
val begin = first.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
val end = first.plusDays(span.toLong()).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
return CalendarSpan(begin, end, allDay = true, zone = null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.CalendarContract
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TimePicker
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.e2e.ChatEvents
|
||||
import io.flamenet.e2e.Content
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
/** The three answers, in the order the buttons sit. */
|
||||
private val ANSWERS =
|
||||
listOf(
|
||||
Content.Rsvp.YES to "Going",
|
||||
Content.Rsvp.MAYBE to "Maybe",
|
||||
Content.Rsvp.NO to "Can't go",
|
||||
)
|
||||
|
||||
/**
|
||||
* An event in a conversation (§6.32): when and where, who is coming, and
|
||||
* the three answers. Revisions and answers change this card; they never add
|
||||
* another one.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun EventBubble(
|
||||
line: Session.Line,
|
||||
state: ChatEvents.State,
|
||||
myUserId: Long,
|
||||
/** A member's name as this phone knows it. */
|
||||
nameOf: (Long) -> String,
|
||||
onAnswer: (String) -> Unit,
|
||||
onAddToCalendar: () -> Unit,
|
||||
onLongPress: () -> Unit,
|
||||
) {
|
||||
val event = state.event
|
||||
val mine = state.answers[myUserId]?.r
|
||||
val shape = RoundedCornerShape(Kit.bubbleRadius)
|
||||
val whenLine = remember(event) { EventText.whenLine(event) }
|
||||
val theirZone = remember(event) { EventText.organizerLine(event) }
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(0.86f)
|
||||
.clip(shape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.border(Kit.hairline, Portal.rule(), shape)
|
||||
.combinedClickable(onClick = {}, onLongClick = onLongPress)
|
||||
.padding(Kit.bubblePadding)
|
||||
.testTag("event-${event.id}"),
|
||||
verticalArrangement = Arrangement.spacedBy(Space.xs),
|
||||
) {
|
||||
Text("Event", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
// §6.32: a cancelled event's card reads "Cancelled: <title>".
|
||||
Text(
|
||||
if (event.cancelled) "Cancelled: ${event.shownTitle}" else event.shownTitle,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (event.cancelled) MaterialTheme.colorScheme.error else androidx.compose.ui.graphics.Color.Unspecified,
|
||||
modifier = Modifier.testTag(if (event.cancelled) "event-cancelled" else "event-title"),
|
||||
)
|
||||
Text(whenLine, fontSize = 14.sp, modifier = Modifier.testTag("event-when"))
|
||||
theirZone?.let {
|
||||
Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.testTag("event-their-time"))
|
||||
}
|
||||
event.shownPlace?.let { Text(it, fontSize = 14.sp, modifier = Modifier.testTag("event-place")) }
|
||||
event.shownNote?.let { Text(it, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.testTag("event-note")) }
|
||||
Text(
|
||||
when (state.organizer) {
|
||||
myUserId -> "You organized this"
|
||||
// Only a backup missing its organizer leaves this unknown,
|
||||
// and then nobody can change the event.
|
||||
0L -> "Organizer not known"
|
||||
else -> "Organized by ${nameOf(state.organizer)}"
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (!event.cancelled) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs), modifier = Modifier.fillMaxWidth()) {
|
||||
ANSWERS.forEach { (answer, label) ->
|
||||
KitChip(
|
||||
text = label,
|
||||
selected = mine == answer,
|
||||
onClick = { if (mine != answer) onAnswer(answer) },
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.semantics { contentDescription = if (mine == answer) "$label, your answer" else label },
|
||||
tag = "rsvp-$answer",
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Everyone in this chat sees your answer.",
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.testTag("rsvp-visibility"),
|
||||
)
|
||||
}
|
||||
AnswerLines(state, myUserId, nameOf)
|
||||
if (!event.cancelled && EventText.calendarSpan(event) != null) {
|
||||
KitSecondaryButton(
|
||||
text = "Add to calendar",
|
||||
onClick = onAddToCalendar,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "event-add-to-calendar",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Going 2: Sam, you", one line per answer anybody gave. */
|
||||
@Composable
|
||||
private fun AnswerLines(
|
||||
state: ChatEvents.State,
|
||||
myUserId: Long,
|
||||
nameOf: (Long) -> String,
|
||||
) {
|
||||
val lines =
|
||||
ANSWERS.mapNotNull { (answer, label) ->
|
||||
val who = state.who(answer)
|
||||
if (who.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
"$label ${who.size}: " + who.joinToString(", ") { if (it == myUserId) "you" else nameOf(it) }
|
||||
}
|
||||
}
|
||||
if (lines.isEmpty()) {
|
||||
Text("No answers yet.", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.testTag("rsvp-none"))
|
||||
} else {
|
||||
lines.forEach { Text(it, fontSize = 12.sp, modifier = Modifier.testTag("rsvp-line")) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The system's own add-event sheet (§6.32). Nothing is read from the
|
||||
* calendar and no permission is asked for: the person sees the event in their
|
||||
* calendar app and decides there whether to save it.
|
||||
*/
|
||||
object EventCalendar {
|
||||
fun intent(event: Content.Event): Intent? {
|
||||
val span = EventText.calendarSpan(event) ?: return null
|
||||
val intent =
|
||||
Intent(Intent.ACTION_INSERT)
|
||||
.setData(CalendarContract.Events.CONTENT_URI)
|
||||
.putExtra(CalendarContract.Events.TITLE, event.shownTitle)
|
||||
.putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, span.beginMillis)
|
||||
.putExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, span.allDay)
|
||||
span.endMillis?.let { intent.putExtra(CalendarContract.EXTRA_EVENT_END_TIME, it) }
|
||||
span.zone?.let { intent.putExtra(CalendarContract.Events.EVENT_TIMEZONE, it) }
|
||||
event.shownPlace?.let { intent.putExtra(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
event.shownNote?.let { intent.putExtra(CalendarContract.Events.DESCRIPTION, it) }
|
||||
return intent
|
||||
}
|
||||
|
||||
/** Hand the event to the calendar app. False when the phone has none that takes it. */
|
||||
fun open(
|
||||
context: Context,
|
||||
event: Content.Event,
|
||||
): Boolean {
|
||||
val intent = intent(event) ?: return false
|
||||
return try {
|
||||
context.startActivity(intent)
|
||||
true
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an event, or change one this account organized. A timed event carries
|
||||
* this phone's zone, so a reader elsewhere can be told the organizer's time.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EventEditorDialog(
|
||||
initial: Content.Event?,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Content.Event) -> Unit,
|
||||
) {
|
||||
val zone = ZoneId.systemDefault()
|
||||
val startAt = initial?.start?.let { java.time.Instant.ofEpochSecond(it).atZone(zone) }
|
||||
val endAt = initial?.end?.let { java.time.Instant.ofEpochSecond(it).atZone(zone) }
|
||||
val initialDay = initial?.day?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||
|
||||
var title by remember { mutableStateOf(initial?.title.orEmpty()) }
|
||||
var allDay by remember { mutableStateOf(initial?.allDay == true) }
|
||||
var day by remember { mutableStateOf(startAt?.toLocalDate() ?: initialDay ?: LocalDate.now().plusDays(1)) }
|
||||
var time by remember { mutableStateOf(startAt?.toLocalTime()?.withSecond(0)?.withNano(0) ?: LocalTime.of(18, 0)) }
|
||||
var hasEnd by remember {
|
||||
mutableStateOf(endAt != null || (initial?.days ?: 1) > 1)
|
||||
}
|
||||
var endDay by remember {
|
||||
mutableStateOf(endAt?.toLocalDate() ?: initialDay?.plusDays(((initial?.days ?: 1) - 1).toLong()) ?: day)
|
||||
}
|
||||
var endTime by remember { mutableStateOf(endAt?.toLocalTime()?.withSecond(0)?.withNano(0) ?: time.plusHours(2)) }
|
||||
var place by remember { mutableStateOf(initial?.place.orEmpty()) }
|
||||
var note by remember { mutableStateOf(initial?.note.orEmpty()) }
|
||||
var picking by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val startMoment = day.atTime(time).atZone(zone).toEpochSecond()
|
||||
val endMoment = if (allDay) null else endDay.atTime(endTime).atZone(zone).toEpochSecond()
|
||||
val endsBeforeItStarts =
|
||||
hasEnd && if (allDay) endDay.isBefore(day) else (endMoment ?: startMoment) < startMoment
|
||||
val ready = title.isNotBlank() && !endsBeforeItStarts
|
||||
|
||||
val dayFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(if (initial == null) "New event" else "Change event") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).testTag("event-editor"),
|
||||
verticalArrangement = Arrangement.spacedBy(Space.sm),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it.take(Content.Event.MAX_TITLE) },
|
||||
label = { Text("What") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("event-title-field"),
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Switch(checked = allDay, onCheckedChange = { allDay = it }, modifier = Modifier.testTag("event-all-day"))
|
||||
Spacer(Modifier.width(Space.sm))
|
||||
Text("All day", fontSize = 14.sp)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs)) {
|
||||
TextButton(onClick = { picking = "day" }, modifier = Modifier.testTag("event-day")) {
|
||||
Text(day.format(dayFormat))
|
||||
}
|
||||
if (!allDay) {
|
||||
TextButton(onClick = { picking = "time" }, modifier = Modifier.testTag("event-time")) {
|
||||
Text(time.format(timeFormat))
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Switch(checked = hasEnd, onCheckedChange = { hasEnd = it }, modifier = Modifier.testTag("event-has-end"))
|
||||
Spacer(Modifier.width(Space.sm))
|
||||
Text(if (allDay) "More than one day" else "Add an end", fontSize = 14.sp)
|
||||
}
|
||||
if (hasEnd) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs)) {
|
||||
TextButton(onClick = { picking = "endDay" }, modifier = Modifier.testTag("event-end-day")) {
|
||||
Text(endDay.format(dayFormat))
|
||||
}
|
||||
if (!allDay) {
|
||||
TextButton(onClick = { picking = "endTime" }, modifier = Modifier.testTag("event-end-time")) {
|
||||
Text(endTime.format(timeFormat))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (endsBeforeItStarts) {
|
||||
Text("It ends before it starts.", fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = place,
|
||||
onValueChange = { place = it.take(Content.Event.MAX_PLACE) },
|
||||
label = { Text("Where (optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("event-place-field"),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = note,
|
||||
onValueChange = { note = it.take(Content.Event.MAX_NOTE) },
|
||||
label = { Text("Note (optional)") },
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth().testTag("event-note-field"),
|
||||
)
|
||||
Text(
|
||||
"Everyone in this chat gets it, and sees who answers what. A place is " +
|
||||
"shown as written; nothing looks it up.",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = ready,
|
||||
onClick = {
|
||||
val days = (endDay.toEpochDay() - day.toEpochDay() + 1).toInt()
|
||||
onSave(
|
||||
Content.Event(
|
||||
id = initial?.id ?: java.util.UUID.randomUUID().toString(),
|
||||
rev = initial?.rev ?: 1,
|
||||
title = title.trim(),
|
||||
start = if (allDay) null else startMoment,
|
||||
end = if (!allDay && hasEnd) endMoment else null,
|
||||
day = if (allDay) day.toString() else null,
|
||||
days = if (allDay && hasEnd) Content.Event.clampDays(days.toLong()) else null,
|
||||
tz = if (allDay) null else zone.id,
|
||||
place = place.trim().takeIf { it.isNotEmpty() },
|
||||
note = note.trim().takeIf { it.isNotEmpty() },
|
||||
),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.testTag("event-save"),
|
||||
) { Text(if (initial == null) "Send" else "Send change") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
|
||||
when (picking) {
|
||||
"day", "endDay" -> {
|
||||
val current = if (picking == "day") day else endDay
|
||||
val state = rememberDatePickerState(initialSelectedDateMillis = current.toEpochDay() * 86_400_000L)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { picking = null },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
state.selectedDateMillis?.let {
|
||||
val chosen = LocalDate.ofEpochDay(it / 86_400_000L)
|
||||
if (picking == "day") {
|
||||
// Keep a following end the same distance away.
|
||||
val shift = chosen.toEpochDay() - day.toEpochDay()
|
||||
day = chosen
|
||||
endDay = endDay.plusDays(shift)
|
||||
} else {
|
||||
endDay = chosen
|
||||
}
|
||||
}
|
||||
picking = null
|
||||
}) { Text("OK") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { picking = null }) { Text("Cancel") } },
|
||||
) { DatePicker(state = state) }
|
||||
}
|
||||
|
||||
"time", "endTime" -> {
|
||||
val current = if (picking == "time") time else endTime
|
||||
val state =
|
||||
rememberTimePickerState(
|
||||
initialHour = current.hour,
|
||||
initialMinute = current.minute,
|
||||
is24Hour = android.text.format.DateFormat.is24HourFormat(LocalContext.current),
|
||||
)
|
||||
AlertDialog(
|
||||
onDismissRequest = { picking = null },
|
||||
text = { TimePicker(state = state) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val chosen = LocalTime.of(state.hour, state.minute)
|
||||
if (picking == "time") time = chosen else endTime = chosen
|
||||
picking = null
|
||||
}) { Text("OK") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { picking = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
@@ -31,12 +31,15 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.client.LocalVault
|
||||
import io.flamenet.messenger.R
|
||||
import java.util.UUID
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -51,7 +54,7 @@ fun ColumnScope.FilesScreen(
|
||||
) {
|
||||
val files = phase.files ?: MessengerViewModel.FilesState()
|
||||
|
||||
PortalHeader(title = "Files")
|
||||
PortalHeader(title = stringResource(R.string.main_files))
|
||||
|
||||
when {
|
||||
!files.hasIdentity -> FilesSetUp(model, files)
|
||||
@@ -104,16 +107,13 @@ private fun ColumnScope.FilesSetUp(
|
||||
// iOS never did. iOS says "your passphrase" and takes whatever is typed, so
|
||||
// a person naturally uses their Flamenet one and it simply works.
|
||||
Text(
|
||||
"Use your Flamenet passphrase, the one you sign in with. Files on " +
|
||||
"this phone are encrypted with it, and Flamenet keeps no copy, so " +
|
||||
"nobody can open them by picking up your unlocked phone.",
|
||||
stringResource(R.string.main_files_setup_intro),
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Text(
|
||||
"If you forget it, the files are gone for good, there's no reset, " +
|
||||
"because there's nothing anywhere that could do one.",
|
||||
stringResource(R.string.main_files_setup_warning),
|
||||
fontSize = 13.sp,
|
||||
color = Palette.Warning,
|
||||
)
|
||||
@@ -122,7 +122,7 @@ private fun ColumnScope.FilesSetUp(
|
||||
OutlinedTextField(
|
||||
value = passphrase,
|
||||
onValueChange = { passphrase = it },
|
||||
label = { Text("Your Flamenet passphrase") },
|
||||
label = { Text(stringResource(R.string.main_files_your_passphrase)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("files-new-passphrase"),
|
||||
@@ -133,7 +133,7 @@ private fun ColumnScope.FilesSetUp(
|
||||
// already sign in with, so asking twice only suggested it was a different
|
||||
// one, which is the opposite of what the paragraph above says.
|
||||
KitPrimaryButton(
|
||||
text = files.busy ?: "Set up Files",
|
||||
text = files.busy ?: stringResource(R.string.main_files_set_up),
|
||||
onClick = { model.createFilesIdentity(passphrase) },
|
||||
enabled = files.busy == null && passphrase.isNotEmpty(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -150,7 +150,7 @@ private fun ColumnScope.FilesUnlock(
|
||||
|
||||
// Not "your Files passphrase".
|
||||
Text(
|
||||
"Enter your Flamenet passphrase to open them.",
|
||||
stringResource(R.string.main_files_unlock_prompt),
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -158,14 +158,14 @@ private fun ColumnScope.FilesUnlock(
|
||||
OutlinedTextField(
|
||||
value = passphrase,
|
||||
onValueChange = { passphrase = it },
|
||||
label = { Text("Passphrase") },
|
||||
label = { Text(stringResource(R.string.main_passphrase)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("files-passphrase"),
|
||||
)
|
||||
Spacer(Modifier.height(Space.md))
|
||||
KitPrimaryButton(
|
||||
text = files.busy ?: "Unlock",
|
||||
text = files.busy ?: stringResource(R.string.common_unlock),
|
||||
onClick = { model.unlockFiles(passphrase) },
|
||||
enabled = files.busy == null && passphrase.isNotEmpty(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -183,6 +183,7 @@ private fun ColumnScope.FilesBrowser(
|
||||
val context = LocalContext.current
|
||||
var pending by remember { mutableStateOf<PendingFile?>(null) }
|
||||
var namingFolder by remember { mutableStateOf(false) }
|
||||
val untitled = stringResource(R.string.main_files_untitled)
|
||||
|
||||
/**
|
||||
* `OpenDocument`, never `GetContent`.
|
||||
@@ -191,7 +192,7 @@ private fun ColumnScope.FilesBrowser(
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
val saf = SafReferences(context)
|
||||
pending = PendingFile(uri, saf.displayName(uri) ?: "Untitled")
|
||||
pending = PendingFile(uri, saf.displayName(uri) ?: untitled)
|
||||
}
|
||||
|
||||
FilesSectionSwitch(model, files)
|
||||
@@ -203,10 +204,9 @@ private fun ColumnScope.FilesBrowser(
|
||||
if (rows.isEmpty()) {
|
||||
val empty =
|
||||
if (files.section == LocalVault.Storage.VAULT) {
|
||||
"Nothing in the vault here yet. Anything you add is encrypted on this phone."
|
||||
stringResource(R.string.main_files_vault_empty)
|
||||
} else {
|
||||
"No linked files here. A link points at a file where it already is, " +
|
||||
"nothing is copied, and nothing is encrypted until you send it."
|
||||
stringResource(R.string.main_files_linked_empty)
|
||||
}
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Text(empty, fontSize = 13.sp, color = Palette.SecondaryText)
|
||||
@@ -218,14 +218,14 @@ private fun ColumnScope.FilesBrowser(
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||
KitSecondaryButton(
|
||||
text = "Add a file",
|
||||
text = stringResource(R.string.main_files_add_file),
|
||||
// Everything: the vault does not care what a file is.
|
||||
onClick = { pick.launch(arrayOf("*/*")) },
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "files-add",
|
||||
)
|
||||
KitSecondaryButton(
|
||||
text = "New folder",
|
||||
text = stringResource(R.string.main_files_new_folder),
|
||||
onClick = { namingFolder = true },
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "files-new-folder",
|
||||
@@ -234,14 +234,14 @@ private fun ColumnScope.FilesBrowser(
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||
KitSecondaryButton(
|
||||
text = if (files.selecting) "Done" else "Select",
|
||||
text = if (files.selecting) stringResource(R.string.common_done) else stringResource(R.string.main_files_select),
|
||||
onClick = { model.filesToggleSelecting() },
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "files-select",
|
||||
)
|
||||
if (files.selecting) {
|
||||
KitSecondaryButton(
|
||||
text = "Delete ${files.selected.size}",
|
||||
text = stringResource(R.string.main_files_delete_count, files.selected.size),
|
||||
onClick = { model.filesDeleteSelected() },
|
||||
enabled = files.selected.isNotEmpty(),
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -249,7 +249,7 @@ private fun ColumnScope.FilesBrowser(
|
||||
)
|
||||
}
|
||||
KitSecondaryButton(
|
||||
text = "Lock",
|
||||
text = stringResource(R.string.main_files_lock),
|
||||
onClick = { model.lockFiles() },
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "files-lock",
|
||||
@@ -296,13 +296,13 @@ private fun ColumnScope.FilesSectionSwitch(
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||
KitChip(
|
||||
text = "In the vault",
|
||||
text = stringResource(R.string.main_files_in_vault),
|
||||
selected = files.section == LocalVault.Storage.VAULT,
|
||||
onClick = { model.filesSetSection(LocalVault.Storage.VAULT) },
|
||||
tag = "files-section-vault",
|
||||
)
|
||||
KitChip(
|
||||
text = "Linked",
|
||||
text = stringResource(R.string.main_files_linked),
|
||||
selected = files.section == LocalVault.Storage.REFERENCE,
|
||||
onClick = { model.filesSetSection(LocalVault.Storage.REFERENCE) },
|
||||
tag = "files-section-linked",
|
||||
@@ -321,7 +321,7 @@ private fun ColumnScope.FilesBreadcrumb(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Files",
|
||||
stringResource(R.string.main_files),
|
||||
fontSize = 13.sp,
|
||||
color = Palette.Accent,
|
||||
modifier = Modifier.clickable { model.filesGoTo(0) }.testTag("files-crumb-root"),
|
||||
@@ -370,7 +370,7 @@ private fun ColumnScope.FileRow(
|
||||
Text(
|
||||
// A row this phone cannot read is still SHOWN, with its
|
||||
// classification.
|
||||
text = entry.name ?: "Encrypted for someone else",
|
||||
text = entry.name ?: stringResource(R.string.main_files_encrypted_for_other),
|
||||
fontSize = 15.sp,
|
||||
fontWeight = if (entry.isFolder) FontWeight.Medium else FontWeight.Normal,
|
||||
color = if (entry.name == null) Palette.SecondaryText else MaterialTheme.colorScheme.onSurface,
|
||||
@@ -384,7 +384,7 @@ private fun ColumnScope.FileRow(
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
if (entry.isMissing) {
|
||||
Text(" · file has moved", fontSize = 11.sp, color = Palette.Warning)
|
||||
Text(stringResource(R.string.main_files_has_moved), fontSize = 11.sp, color = Palette.Warning)
|
||||
} else if (!entry.isFolder && entry.byteCount > 0) {
|
||||
Text(
|
||||
" · ${bytes(entry.byteCount)}",
|
||||
@@ -400,7 +400,7 @@ private fun ColumnScope.FileRow(
|
||||
!entry.isFolder && !entry.isMissing
|
||||
) {
|
||||
KitSecondaryButton(
|
||||
text = "Encrypt",
|
||||
text = stringResource(R.string.main_files_encrypt),
|
||||
onClick = { model.filesEncrypt(entry.id) },
|
||||
tag = "file-encrypt",
|
||||
)
|
||||
@@ -408,11 +408,12 @@ private fun ColumnScope.FileRow(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun bytes(count: Int): String =
|
||||
when {
|
||||
count >= 1_000_000 -> "%.1f MB".format(count / 1_000_000.0)
|
||||
count >= 1_000 -> "%.0f kB".format(count / 1_000.0)
|
||||
else -> "$count bytes"
|
||||
count >= 1_000_000 -> stringResource(R.string.main_files_size_mb, count / 1_000_000.0)
|
||||
count >= 1_000 -> stringResource(R.string.main_files_size_kb, count / 1_000.0)
|
||||
else -> pluralStringResource(R.plurals.main_files_size_bytes, count, count)
|
||||
}
|
||||
|
||||
// ---- Sheets --------------------------------------------------------------.
|
||||
@@ -442,12 +443,12 @@ private fun ColumnScope.AddFileSheet(
|
||||
.padding(Space.md)
|
||||
.testTag("files-add-sheet"),
|
||||
) {
|
||||
Text("Add a file", fontWeight = FontWeight.Medium, fontSize = 16.sp)
|
||||
Text(stringResource(R.string.main_files_add_file), fontWeight = FontWeight.Medium, fontSize = 16.sp)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
label = { Text(stringResource(R.string.common_name)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("files-add-name"),
|
||||
)
|
||||
@@ -458,36 +459,33 @@ private fun ColumnScope.AddFileSheet(
|
||||
Spacer(Modifier.height(Space.md))
|
||||
|
||||
Text(
|
||||
"Copy it in and encrypt it. A stolen phone or backup gives up " +
|
||||
"nothing but ciphertext.",
|
||||
stringResource(R.string.main_files_copy_in_note),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
KitPrimaryButton(
|
||||
text = "Copy in and encrypt",
|
||||
text = stringResource(R.string.main_files_copy_in),
|
||||
onClick = { onVault(name.trim().ifEmpty { file.name }, level, areas) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "files-add-vault",
|
||||
)
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Text(
|
||||
"Or link to it where it is. Nothing is copied and nothing is " +
|
||||
"encrypted until you send it, until then the classification " +
|
||||
"is a note, not a lock.",
|
||||
stringResource(R.string.main_files_link_note),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
KitSecondaryButton(
|
||||
text = "Link to it instead",
|
||||
text = stringResource(R.string.main_files_link_instead),
|
||||
onClick = { onLink(name.trim().ifEmpty { file.name }, level, areas) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "files-add-link",
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
KitSecondaryButton(
|
||||
text = "Cancel",
|
||||
text = stringResource(R.string.common_cancel),
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "files-add-cancel",
|
||||
@@ -514,12 +512,12 @@ private fun ColumnScope.FolderNameSheet(
|
||||
.padding(Space.md)
|
||||
.testTag("files-folder-sheet"),
|
||||
) {
|
||||
Text("New folder", fontWeight = FontWeight.Medium, fontSize = 16.sp)
|
||||
Text(stringResource(R.string.main_files_new_folder), fontWeight = FontWeight.Medium, fontSize = 16.sp)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
label = { Text(stringResource(R.string.common_name)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("files-folder-name"),
|
||||
)
|
||||
@@ -531,21 +529,20 @@ private fun ColumnScope.FolderNameSheet(
|
||||
// Said here because the tree deliberately allows the opposite of what
|
||||
// people assume: a folder's level does not constrain what goes in it.
|
||||
Text(
|
||||
"A folder's level doesn't limit what you put inside it, each file " +
|
||||
"keeps its own.",
|
||||
stringResource(R.string.main_files_folder_level_note),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||
KitSecondaryButton(
|
||||
text = "Cancel",
|
||||
text = stringResource(R.string.common_cancel),
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "files-folder-cancel",
|
||||
)
|
||||
KitPrimaryButton(
|
||||
text = "Create",
|
||||
text = stringResource(R.string.main_files_create),
|
||||
onClick = { onCreate(name, level, areas) },
|
||||
enabled = name.isNotBlank(),
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -572,12 +569,11 @@ private fun ColumnScope.AreaPicker(
|
||||
var adding by remember { mutableStateOf("") }
|
||||
var renaming by remember { mutableStateOf<Pair<Int, String>?>(null) }
|
||||
|
||||
Text("Areas", fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
Text(stringResource(R.string.main_files_areas), fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
if (areas.isEmpty()) {
|
||||
Text(
|
||||
"Nothing here is named for you. Add the words your organisation " +
|
||||
"already uses.",
|
||||
stringResource(R.string.main_files_areas_empty),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.testTag("files-areas-empty"),
|
||||
@@ -607,7 +603,7 @@ private fun ColumnScope.AreaPicker(
|
||||
OutlinedTextField(
|
||||
value = adding,
|
||||
onValueChange = { adding = it },
|
||||
label = { Text("Add an area") },
|
||||
label = { Text(stringResource(R.string.main_files_add_area)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f).testTag("files-area-new"),
|
||||
)
|
||||
@@ -619,13 +615,12 @@ private fun ColumnScope.AreaPicker(
|
||||
areas = LocalAreas.all(context)
|
||||
},
|
||||
modifier = Modifier.testTag("files-area-add"),
|
||||
) { Text("Add") }
|
||||
) { Text(stringResource(R.string.common_add)) }
|
||||
}
|
||||
if (areas.isNotEmpty()) {
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
Text(
|
||||
"Long-press an area to rename or forget it. A file carries the area " +
|
||||
"itself; the name is this phone's.",
|
||||
stringResource(R.string.main_files_areas_hint),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -634,7 +629,7 @@ private fun ColumnScope.AreaPicker(
|
||||
var text by remember(bit) { mutableStateOf(current) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { renaming = null },
|
||||
title = { Text("Rename area") },
|
||||
title = { Text(stringResource(R.string.main_files_rename_area)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
@@ -648,14 +643,14 @@ private fun ColumnScope.AreaPicker(
|
||||
LocalAreas.rename(context, bit, text)
|
||||
renaming = null
|
||||
areas = LocalAreas.all(context)
|
||||
}) { Text("Rename") }
|
||||
}) { Text(stringResource(R.string.common_rename)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
LocalAreas.forget(context, bit)
|
||||
renaming = null
|
||||
areas = LocalAreas.all(context)
|
||||
}) { Text("Forget") }
|
||||
}) { Text(stringResource(R.string.main_files_forget)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -666,7 +661,7 @@ private fun ColumnScope.LevelPicker(
|
||||
selected: Short,
|
||||
onPick: (Short) -> Unit,
|
||||
) {
|
||||
Text("Classification", fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
Text(stringResource(R.string.main_files_classification), fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
@@ -687,10 +682,7 @@ private fun ColumnScope.LevelPicker(
|
||||
// The last sentence is the honest boundary: neither app stops somebody
|
||||
// filing sensitive material somewhere low. See android#15.
|
||||
Text(
|
||||
"Who can open this. A folder's level doesn't limit what you put inside " +
|
||||
"it, each file keeps its own. It is sealed with the label bound in, " +
|
||||
"so this is chosen now rather than changed later. It decides who can " +
|
||||
"open a file, not where the file can be put.",
|
||||
stringResource(R.string.main_files_level_note),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.testTag("files-level-note"),
|
||||
@@ -707,29 +699,35 @@ fun ColumnScope.AttachPanel(
|
||||
model: MessengerViewModel,
|
||||
onPhoto: () -> Unit,
|
||||
onFiles: () -> Unit,
|
||||
/** A greeting card (§6.31). */
|
||||
onCard: () -> Unit,
|
||||
/** An event the chat can answer (§6.32). */
|
||||
onEvent: () -> Unit,
|
||||
/** A game (§6.33); null where none can be played, which is anywhere but a one-to-one chat. */
|
||||
onGame: (() -> Unit)? = null,
|
||||
) {
|
||||
Column(
|
||||
// One row, because they sit together, scrolling sideways when the screen
|
||||
// is narrow. Locked Files still opens its sheet, which says how to unlock it.
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.SurfaceAlt)
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(Space.md)
|
||||
.testTag("attach-panel"),
|
||||
verticalArrangement = Arrangement.spacedBy(Space.sm),
|
||||
horizontalArrangement = Arrangement.spacedBy(Space.xs),
|
||||
) {
|
||||
KitSecondaryButton(text = stringResource(R.string.main_files_photo), onClick = onPhoto, tag = "attach-photo")
|
||||
KitSecondaryButton(
|
||||
text = "Photo",
|
||||
onClick = onPhoto,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "attach-photo",
|
||||
)
|
||||
KitSecondaryButton(
|
||||
text = if (model.filesUnlocked()) "From Files" else "From Files (locked)",
|
||||
text = if (model.filesUnlocked()) stringResource(R.string.main_files_from_files) else stringResource(R.string.main_files_from_files_locked),
|
||||
onClick = onFiles,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "attach-files",
|
||||
)
|
||||
KitSecondaryButton(text = stringResource(R.string.main_files_card), onClick = onCard, tag = "attach-card")
|
||||
KitSecondaryButton(text = stringResource(R.string.main_files_event), onClick = onEvent, tag = "attach-event")
|
||||
onGame?.let { KitSecondaryButton(text = stringResource(R.string.main_files_game), onClick = it, tag = "attach-game") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,32 +755,27 @@ fun ColumnScope.PickFileToSendSheet(
|
||||
.padding(Space.md)
|
||||
.testTag("pick-file-sheet"),
|
||||
) {
|
||||
Text("Send a file", fontWeight = FontWeight.Medium, fontSize = 16.sp)
|
||||
Text(stringResource(R.string.main_files_send_a_file), fontWeight = FontWeight.Medium, fontSize = 16.sp)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
|
||||
if (!unlocked) {
|
||||
Text(
|
||||
"Files is locked. Open Files from your account and unlock it, " +
|
||||
"then come back, your passphrase isn't asked for here.",
|
||||
stringResource(R.string.main_files_locked_note),
|
||||
fontSize = 13.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
} else if (entries.isEmpty()) {
|
||||
Text("Nothing in your files yet.", fontSize = 13.sp, color = Palette.SecondaryText)
|
||||
Text(stringResource(R.string.main_files_none_yet), fontSize = 13.sp, color = Palette.SecondaryText)
|
||||
} else {
|
||||
Text(
|
||||
"Whoever you send this to will be able to open it. The " +
|
||||
"classification travels with the file so it's filed the " +
|
||||
"same way on their phone, it doesn't stop them reading it.",
|
||||
stringResource(R.string.main_files_send_note),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
// The photo button strips location; this one does not (android#17).
|
||||
Text(
|
||||
"A file is sent exactly as it is, including anything inside " +
|
||||
"it, like where a photo was taken. The camera button " +
|
||||
"leaves that out; this doesn't.",
|
||||
stringResource(R.string.main_files_send_metadata_note),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -799,7 +792,7 @@ fun ColumnScope.PickFileToSendSheet(
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
entry.name ?: "Unnamed",
|
||||
entry.name ?: stringResource(R.string.main_files_unnamed),
|
||||
fontSize = 15.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -815,7 +808,7 @@ fun ColumnScope.PickFileToSendSheet(
|
||||
}
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
KitSecondaryButton(
|
||||
text = "Cancel",
|
||||
text = stringResource(R.string.common_cancel),
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "pick-file-cancel",
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shadow
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.games.Board
|
||||
import io.flamenet.games.GameType
|
||||
import io.flamenet.games.Piece
|
||||
import io.flamenet.games.PieceKind
|
||||
import io.flamenet.games.Side
|
||||
|
||||
/** Square colours: plain, and the same in both themes, as a board is. */
|
||||
private object BoardColors {
|
||||
val light = Color(0xFFDCE7F2)
|
||||
val dark = Color(0xFF5E7A96)
|
||||
val reversi = Color(0xFF137A4B)
|
||||
val reversiLine = Color(0xFF0B5233)
|
||||
val grid = Color(0xFF5E7A96)
|
||||
val selected = Color(0xFFFFCC00)
|
||||
val target = Color(0xCCFFCC00)
|
||||
val firstPiece = Color(0xFFFFFFFF)
|
||||
val secondPiece = Color(0xFF12212F)
|
||||
val darkMan = Color(0xFF7A1F2B)
|
||||
val lightMan = Color(0xFFF5E6C8)
|
||||
val crown = Color(0xFFFFCC00)
|
||||
}
|
||||
|
||||
/**
|
||||
* A game in a one-to-one chat (§6.33): the board, whose move it is, and what
|
||||
* either side may do now. The board is turned round for the second player,
|
||||
* except tic-tac-toe's.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun GameBubble(
|
||||
state: ChatGames.State,
|
||||
/** Who the game is with. */
|
||||
peerName: String,
|
||||
onMove: (String) -> Unit,
|
||||
onEnd: (String) -> Unit,
|
||||
onNewGame: () -> Unit,
|
||||
onLongPress: () -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(Kit.bubbleRadius)
|
||||
var confirmResign by remember(state.id) { mutableStateOf(false) }
|
||||
var showMoves by remember(state.id) { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(0.92f)
|
||||
.clip(shape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.border(Kit.hairline, Portal.rule(), shape)
|
||||
.combinedClickable(onClick = {}, onLongClick = onLongPress)
|
||||
.padding(Kit.bubblePadding)
|
||||
.testTag("game-${state.id}"),
|
||||
verticalArrangement = Arrangement.spacedBy(Space.xs),
|
||||
) {
|
||||
Text(
|
||||
"${ChatGames.name(state.type)} with $peerName",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
ChatGames.status(state),
|
||||
fontSize = 14.sp,
|
||||
color = if (state.outOfStep) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.testTag("game-status"),
|
||||
)
|
||||
Text(
|
||||
"You play ${ChatGames.sideName(state.type, state.me)}",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val moves = state.myMoves
|
||||
val onlyPass = moves == listOf("pass")
|
||||
GameBoard(state, enabled = moves.isNotEmpty() && !onlyPass, onMove = onMove)
|
||||
if (onlyPass) {
|
||||
// §6.33: when a reversi side has no square, Pass is the only move offered.
|
||||
KitPrimaryButton(
|
||||
text = "Pass",
|
||||
onClick = { onMove("pass") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "game-pass",
|
||||
)
|
||||
}
|
||||
GameControls(state, onEnd = onEnd, onResign = { confirmResign = true })
|
||||
if (state.outOfStep) {
|
||||
KitPrimaryButton(
|
||||
text = "Start a new game",
|
||||
onClick = onNewGame,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "game-new",
|
||||
)
|
||||
}
|
||||
if (state.log.isNotEmpty()) {
|
||||
TextButton(onClick = { showMoves = !showMoves }, modifier = Modifier.testTag("game-moves-toggle")) {
|
||||
Text(if (showMoves) "Hide moves" else "Moves (${state.log.size})", fontSize = 12.sp)
|
||||
}
|
||||
if (showMoves) {
|
||||
Text(
|
||||
moveList(state.log),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.testTag("game-moves"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (confirmResign) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmResign = false },
|
||||
title = { Text("Resign this game?") },
|
||||
text = { Text("They win, and the game ends for both of you.", fontSize = 14.sp) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
confirmResign = false
|
||||
onEnd(ChatGames.RESIGN)
|
||||
},
|
||||
modifier = Modifier.testTag("game-resign-confirm"),
|
||||
) { Text("Resign", color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { confirmResign = false }) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "1. e2e4 e7e5", one line per turn. */
|
||||
internal fun moveList(log: List<String>): String =
|
||||
log.chunked(2).mapIndexed { i, pair -> "${i + 1}. ${pair.joinToString(" ")}" }.joinToString("\n")
|
||||
|
||||
/** What either side may send now, in one row. */
|
||||
@Composable
|
||||
private fun GameControls(
|
||||
state: ChatGames.State,
|
||||
onEnd: (String) -> Unit,
|
||||
onResign: () -> Unit,
|
||||
) {
|
||||
val buttons = mutableListOf<Triple<String, String, () -> Unit>>()
|
||||
if (ChatGames.canEnd(state, ChatGames.DRAW_ACCEPT)) {
|
||||
buttons += Triple("Accept draw", "game-draw-accept") { onEnd(ChatGames.DRAW_ACCEPT) }
|
||||
buttons += Triple("Decline draw", "game-draw-decline") { onEnd(ChatGames.DRAW_DECLINE) }
|
||||
} else {
|
||||
if (ChatGames.canEnd(state, ChatGames.DECLINE)) buttons += Triple("Decline", "game-decline") { onEnd(ChatGames.DECLINE) }
|
||||
if (ChatGames.canEnd(state, ChatGames.CANCEL)) buttons += Triple("Cancel game", "game-cancel") { onEnd(ChatGames.CANCEL) }
|
||||
if (ChatGames.canEnd(state, ChatGames.RESIGN)) buttons += Triple("Resign", "game-resign", onResign)
|
||||
if (ChatGames.canEnd(state, ChatGames.DRAW_OFFER)) {
|
||||
buttons += Triple("Offer a draw", "game-draw-offer") { onEnd(ChatGames.DRAW_OFFER) }
|
||||
}
|
||||
}
|
||||
if (buttons.isEmpty()) return
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs), modifier = Modifier.fillMaxWidth()) {
|
||||
buttons.forEach { (label, tag, action) ->
|
||||
KitSecondaryButton(text = label, onClick = action, modifier = Modifier.weight(1f), tag = tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The board, from the engine's `board` with row 0 at the top as the first
|
||||
* player sees it, turned round for the second except in tic-tac-toe. Tap a
|
||||
* piece, then one of the squares it can reach; a checkers jump is entered
|
||||
* square by square.
|
||||
*/
|
||||
@Composable
|
||||
fun GameBoard(
|
||||
state: ChatGames.State,
|
||||
enabled: Boolean,
|
||||
onMove: (String) -> Unit,
|
||||
) {
|
||||
val position = state.position
|
||||
if (position == null) {
|
||||
Text("These moves do not add up.", fontSize = 13.sp, color = MaterialTheme.colorScheme.error)
|
||||
return
|
||||
}
|
||||
val board = position.board
|
||||
val moves = if (enabled) state.myMoves else emptyList()
|
||||
var path by remember(state.id, state.log.size) { mutableStateOf<List<String>>(emptyList()) }
|
||||
var promotions by remember(state.id, state.log.size) { mutableStateOf<List<String>>(emptyList()) }
|
||||
val selection = MoveSelection(state.type, moves)
|
||||
val targets = selection.targets(path)
|
||||
val flipped = ChatGames.turnedRound(state)
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth().testTag("game-board")) {
|
||||
val side: Dp = minOf(maxWidth, 320.dp)
|
||||
val cell = side / board.columns
|
||||
Column(modifier = Modifier.width(side).border(Kit.hairline, BoardColors.grid)) {
|
||||
for (shownRow in 0 until board.rows) {
|
||||
Row {
|
||||
for (shownColumn in 0 until board.columns) {
|
||||
val row = if (flipped) board.rows - 1 - shownRow else shownRow
|
||||
val column = if (flipped) board.columns - 1 - shownColumn else shownColumn
|
||||
val name = board.squareAt(row, column)
|
||||
val piece = board.pieceAt(row, column)
|
||||
BoardSquare(
|
||||
type = state.type,
|
||||
board = board,
|
||||
row = row,
|
||||
column = column,
|
||||
name = name,
|
||||
piece = piece,
|
||||
size = cell,
|
||||
// The whole path so far, so a jump shows where it started.
|
||||
selected = name != null && name in path,
|
||||
target = name != null && name in targets,
|
||||
onTap = {
|
||||
if (name == null || moves.isEmpty()) return@BoardSquare
|
||||
when (val next = selection.tap(path, name)) {
|
||||
is MoveSelection.Next.Play -> {
|
||||
path = emptyList()
|
||||
onMove(next.move)
|
||||
}
|
||||
|
||||
is MoveSelection.Next.Choose -> {
|
||||
promotions = next.moves
|
||||
}
|
||||
|
||||
is MoveSelection.Next.Select -> {
|
||||
path = next.path
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (promotions.isNotEmpty()) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { promotions = emptyList() },
|
||||
title = { Text("Promote to") },
|
||||
text = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs)) {
|
||||
promotions.forEach { move ->
|
||||
val name = promotionName(move.last())
|
||||
KitSecondaryButton(
|
||||
text = name,
|
||||
onClick = {
|
||||
promotions = emptyList()
|
||||
path = emptyList()
|
||||
onMove(move)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "promote-${move.last()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = { TextButton(onClick = { promotions = emptyList() }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun promotionName(letter: Char): String =
|
||||
when (letter) {
|
||||
'q' -> "Queen"
|
||||
'r' -> "Rook"
|
||||
'b' -> "Bishop"
|
||||
'n' -> "Knight"
|
||||
else -> letter.toString()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoardSquare(
|
||||
type: GameType,
|
||||
board: Board,
|
||||
row: Int,
|
||||
column: Int,
|
||||
name: String?,
|
||||
piece: Piece?,
|
||||
size: Dp,
|
||||
selected: Boolean,
|
||||
target: Boolean,
|
||||
onTap: () -> Unit,
|
||||
) {
|
||||
val ground =
|
||||
when (type) {
|
||||
GameType.REVERSI -> BoardColors.reversi
|
||||
GameType.TICTACTOE -> MaterialTheme.colorScheme.surface
|
||||
else -> if ((row + column) % 2 == 0) BoardColors.light else BoardColors.dark
|
||||
}
|
||||
val label =
|
||||
buildString {
|
||||
append(name ?: "")
|
||||
append(", ")
|
||||
append(piece?.let { describe(type, it) } ?: "empty")
|
||||
if (selected) append(", selected")
|
||||
if (target) append(", can move here")
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(size)
|
||||
.background(ground)
|
||||
.then(
|
||||
when {
|
||||
selected -> Modifier.border(3.dp, BoardColors.selected)
|
||||
type == GameType.REVERSI -> Modifier.border(0.5.dp, BoardColors.reversiLine)
|
||||
type == GameType.TICTACTOE -> Modifier.border(1.dp, BoardColors.grid)
|
||||
else -> Modifier
|
||||
},
|
||||
).then(
|
||||
if (name != null) {
|
||||
Modifier
|
||||
.clickable(onClick = onTap)
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
this.selected = selected
|
||||
}.testTag("square-$name")
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (piece != null) PieceMark(type, piece, size)
|
||||
if (target) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(size / 4)
|
||||
.clip(CircleShape)
|
||||
.background(BoardColors.target),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PieceMark(
|
||||
type: GameType,
|
||||
piece: Piece,
|
||||
size: Dp,
|
||||
) {
|
||||
when (type) {
|
||||
GameType.CHESS -> {
|
||||
val fill = if (piece.side == Side.FIRST) BoardColors.firstPiece else BoardColors.secondPiece
|
||||
val edge = if (piece.side == Side.FIRST) BoardColors.secondPiece else BoardColors.firstPiece
|
||||
Text(
|
||||
chessGlyph(piece.kind),
|
||||
fontSize = (size.value * 0.62f).sp,
|
||||
color = fill,
|
||||
style = TextStyle(shadow = Shadow(color = edge, offset = Offset(0f, 0f), blurRadius = 3f)),
|
||||
)
|
||||
}
|
||||
|
||||
GameType.CHECKERS -> {
|
||||
val fill = if (piece.side == Side.FIRST) BoardColors.darkMan else BoardColors.lightMan
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(size * 0.72f)
|
||||
.clip(CircleShape)
|
||||
.background(fill)
|
||||
.border(1.dp, BoardColors.secondPiece, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (piece.kind == PieceKind.KING) {
|
||||
Box(Modifier.size(size * 0.34f).clip(CircleShape).border(3.dp, BoardColors.crown, CircleShape))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GameType.REVERSI -> {
|
||||
val fill = if (piece.side == Side.FIRST) BoardColors.secondPiece else BoardColors.firstPiece
|
||||
Box(
|
||||
Modifier
|
||||
.size(size * 0.78f)
|
||||
.clip(CircleShape)
|
||||
.background(fill)
|
||||
.border(1.dp, BoardColors.reversiLine, CircleShape),
|
||||
)
|
||||
}
|
||||
|
||||
GameType.TICTACTOE -> {
|
||||
// The cells take the card's own ground, so the marks follow the theme.
|
||||
val dark = LocalPortalDark.current
|
||||
Text(
|
||||
if (piece.side == Side.FIRST) "X" else "O",
|
||||
fontSize = (size.value * 0.55f).sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color =
|
||||
when {
|
||||
piece.side == Side.FIRST && dark -> Palette.NightLink
|
||||
piece.side == Side.FIRST -> Palette.Navy
|
||||
dark -> Palette.NightDanger
|
||||
else -> Palette.Channel
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The solid chess glyphs, asked for as text so no platform draws them as emoji. */
|
||||
private fun chessGlyph(kind: PieceKind): String =
|
||||
when (kind) {
|
||||
PieceKind.KING -> "\u265A"
|
||||
PieceKind.QUEEN -> "\u265B"
|
||||
PieceKind.ROOK -> "\u265C"
|
||||
PieceKind.BISHOP -> "\u265D"
|
||||
PieceKind.KNIGHT -> "\u265E"
|
||||
else -> "\u265F"
|
||||
} + "\uFE0E"
|
||||
|
||||
/** A piece in words, for TalkBack: "white knight", "dark king", "black disc", "X". */
|
||||
internal fun describe(
|
||||
type: GameType,
|
||||
piece: Piece,
|
||||
): String {
|
||||
val side = ChatGames.sideName(type, piece.side)
|
||||
return when (piece.kind) {
|
||||
PieceKind.DISC -> "$side disc"
|
||||
PieceKind.MARK -> side
|
||||
else -> "$side ${piece.kind.name.lowercase()}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which squares a tap can lead to, worked out from the legal moves alone, so
|
||||
* whatever is sent is exactly a string the engine offered.
|
||||
*/
|
||||
internal class MoveSelection(
|
||||
private val type: GameType,
|
||||
private val moves: List<String>,
|
||||
) {
|
||||
sealed interface Next {
|
||||
/** A whole move: send it. */
|
||||
data class Play(
|
||||
val move: String,
|
||||
) : Next
|
||||
|
||||
/** Several moves end on that square: a chess promotion. */
|
||||
data class Choose(
|
||||
val moves: List<String>,
|
||||
) : Next
|
||||
|
||||
/** Keep going: this is the path so far, maybe empty again. */
|
||||
data class Select(
|
||||
val path: List<String>,
|
||||
) : Next
|
||||
}
|
||||
|
||||
/** The squares each move passes through, in order. */
|
||||
private fun squares(move: String): List<String> =
|
||||
when (type) {
|
||||
GameType.CHESS -> listOf(move.substring(0, 2), move.substring(2, 4))
|
||||
GameType.CHECKERS -> move.split('-', 'x')
|
||||
else -> listOf(move)
|
||||
}
|
||||
|
||||
private val paths = moves.filter { it != "pass" }.map { it to squares(it) }
|
||||
|
||||
/** Where the piece on [path] can go next. With nothing chosen, nothing is marked. */
|
||||
fun targets(path: List<String>): Set<String> {
|
||||
if (type == GameType.REVERSI || type == GameType.TICTACTOE) return paths.map { it.second[0] }.toSet()
|
||||
if (path.isEmpty()) return emptySet()
|
||||
return paths.filter { it.second.size > path.size && it.second.subList(0, path.size) == path }.map { it.second[path.size] }.toSet()
|
||||
}
|
||||
|
||||
fun tap(
|
||||
path: List<String>,
|
||||
square: String,
|
||||
): Next {
|
||||
if (type == GameType.REVERSI || type == GameType.TICTACTOE) {
|
||||
return if (paths.any { it.second[0] == square }) Next.Play(square) else Next.Select(emptyList())
|
||||
}
|
||||
if (path.isNotEmpty() && path.last() == square) return Next.Select(emptyList())
|
||||
val next = path + square
|
||||
val through = paths.filter { it.second.size >= next.size && it.second.subList(0, next.size) == next }
|
||||
if (through.isEmpty()) {
|
||||
// Not a step from here: maybe another of their own pieces instead.
|
||||
return if (paths.any { it.second[0] == square }) Next.Select(listOf(square)) else Next.Select(emptyList())
|
||||
}
|
||||
val complete = through.filter { it.second.size == next.size }.map { it.first }
|
||||
return when {
|
||||
complete.size == 1 && through.size == 1 -> Next.Play(complete.single())
|
||||
complete.size > 1 -> Next.Choose(complete)
|
||||
complete.size == 1 -> Next.Play(complete.single())
|
||||
else -> Next.Select(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Start a game: which one, and who moves first. */
|
||||
@Composable
|
||||
fun NewGameDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onStart: (GameType, Boolean) -> Unit,
|
||||
) {
|
||||
var type by remember { mutableStateOf(GameType.CHESS) }
|
||||
var iGoFirst by remember { mutableStateOf(true) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Play a game") },
|
||||
text = {
|
||||
Column(modifier = Modifier.testTag("new-game"), verticalArrangement = Arrangement.spacedBy(Space.xs)) {
|
||||
GameType.entries.forEach { option ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { type = option }
|
||||
.testTag("new-game-${option.wire}"),
|
||||
) {
|
||||
RadioButton(selected = type == option, onClick = { type = option })
|
||||
Text(ChatGames.name(option), fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs), modifier = Modifier.padding(top = Space.sm)) {
|
||||
KitChip(
|
||||
text = "I go first",
|
||||
selected = iGoFirst,
|
||||
onClick = { iGoFirst = true },
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "new-game-first",
|
||||
)
|
||||
KitChip(
|
||||
text = "You go first",
|
||||
selected = !iGoFirst,
|
||||
onClick = { iGoFirst = false },
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "new-game-second",
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Moves travel inside your messages, like everything else here. Their first " +
|
||||
"move accepts the game; they can decline it instead.",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onStart(type, iGoFirst) }, modifier = Modifier.testTag("new-game-start")) { Text("Start") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Hello links (SPEC §6.34): `https://flamenetmessenger.com/hi#<address>`.
|
||||
*
|
||||
* The address is in the fragment, which a browser keeps to itself, so the web
|
||||
* page the link opens never receives it. The app reads the same address from
|
||||
* `flamenetmessenger://hi#<address>`, from a scanned code, or as a bare
|
||||
* address, and only ever pre-fills the add screen with it.
|
||||
*
|
||||
* The rule is the one both apps and the relay's page share (DESIGN 21 and
|
||||
* 35): the part before the `#` is one of the known forms exactly, the
|
||||
* fragment is percent-decoded once, and what is left must be an address and
|
||||
* nothing else.
|
||||
*/
|
||||
object HelloLink {
|
||||
/** What the address card shares and its QR code holds. */
|
||||
const val WEB_PREFIX = "https://flamenetmessenger.com/hi#"
|
||||
|
||||
/** The app's own scheme, which the relay's /hi page links to. */
|
||||
const val SCHEME = "flamenetmessenger"
|
||||
|
||||
/** `fnid:` and the 52 characters of an account key in the FNID alphabet. */
|
||||
private val FNID = Regex("fnid:[0-9a-hjkmnp-tv-z]{52}")
|
||||
|
||||
/**
|
||||
* ASCII letters, digits, hyphens and dots, checked before lowercasing,
|
||||
* which in Java turns a Kelvin sign into an ASCII k (DESIGN 41). The dot
|
||||
* is checked separately.
|
||||
*/
|
||||
private val HOST = Regex("[A-Za-z0-9.-]+")
|
||||
|
||||
/** The longest fragment read, as the relay's /hi page caps it (DESIGN 41). */
|
||||
private const val MAX_FRAGMENT = 400
|
||||
|
||||
/** The paths of the page, in English and in Spanish, in exactly this case. */
|
||||
private val PAGE_PATHS = setOf("/hi", "/es/hi")
|
||||
|
||||
private const val CARD_PREFIX = "flamenet:"
|
||||
|
||||
/** The hello link for [address], or null when it is not an address. */
|
||||
fun forAddress(address: String): String? = canonical(address)?.let { WEB_PREFIX + it }
|
||||
|
||||
/**
|
||||
* [text] as an address, `fnid:<52>@<host>` with the host in lower case, or
|
||||
* null. Nothing around it is trimmed. The account part must already be
|
||||
* lower case: it is a key, and a changed character is a different person.
|
||||
*/
|
||||
fun canonical(text: String): String? {
|
||||
val at = text.indexOf('@')
|
||||
if (at <= 0 || at != text.lastIndexOf('@')) return null
|
||||
val fnid = text.substring(0, at)
|
||||
val host = text.substring(at + 1)
|
||||
if (!FNID.matches(fnid) || !HOST.matches(host) || '.' !in host) return null
|
||||
return "$fnid@${host.lowercase(Locale.ROOT)}"
|
||||
}
|
||||
|
||||
/**
|
||||
* The address in a hello link, web or app form, or null. A link with a
|
||||
* query, a port, a user name or another host is not a hello link.
|
||||
*/
|
||||
fun fromLink(link: String): String? {
|
||||
// Whitespace around a link is not part of it, as with a scanned code.
|
||||
val text = link.trim()
|
||||
val hash = text.indexOf('#')
|
||||
if (hash < 0 || !isHead(text.substring(0, hash))) return null
|
||||
val fragment = text.substring(hash + 1)
|
||||
if (fragment.length > MAX_FRAGMENT) return null
|
||||
return decodeOnce(fragment)?.let(::canonical)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [head], the part before the `#`, is `https://flamenetmessenger.com`
|
||||
* with the path `/hi` or `/es/hi`, or the app's own `flamenetmessenger://hi`
|
||||
* (DESIGN 35). The scheme and the host may be in any case; the path is
|
||||
* exact, so no trailing slash, and no query.
|
||||
*/
|
||||
private fun isHead(head: String): Boolean {
|
||||
val sep = head.indexOf("://")
|
||||
if (sep < 0) return false
|
||||
val scheme = head.substring(0, sep).lowercase(Locale.ROOT)
|
||||
val rest = head.substring(sep + 3)
|
||||
val slash = rest.indexOf('/')
|
||||
val host = (if (slash < 0) rest else rest.substring(0, slash)).lowercase(Locale.ROOT)
|
||||
val path = if (slash < 0) "" else rest.substring(slash)
|
||||
return when (scheme) {
|
||||
"https" -> host == "flamenetmessenger.com" && path in PAGE_PATHS
|
||||
SCHEME -> host == "hi" && path.isEmpty()
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a scanned code or a pasted line names: a hello link, a bare
|
||||
* address, or one written `flamenet:<address>`.
|
||||
*/
|
||||
fun address(text: String): String? {
|
||||
val t = text.trim()
|
||||
fromLink(t)?.let { return it }
|
||||
val bare = if (t.lowercase(Locale.ROOT).startsWith(CARD_PREFIX)) t.substring(CARD_PREFIX.length) else t
|
||||
return canonical(bare)
|
||||
}
|
||||
|
||||
/** Whether [scheme] is the app's own, so the link was meant for this app. */
|
||||
fun isAppScheme(scheme: String?): Boolean = scheme?.lowercase(Locale.ROOT) == SCHEME
|
||||
|
||||
/**
|
||||
* `%XX` escapes decoded once. Null for a broken escape, or for anything
|
||||
* outside ASCII, which no address contains.
|
||||
*/
|
||||
internal fun decodeOnce(s: String): String? {
|
||||
val out = StringBuilder(s.length)
|
||||
var i = 0
|
||||
while (i < s.length) {
|
||||
val c = s[i]
|
||||
if (c == '%') {
|
||||
if (i + 2 >= s.length) return null
|
||||
val hi = hex(s[i + 1])
|
||||
val lo = hex(s[i + 2])
|
||||
if (hi < 0 || lo < 0) return null
|
||||
val v = hi * 16 + lo
|
||||
if (v >= 0x80) return null
|
||||
out.append(v.toChar())
|
||||
i += 3
|
||||
} else {
|
||||
if (c.code >= 0x80) return null
|
||||
out.append(c)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
/** An ASCII hex digit's value, or -1. */
|
||||
private fun hex(c: Char): Int =
|
||||
when (c) {
|
||||
in '0'..'9' -> c - '0'
|
||||
in 'a'..'f' -> c - 'a' + 10
|
||||
in 'A'..'F' -> c - 'A' + 10
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,11 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.e2e.BackupPayload
|
||||
import io.flamenet.messenger.R
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
@@ -57,8 +59,8 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) }
|
||||
?: error("nothing to write to")
|
||||
}.fold(
|
||||
onSuccess = { "Backup saved. Keep the passphrase, it is the only key." },
|
||||
onFailure = { "Couldn't write the file: ${it.message}" },
|
||||
onSuccess = { context.getString(R.string.set_backup_saved) },
|
||||
onFailure = { context.getString(R.string.set_backup_write_failed, it.message) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -67,13 +69,9 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
ActivityResultContracts.OpenDocument(),
|
||||
) { uri -> if (uri != null) pendingImport = uri }
|
||||
|
||||
Text("History backup", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.set_backup_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Your history lives only on this phone, the server keeps none, so a " +
|
||||
"new phone starts empty. An export is sealed with a passphrase you " +
|
||||
"choose and opens on any phone signed into this account, Android " +
|
||||
"or iPhone. Lose the passphrase and the backup cannot be opened, " +
|
||||
"by anyone.",
|
||||
stringResource(R.string.set_backup_intro),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -83,7 +81,7 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
onClick = { showExportDialog = true },
|
||||
enabled = !working,
|
||||
modifier = Modifier.testTag("export-history"),
|
||||
) { Text("Export…") }
|
||||
) { Text(stringResource(R.string.set_backup_export)) }
|
||||
Spacer(Modifier.width(Space.sm))
|
||||
OutlinedButton(
|
||||
// SAF filters on MIME, and a custom extension has none, offer
|
||||
@@ -91,7 +89,7 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
onClick = { openLauncher.launch(arrayOf("*/*")) },
|
||||
enabled = !working,
|
||||
modifier = Modifier.testTag("import-history"),
|
||||
) { Text("Import…") }
|
||||
) { Text(stringResource(R.string.set_backup_import)) }
|
||||
}
|
||||
status?.let {
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
@@ -105,11 +103,8 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
|
||||
if (showExportDialog) {
|
||||
PassphraseDialog(
|
||||
title = "Export history",
|
||||
explainer =
|
||||
"This passphrase is the only key to the backup. It isn't " +
|
||||
"stored anywhere, a forgotten passphrase means the backup can " +
|
||||
"never be opened.",
|
||||
title = stringResource(R.string.set_backup_export_title),
|
||||
explainer = stringResource(R.string.set_backup_export_explainer),
|
||||
requireRepeat = true,
|
||||
busy = working,
|
||||
onDismiss = { if (!working) showExportDialog = false },
|
||||
@@ -120,7 +115,7 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
working = false
|
||||
showExportDialog = false
|
||||
if (bytes == null) {
|
||||
status = "Sign in first."
|
||||
status = context.getString(R.string.set_backup_sign_in_first)
|
||||
} else {
|
||||
pendingExport = bytes
|
||||
val stamp = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
|
||||
@@ -134,10 +129,8 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
|
||||
pendingImport?.let { uri ->
|
||||
PassphraseDialog(
|
||||
title = "Import history",
|
||||
explainer =
|
||||
"Anything already on this phone stays, the backup is " +
|
||||
"merged in, never a replacement.",
|
||||
title = stringResource(R.string.set_backup_import_title),
|
||||
explainer = stringResource(R.string.set_backup_import_explainer),
|
||||
requireRepeat = false,
|
||||
busy = working,
|
||||
onDismiss = { if (!working) pendingImport = null },
|
||||
@@ -151,7 +144,7 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
if (bytes == null) {
|
||||
working = false
|
||||
pendingImport = null
|
||||
status = "Couldn't read that file."
|
||||
status = context.getString(R.string.set_backup_read_failed)
|
||||
return@launch
|
||||
}
|
||||
val outcome = model.importHistory(bytes, phrase.toCharArray())
|
||||
@@ -161,14 +154,24 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
outcome.fold(
|
||||
onSuccess = { counts ->
|
||||
if (counts.messages + counts.stories == 0) {
|
||||
"Backup opened, everything in it was already here."
|
||||
context.getString(R.string.set_backup_nothing_new)
|
||||
} else {
|
||||
"Imported ${counts.messages} message${if (counts.messages == 1) "" else "s"}" +
|
||||
" and ${counts.stories} " +
|
||||
if (counts.stories == 1) "story." else "stories."
|
||||
context.getString(
|
||||
R.string.set_backup_imported,
|
||||
context.resources.getQuantityString(
|
||||
R.plurals.set_backup_messages,
|
||||
counts.messages,
|
||||
counts.messages,
|
||||
),
|
||||
context.resources.getQuantityString(
|
||||
R.plurals.set_backup_stories,
|
||||
counts.stories,
|
||||
counts.stories,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { it.message ?: "That backup couldn't be opened." },
|
||||
onFailure = { it.message ?: context.getString(R.string.set_backup_open_failed) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -199,7 +202,7 @@ private fun PassphraseDialog(
|
||||
OutlinedTextField(
|
||||
value = phrase,
|
||||
onValueChange = { phrase = it },
|
||||
placeholder = { Text("Passphrase", fontSize = 14.sp) },
|
||||
placeholder = { Text(stringResource(R.string.set_backup_passphrase), fontSize = 14.sp) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth().testTag("backup-passphrase"),
|
||||
@@ -209,7 +212,7 @@ private fun PassphraseDialog(
|
||||
OutlinedTextField(
|
||||
value = repeat,
|
||||
onValueChange = { repeat = it },
|
||||
placeholder = { Text("Repeat passphrase", fontSize = 14.sp) },
|
||||
placeholder = { Text(stringResource(R.string.set_backup_repeat_passphrase), fontSize = 14.sp) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
isError = mismatch,
|
||||
@@ -217,7 +220,7 @@ private fun PassphraseDialog(
|
||||
)
|
||||
if (mismatch) {
|
||||
Text(
|
||||
"The two passphrases don't match yet.",
|
||||
stringResource(R.string.set_backup_mismatch),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
@@ -225,7 +228,7 @@ private fun PassphraseDialog(
|
||||
}
|
||||
if (phrase.isNotEmpty() && phrase.length < 6) {
|
||||
Text(
|
||||
"Use at least 6 characters.",
|
||||
stringResource(R.string.set_backup_min_length),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -237,10 +240,10 @@ private fun PassphraseDialog(
|
||||
onClick = { onConfirm(phrase) },
|
||||
enabled = ready && !busy,
|
||||
modifier = Modifier.testTag("backup-confirm"),
|
||||
) { Text(if (busy) "Working…" else "Continue") }
|
||||
) { Text(if (busy) stringResource(R.string.set_backup_working) else stringResource(R.string.set_backup_continue)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") }
|
||||
TextButton(onClick = onDismiss, enabled = !busy) { Text(stringResource(R.string.common_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ object KeystoreVault {
|
||||
/**
|
||||
* Records as files under `filesDir`, which is app-private storage.
|
||||
*/
|
||||
private class FileStorage(
|
||||
internal class FileStorage(
|
||||
private val root: File,
|
||||
) : Vault.Storage {
|
||||
init {
|
||||
@@ -87,9 +87,13 @@ object KeystoreVault {
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
// Write beside and rename, so a kill mid-write leaves the previous
|
||||
// record intact rather than a truncated one.
|
||||
// record intact rather than a truncated one. Each write stages
|
||||
// under its own name: two writes of one record (a draft saved per
|
||||
// keystroke) shared one `.tmp`, the first rename took it, and the
|
||||
// second threw on a worker thread, which killed the app. The last
|
||||
// rename wins, as the last write should.
|
||||
val target = fileFor(name)
|
||||
val staging = File(root, target.name + ".tmp")
|
||||
val staging = File(root, target.name + "." + java.util.UUID.randomUUID() + ".tmp")
|
||||
staging.writeBytes(bytes)
|
||||
if (!staging.renameTo(target)) {
|
||||
staging.delete()
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.Mic
|
||||
import androidx.compose.material.icons.outlined.MicOff
|
||||
import androidx.compose.material.icons.outlined.MoreHoriz
|
||||
import androidx.compose.material.icons.outlined.Newspaper
|
||||
import androidx.compose.material.icons.outlined.NotificationsNone
|
||||
import androidx.compose.material.icons.outlined.PersonAdd
|
||||
import androidx.compose.material.icons.outlined.PersonOutline
|
||||
@@ -76,6 +77,9 @@ object KitIcon {
|
||||
val Files: ImageVector = Icons.Outlined.FolderOpen
|
||||
val Wallet: ImageVector = Icons.Outlined.AccountBalanceWallet
|
||||
|
||||
/** The Today tab: headlines first, so a newspaper. */
|
||||
val Today: ImageVector = Icons.Outlined.Newspaper
|
||||
|
||||
// Composer.
|
||||
val Attach: ImageVector = Icons.Outlined.AddCircleOutline
|
||||
val Camera: ImageVector = Icons.Outlined.PhotoCamera
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
// What the long-press menu may do with a line, kept apart from the screen so
|
||||
// the rules can be checked without composing anything.
|
||||
|
||||
/**
|
||||
* The words Copy takes: a message's text, an open card's lines, an event's
|
||||
* details. A card still sealed here has none to give.
|
||||
*/
|
||||
internal fun copyableWords(
|
||||
line: Session.Line,
|
||||
nowMillis: Long = System.currentTimeMillis(),
|
||||
): String {
|
||||
val card = line.card
|
||||
val event = line.event?.event
|
||||
return when {
|
||||
// A game is a board, not words.
|
||||
line.game != null -> ""
|
||||
card != null ->
|
||||
if (card.isSealed(line.mine, line.at, nowMillis)) {
|
||||
""
|
||||
} else {
|
||||
listOfNotNull(card.shownTo, card.shownMsg.takeIf { it.isNotBlank() }, card.shownFrom).joinToString("\n")
|
||||
}
|
||||
event != null ->
|
||||
listOfNotNull(event.shownTitle, EventText.whenLine(event), event.shownPlace, event.shownNote).joinToString("\n")
|
||||
else -> line.text
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a report may carry: the same as Copy, so a sealed card's words are
|
||||
* never sent to whoever runs the server by somebody who has not read them.
|
||||
*/
|
||||
internal fun reportableWords(line: Session.Line): String = copyableWords(line).ifBlank { line.summary }
|
||||
|
||||
/**
|
||||
* §6.31 and §6.32: a card is not forwarded while it is still sealed here,
|
||||
* and a cancelled event is not forwarded at all. §6.33: a game never is.
|
||||
*/
|
||||
internal fun canForward(
|
||||
line: Session.Line,
|
||||
nowMillis: Long = System.currentTimeMillis(),
|
||||
): Boolean {
|
||||
val card = line.card
|
||||
val event = line.event
|
||||
return when {
|
||||
line.game != null -> false
|
||||
card != null -> !card.isSealed(line.mine, line.at, nowMillis)
|
||||
event != null -> !event.event.cancelled
|
||||
else -> line.text.isNotBlank()
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -106,6 +107,10 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
|
||||
|
||||
val lock = AppLock(this)
|
||||
|
||||
// Only on a fresh start: an activity recreated for a rotation is handed
|
||||
// the same launch intent again, and the link was already taken.
|
||||
if (savedInstanceState == null) openHelloLinkIfAsked(intent)
|
||||
|
||||
setContent {
|
||||
MessengerTheme {
|
||||
// `android:windowSoftInputMode="adjustResize"` stopped doing
|
||||
@@ -187,10 +192,32 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/** A contact link opened from outside the app (spec 8.12), once. */
|
||||
private fun openLinkIfAsked(intent: Intent?) {
|
||||
if (intent?.action != Intent.ACTION_VIEW) return
|
||||
val text = intent.dataString ?: return
|
||||
intent.data = null
|
||||
model.handleLinkIntent(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* A hello link (§6.34), `flamenetmessenger://hi#<address>`, opened from a
|
||||
* browser or another app. Anything can send one of these, so all it does is
|
||||
* fill in the add screen; the person decides whether to write.
|
||||
*/
|
||||
private fun openHelloLinkIfAsked(intent: Intent?) {
|
||||
val link = helloLinkIn(intent) ?: return
|
||||
// Taken once. `onStart` and a later `onNewIntent` see this intent again.
|
||||
intent?.data = null
|
||||
model.openHelloLink(link)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
answerIfAsked(intent)
|
||||
openHelloLinkIfAsked(intent)
|
||||
openLinkIfAsked(intent)
|
||||
}
|
||||
|
||||
// The handover: exactly one poller at a time.
|
||||
@@ -200,6 +227,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
|
||||
// Also on the way in: a cold start from the notification arrives here
|
||||
// rather than through onNewIntent.
|
||||
answerIfAsked(intent)
|
||||
openLinkIfAsked(intent)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
@@ -209,6 +237,17 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The link a VIEW intent for the app's own scheme carries, as text, or null
|
||||
* for any other intent. Whether it holds an address is the view model's call.
|
||||
*/
|
||||
internal fun helloLinkIn(intent: Intent?): String? {
|
||||
if (intent?.action != Intent.ACTION_VIEW) return null
|
||||
val data = intent.data ?: return null
|
||||
if (!HelloLink.isAppScheme(data.scheme)) return null
|
||||
return data.toString()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Root(model: MessengerViewModel = viewModel()) {
|
||||
val phase by model.phase.collectAsStateWithLifecycle()
|
||||
@@ -262,7 +301,7 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
||||
) {
|
||||
when (current) {
|
||||
is MessengerViewModel.Phase.Starting -> CircularProgressIndicator()
|
||||
is MessengerViewModel.Phase.SignedOut -> SignIn(model, current)
|
||||
is MessengerViewModel.Phase.SignedOut -> SignInScreen(model, current)
|
||||
is MessengerViewModel.Phase.VaultUnreadable -> VaultUnreadable(model, current)
|
||||
else -> Unit
|
||||
}
|
||||
@@ -308,6 +347,8 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
||||
myUserId = current.userId,
|
||||
groups = current.groups,
|
||||
heldMessages = current.heldMessages,
|
||||
unsealedSendAt = current.unsealedSendAt,
|
||||
myName = current.handle,
|
||||
)
|
||||
} else {
|
||||
// The content area is weighted, and the tab bar is not.
|
||||
@@ -330,6 +371,16 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
||||
MessagesScreen(model, current)
|
||||
}
|
||||
|
||||
// Hidden with the tab under supervision
|
||||
// (DESIGN 31), wherever the screen was left.
|
||||
MessengerViewModel.Phase.Screen.TODAY -> {
|
||||
if (ParentalControls.todayHidden(androidx.compose.ui.platform.LocalContext.current)) {
|
||||
SignedIn(model, current)
|
||||
} else {
|
||||
TodayScreen(model, current)
|
||||
}
|
||||
}
|
||||
|
||||
MessengerViewModel.Phase.Screen.CALLS -> {
|
||||
CallsScreen(model, current)
|
||||
}
|
||||
@@ -357,6 +408,10 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
||||
MessengerViewModel.Phase.Screen.ACCOUNT -> {
|
||||
AccountScreen(model, current)
|
||||
}
|
||||
|
||||
MessengerViewModel.Phase.Screen.SEARCH -> {
|
||||
WebSearchScreen(model, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
BottomNav(model, current)
|
||||
@@ -368,7 +423,7 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.SignIn(
|
||||
internal fun ColumnScope.SignInScreen(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedOut,
|
||||
) {
|
||||
@@ -380,6 +435,9 @@ private fun ColumnScope.SignIn(
|
||||
var passphrase by remember { mutableStateOf("") }
|
||||
var email by remember { mutableStateOf("") }
|
||||
var registering by remember { mutableStateOf(false) }
|
||||
// Spec 8.12, decision DB2: new accounts are Listed, and the choice is
|
||||
// offered here.
|
||||
var listed by remember { mutableStateOf(true) }
|
||||
var birthYear by remember { mutableStateOf("") }
|
||||
var ageError by remember { mutableStateOf<String?>(null) }
|
||||
// Only a relay keeping its own accounts needs an address for reset mail.
|
||||
@@ -409,11 +467,10 @@ private fun ColumnScope.SignIn(
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(Space.lg))
|
||||
Text("Hi there!", fontSize = 40.sp, fontWeight = FontWeight.Bold)
|
||||
Text(stringResource(R.string.main_welcome_title), fontSize = 40.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Text(
|
||||
"Flamenet Messenger. End-to-end encrypted, no phone number, and it " +
|
||||
"runs on whichever server you point it at.",
|
||||
stringResource(R.string.main_welcome_body),
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -424,7 +481,7 @@ private fun ColumnScope.SignIn(
|
||||
OutlinedTextField(
|
||||
value = relay,
|
||||
onValueChange = model::setRelay,
|
||||
label = { Text("Server") },
|
||||
label = { Text(stringResource(R.string.main_server)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
||||
modifier = Modifier.fillMaxWidth().testTag("relay"),
|
||||
@@ -433,7 +490,7 @@ private fun ColumnScope.SignIn(
|
||||
// Say which kind of server this is BEFORE the passphrase is typed.
|
||||
site?.let {
|
||||
Text(
|
||||
it.identityDescription,
|
||||
stringResource(it.identityDescriptionRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.testTag("identity-mode"),
|
||||
)
|
||||
@@ -442,7 +499,7 @@ private fun ColumnScope.SignIn(
|
||||
OutlinedTextField(
|
||||
value = handle,
|
||||
onValueChange = { handle = it },
|
||||
label = { Text("Handle") },
|
||||
label = { Text(stringResource(R.string.main_handle)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
modifier = Modifier.fillMaxWidth().testTag("handle"),
|
||||
@@ -454,14 +511,14 @@ private fun ColumnScope.SignIn(
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = { Text("Email") },
|
||||
label = { Text(stringResource(R.string.main_email)) },
|
||||
singleLine = true,
|
||||
keyboardOptions =
|
||||
KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
|
||||
modifier = Modifier.fillMaxWidth().testTag("email"),
|
||||
)
|
||||
Text(
|
||||
"Used only by this server, to send a password reset.",
|
||||
stringResource(R.string.main_email_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
@@ -469,7 +526,7 @@ private fun ColumnScope.SignIn(
|
||||
OutlinedTextField(
|
||||
value = passphrase,
|
||||
onValueChange = { passphrase = it },
|
||||
label = { Text("Passphrase") },
|
||||
label = { Text(stringResource(R.string.main_passphrase)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions =
|
||||
@@ -482,14 +539,14 @@ private fun ColumnScope.SignIn(
|
||||
OutlinedTextField(
|
||||
value = birthYear,
|
||||
onValueChange = { birthYear = it.filter(Char::isDigit).take(4) },
|
||||
label = { Text("Year of birth") },
|
||||
label = { Text(stringResource(R.string.main_birth_year)) },
|
||||
singleLine = true,
|
||||
keyboardOptions =
|
||||
KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
|
||||
modifier = Modifier.fillMaxWidth().testTag("birth-year"),
|
||||
)
|
||||
Text(
|
||||
"Checked on this phone to confirm you're old enough, then forgotten, never sent anywhere.",
|
||||
stringResource(R.string.main_birth_year_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
ageError?.let {
|
||||
@@ -499,12 +556,23 @@ private fun ColumnScope.SignIn(
|
||||
modifier = Modifier.testTag("age-error"),
|
||||
)
|
||||
}
|
||||
Text(stringResource(R.string.main_who_can_find_you), style = MaterialTheme.typography.titleSmall)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
androidx.compose.material3.RadioButton(selected = listed, onClick = { listed = true }, modifier = Modifier.testTag("signup-listed"))
|
||||
Text(stringResource(R.string.main_signup_listed), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
androidx.compose.material3.RadioButton(selected = !listed, onClick = { listed = false }, modifier = Modifier.testTag("signup-unlisted"))
|
||||
Text(stringResource(R.string.main_signup_unlisted), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
phase.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("error"))
|
||||
}
|
||||
|
||||
val enterYearText = stringResource(R.string.main_age_enter_year)
|
||||
val tooYoungText = stringResource(R.string.main_age_too_young)
|
||||
// 240 wide and centred, which is the kit's primary button on this screen,
|
||||
// not a full-width slab.
|
||||
Spacer(Modifier.height(Space.md))
|
||||
@@ -522,15 +590,15 @@ private fun ColumnScope.SignIn(
|
||||
if (year < 1900 || age < 14) {
|
||||
ageError =
|
||||
if (year < 1900) {
|
||||
"Enter your year of birth."
|
||||
enterYearText
|
||||
} else {
|
||||
"You must be at least 13 to use Flamenet Messenger."
|
||||
tooYoungText
|
||||
}
|
||||
return@Button
|
||||
}
|
||||
ageError = null
|
||||
}
|
||||
model.signIn(handle.trim(), passphrase, register = registering, email = email.trim())
|
||||
model.signIn(handle.trim(), passphrase, register = registering, email = email.trim(), listed = listed)
|
||||
},
|
||||
enabled =
|
||||
!phase.busy && handle.isNotBlank() && passphrase.isNotBlank() &&
|
||||
@@ -543,7 +611,7 @@ private fun ColumnScope.SignIn(
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text(
|
||||
if (registering) "Create account" else "Sign in",
|
||||
if (registering) stringResource(R.string.main_create_account) else stringResource(R.string.main_sign_in),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
@@ -561,10 +629,27 @@ private fun ColumnScope.SignIn(
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().testTag("toggle-register"),
|
||||
) {
|
||||
Text(if (registering) "I already have an account" else "Create an account")
|
||||
Text(if (registering) stringResource(R.string.main_have_account) else stringResource(R.string.main_create_an_account))
|
||||
}
|
||||
|
||||
if (!registering) ResetPassword(model, handle)
|
||||
|
||||
// Before any account exists, for a network that blocks Flamenet: without
|
||||
// a bridge here, the sign-up that needs one cannot happen (SPEC §24).
|
||||
var bridges by remember { mutableStateOf(false) }
|
||||
TextButton(
|
||||
onClick = { bridges = !bridges },
|
||||
modifier = Modifier.fillMaxWidth().testTag("signin-bridges"),
|
||||
) {
|
||||
Text(
|
||||
if (bridges) {
|
||||
stringResource(R.string.set_bridge_signin_hide)
|
||||
} else {
|
||||
stringResource(R.string.set_bridge_signin_show)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (bridges) NetworkSection()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -582,32 +667,31 @@ private fun ColumnScope.ResetPassword(
|
||||
TextButton(
|
||||
onClick = { open = !open },
|
||||
modifier = Modifier.fillMaxWidth().testTag("toggle-reset"),
|
||||
) { Text(if (open) "Back to signing in" else "Forgotten your password?") }
|
||||
) { Text(if (open) stringResource(R.string.main_back_to_sign_in) else stringResource(R.string.main_forgot_password)) }
|
||||
|
||||
if (!open) return
|
||||
|
||||
Text(
|
||||
"Enter your handle above, then send yourself a code. " +
|
||||
"This server can only do that if it has an address on file for you.",
|
||||
stringResource(R.string.main_reset_explainer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Button(
|
||||
onClick = { model.requestPasswordReset(handle.trim()) },
|
||||
enabled = handle.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth().testTag("send-reset-code"),
|
||||
) { Text("Send a code") }
|
||||
) { Text(stringResource(R.string.main_send_code)) }
|
||||
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { code = it },
|
||||
label = { Text("Code from the email") },
|
||||
label = { Text(stringResource(R.string.main_reset_code)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("reset-code"),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = fresh,
|
||||
onValueChange = { fresh = it },
|
||||
label = { Text("New password") },
|
||||
label = { Text(stringResource(R.string.main_new_password)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth().testTag("reset-password"),
|
||||
@@ -616,7 +700,7 @@ private fun ColumnScope.ResetPassword(
|
||||
onClick = { model.confirmPasswordReset(handle.trim(), code.trim(), fresh) },
|
||||
enabled = code.isNotBlank() && fresh.length >= 8,
|
||||
modifier = Modifier.fillMaxWidth().testTag("confirm-reset"),
|
||||
) { Text("Set the new password") }
|
||||
) { Text(stringResource(R.string.main_set_new_password)) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -627,20 +711,26 @@ object NavDestinations {
|
||||
val screen: MessengerViewModel.Phase.Screen,
|
||||
val label: String,
|
||||
val tag: String,
|
||||
@androidx.annotation.StringRes val labelRes: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* The bar, in order.
|
||||
*/
|
||||
fun tabs(storiesHidden: Boolean): List<Tab> =
|
||||
fun tabs(
|
||||
storiesHidden: Boolean,
|
||||
todayHidden: Boolean = false,
|
||||
): List<Tab> =
|
||||
listOfNotNull(
|
||||
Tab(Screen.CONTACTS, "Home", "nav-contacts"),
|
||||
Tab(Screen.MESSAGES, "Messages", "nav-messages"),
|
||||
Tab(Screen.CALLS, "Calls", "nav-calls"),
|
||||
Tab(Screen.STORIES, "Stories", "nav-stories").takeUnless { storiesHidden },
|
||||
Tab(Screen.FILES, "Files", "nav-files"),
|
||||
Tab(Screen.WALLET, "Wallet", "nav-wallet"),
|
||||
Tab(Screen.ACCOUNT, "Account", "nav-account"),
|
||||
Tab(Screen.CONTACTS, "Home", "nav-contacts", R.string.main_home),
|
||||
Tab(Screen.MESSAGES, "Messages", "nav-messages", R.string.main_messages),
|
||||
// Beside Messages: the public Today file and the directory (§8.12).
|
||||
Tab(Screen.TODAY, "Today", "nav-today", R.string.main_today).takeUnless { todayHidden },
|
||||
Tab(Screen.CALLS, "Calls", "nav-calls", R.string.main_calls),
|
||||
Tab(Screen.STORIES, "Stories", "nav-stories", R.string.main_stories).takeUnless { storiesHidden },
|
||||
Tab(Screen.FILES, "Files", "nav-files", R.string.main_files),
|
||||
Tab(Screen.WALLET, "Wallet", "nav-wallet", R.string.main_wallet),
|
||||
Tab(Screen.ACCOUNT, "Account", "nav-account", R.string.main_account),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -680,9 +770,13 @@ internal fun BottomNav(
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
// Supervision removes the Stories surface rather than emptying it.
|
||||
val storiesHidden =
|
||||
ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current)
|
||||
for (tab in NavDestinations.tabs(storiesHidden)) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val tabs =
|
||||
NavDestinations.tabs(
|
||||
storiesHidden = ParentalControls.storiesHidden(context),
|
||||
todayHidden = ParentalControls.todayHidden(context),
|
||||
)
|
||||
for (tab in tabs) {
|
||||
val badge =
|
||||
when (tab.screen) {
|
||||
// The inbox badge lives on Messages.
|
||||
@@ -700,13 +794,24 @@ internal fun BottomNav(
|
||||
when (tab.screen) {
|
||||
Screen.CONTACTS -> KitIcon.Home to KitIcon.Home
|
||||
Screen.MESSAGES -> KitIcon.Chats to KitIcon.ChatsActive
|
||||
Screen.TODAY -> KitIcon.Today to KitIcon.Today
|
||||
Screen.CALLS -> KitIcon.Call to KitIcon.Call
|
||||
Screen.STORIES -> KitIcon.Stories to KitIcon.Stories
|
||||
Screen.FILES -> KitIcon.Files to KitIcon.Files
|
||||
Screen.WALLET -> KitIcon.Wallet to KitIcon.Wallet
|
||||
else -> KitIcon.Account to KitIcon.Account
|
||||
}
|
||||
NavButton(model, phase, tab.screen, icon, active, tab.label, badge, tab.tag)
|
||||
NavButton(
|
||||
model,
|
||||
phase,
|
||||
tab.screen,
|
||||
icon,
|
||||
active,
|
||||
stringResource(tab.labelRes),
|
||||
badge,
|
||||
tab.tag,
|
||||
compact = tabs.size > 7,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -726,9 +831,15 @@ private fun RowScope.NavButton(
|
||||
label: String,
|
||||
badge: Int,
|
||||
tag: String,
|
||||
/**
|
||||
* Eight tabs leave about 45dp each on a 360dp phone, so the labels go one
|
||||
* size smaller and stay on one line.
|
||||
*/
|
||||
compact: Boolean = false,
|
||||
) {
|
||||
val selected = phase.screen == screen
|
||||
val face = if (selected) Color.White else Color(0xFFB4D2EE)
|
||||
val unreadLabel = stringResource(R.string.main_nav_unread, label, badge)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -739,7 +850,7 @@ private fun RowScope.NavButton(
|
||||
.clickable { model.navigateTo(screen) }
|
||||
.semantics {
|
||||
contentDescription =
|
||||
if (badge > 0) "$label, $badge unread" else label
|
||||
if (badge > 0) unreadLabel else label
|
||||
}.testTag(tag),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -768,6 +879,9 @@ private fun RowScope.NavButton(
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = face,
|
||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
|
||||
fontSize = if (compact) 10.sp else MaterialTheme.typography.labelSmall.fontSize,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
if (badge > 0) {
|
||||
@@ -815,6 +929,17 @@ internal fun ColumnScope.SignedIn(
|
||||
scanning = true
|
||||
}
|
||||
|
||||
// A hello link that was opened (§6.34) lands in the add screen and stops
|
||||
// there. Nothing is looked up or sent until the person chooses to.
|
||||
val hello by model.helloAddress.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(hello) {
|
||||
val address = hello ?: return@LaunchedEffect
|
||||
openProfile = null
|
||||
scannedPrefill = address
|
||||
addingContact = true
|
||||
model.helloAddressTaken()
|
||||
}
|
||||
|
||||
// The contact screen REPLACES the list rather than sitting under it, the
|
||||
// same way an open chat does.
|
||||
val profile = openProfile?.let { id -> phase.buddies.firstOrNull { it.userId == id } }
|
||||
@@ -825,13 +950,13 @@ internal fun ColumnScope.SignedIn(
|
||||
|
||||
// Home is two things now, on purpose: your people, and their stories.
|
||||
PortalHeader(
|
||||
title = "Home",
|
||||
title = stringResource(R.string.main_home),
|
||||
subtitle = phase.handle.ifBlank { null }?.let { "$it · ${phase.relay}" },
|
||||
) {
|
||||
// Scan their Address screen, iOS's home has the same button.
|
||||
PortalHeaderIcon(
|
||||
icon = KitIcon.Scan,
|
||||
contentDescription = "Scan an address",
|
||||
contentDescription = stringResource(R.string.main_scan_address),
|
||||
modifier = Modifier.testTag("scan-address"),
|
||||
onClick = {
|
||||
val has =
|
||||
@@ -848,7 +973,7 @@ internal fun ColumnScope.SignedIn(
|
||||
)
|
||||
PortalHeaderIcon(
|
||||
icon = if (addingContact) KitIcon.Close else KitIcon.Add,
|
||||
contentDescription = "Add contact",
|
||||
contentDescription = stringResource(R.string.main_add_contact),
|
||||
modifier = Modifier.testTag("add-contact"),
|
||||
onClick = { addingContact = !addingContact },
|
||||
)
|
||||
@@ -858,9 +983,12 @@ internal fun ColumnScope.SignedIn(
|
||||
ScanAddressDialog(
|
||||
onFound = { raw ->
|
||||
scanning = false
|
||||
parseScannedAddress(raw)?.let { card ->
|
||||
// Bind by the fnid, never the handle (audit LOW).
|
||||
scannedPrefill = "${'$'}{card.fnid}@${'$'}{card.relay}"
|
||||
// A hello link's code, an older card, or a bare address.
|
||||
val prefill = prefillFor(raw)
|
||||
if (prefill == null) {
|
||||
model.unreadableCode()
|
||||
} else {
|
||||
scannedPrefill = prefill
|
||||
addingContact = true
|
||||
}
|
||||
},
|
||||
@@ -887,14 +1015,14 @@ internal fun ColumnScope.SignedIn(
|
||||
}
|
||||
}
|
||||
|
||||
PortalBand("Your people")
|
||||
PortalBand(stringResource(R.string.main_your_people))
|
||||
if (phase.buddies.isEmpty()) {
|
||||
PortalBody {
|
||||
Text(
|
||||
if (phase.buddiesLoaded) {
|
||||
"Nobody here yet. Add someone by the username they signed up with."
|
||||
stringResource(R.string.main_no_contacts)
|
||||
} else {
|
||||
"Couldn't load your contacts. Check the connection and sign in again."
|
||||
stringResource(R.string.main_contacts_load_failed)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
@@ -904,7 +1032,7 @@ internal fun ColumnScope.SignedIn(
|
||||
}
|
||||
|
||||
if (!ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current)) {
|
||||
PortalBand("Stories")
|
||||
PortalBand(stringResource(R.string.main_stories))
|
||||
StoriesCards(model, phase)
|
||||
}
|
||||
}
|
||||
@@ -940,16 +1068,13 @@ private fun ColumnScope.VaultUnreadable(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.VaultUnreadable,
|
||||
) {
|
||||
Text("Stored keys unreadable", style = MaterialTheme.typography.headlineMedium)
|
||||
Text(stringResource(R.string.main_vault_unreadable_title), style = MaterialTheme.typography.headlineMedium)
|
||||
Text(
|
||||
"This phone has keys saved for Flamenet Messenger, but they cannot be opened. " +
|
||||
"That usually means the app was restored onto a different device, where the " +
|
||||
"key that protects them could not follow.",
|
||||
stringResource(R.string.main_vault_unreadable_body),
|
||||
)
|
||||
Text(phase.detail, style = MaterialTheme.typography.bodySmall)
|
||||
Text(
|
||||
"Starting fresh publishes a new device. Everyone you talk to will see " +
|
||||
"\"security keys changed\", so tell them to expect it.",
|
||||
stringResource(R.string.main_vault_fresh_note),
|
||||
)
|
||||
// Behind a confirmation, because the button is one tap from a first
|
||||
// screen and what it does cannot be undone: the stored keys go, and with
|
||||
@@ -963,23 +1088,20 @@ private fun ColumnScope.VaultUnreadable(
|
||||
onClick = { confirming = true },
|
||||
modifier = Modifier.testTag("start-fresh"),
|
||||
) {
|
||||
Text("Erase and start fresh")
|
||||
Text(stringResource(R.string.main_vault_erase_start_fresh))
|
||||
}
|
||||
if (confirming) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { confirming = false },
|
||||
title = { Text("Erase the stored keys?") },
|
||||
title = { Text(stringResource(R.string.main_vault_erase_title)) },
|
||||
text = {
|
||||
Text(
|
||||
"This deletes the keys on this phone and every message they " +
|
||||
"unlock. It cannot be undone. If these keys came from another " +
|
||||
"device, they can still be opened there -- erasing here does " +
|
||||
"not bring anything back.",
|
||||
stringResource(R.string.main_vault_erase_body),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
KitSecondaryButton(
|
||||
text = "Erase everything",
|
||||
text = stringResource(R.string.main_vault_erase_everything),
|
||||
onClick = {
|
||||
confirming = false
|
||||
model.discardUnreadableVault()
|
||||
@@ -990,7 +1112,7 @@ private fun ColumnScope.VaultUnreadable(
|
||||
},
|
||||
dismissButton = {
|
||||
KitChip(
|
||||
text = "Keep them",
|
||||
text = stringResource(R.string.main_vault_keep_them),
|
||||
selected = false,
|
||||
onClick = { confirming = false },
|
||||
tag = "start-fresh-cancel",
|
||||
@@ -1017,21 +1139,19 @@ private fun CallKeyReviewDialog(
|
||||
) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { model.dismissBlockedCallKeys() },
|
||||
title = { Text("Security keys changed") },
|
||||
title = { Text(stringResource(R.string.main_call_keys_title)) },
|
||||
text = {
|
||||
androidx.compose.foundation.layout.Column(
|
||||
verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(Space.sm),
|
||||
) {
|
||||
Text(
|
||||
"${review.peerName}'s keys are not the ones you accepted before, so the " +
|
||||
"call was not placed.",
|
||||
stringResource(R.string.main_call_keys_body, review.peerName),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
// Both readings, in the order that matters, as the chat banner
|
||||
// has them.
|
||||
Text(
|
||||
"That happens when someone reinstalls or adds a device, and it is also " +
|
||||
"what an intercepted conversation looks like.",
|
||||
stringResource(R.string.main_call_keys_why),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
review.safetyNumber?.let { number ->
|
||||
@@ -1044,14 +1164,12 @@ private fun CallKeyReviewDialog(
|
||||
}
|
||||
if (review.accepted) {
|
||||
Text(
|
||||
"New keys accepted. You can call ${review.peerName} again.",
|
||||
stringResource(R.string.main_call_keys_accepted, review.peerName),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"Accepting means this call, and everything you send after it, goes to " +
|
||||
"whoever holds the new keys. Read the number to them on a channel " +
|
||||
"this app does not control, and accept only if it matches.",
|
||||
stringResource(R.string.main_call_keys_warning),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
@@ -1060,13 +1178,13 @@ private fun CallKeyReviewDialog(
|
||||
confirmButton = {
|
||||
if (review.accepted) {
|
||||
KitSecondaryButton(
|
||||
text = "Done",
|
||||
text = stringResource(R.string.common_done),
|
||||
onClick = { model.dismissBlockedCallKeys() },
|
||||
tag = "call-keys-done",
|
||||
)
|
||||
} else {
|
||||
KitSecondaryButton(
|
||||
text = "Accept new keys",
|
||||
text = stringResource(R.string.main_call_keys_accept),
|
||||
onClick = { model.acceptBlockedCallKeys() },
|
||||
tag = "call-accept-new-keys",
|
||||
danger = true,
|
||||
@@ -1076,7 +1194,7 @@ private fun CallKeyReviewDialog(
|
||||
dismissButton = {
|
||||
if (!review.accepted) {
|
||||
KitChip(
|
||||
text = "Not now",
|
||||
text = stringResource(R.string.common_not_now),
|
||||
selected = false,
|
||||
onClick = { model.dismissBlockedCallKeys() },
|
||||
tag = "call-keys-not-now",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -28,16 +30,19 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.e2e.Vault
|
||||
import io.flamenet.messenger.R
|
||||
|
||||
/**
|
||||
* The kit's `07 Messages-Listing` as its own tab: large title, search box, the
|
||||
* Recent circles row, then a flat list of conversations.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ColumnScope.MessagesScreen(
|
||||
model: MessengerViewModel,
|
||||
@@ -62,10 +67,10 @@ fun ColumnScope.MessagesScreen(
|
||||
// Starting a group sits on the Groups heading and a chat on the Chats
|
||||
// heading; the bar keeps the two ways a NEW person gets in, scan their
|
||||
// code, or add them by name, the same pair the Home bar carries.
|
||||
PortalHeader(title = "Messages") {
|
||||
PortalHeader(title = stringResource(R.string.set_messages_title)) {
|
||||
PortalHeaderIcon(
|
||||
icon = KitIcon.Scan,
|
||||
contentDescription = "Scan an address",
|
||||
contentDescription = stringResource(R.string.set_messages_scan),
|
||||
modifier = Modifier.testTag("messages-scan"),
|
||||
onClick = {
|
||||
val has =
|
||||
@@ -82,7 +87,7 @@ fun ColumnScope.MessagesScreen(
|
||||
)
|
||||
PortalHeaderIcon(
|
||||
icon = if (addingContact) KitIcon.Close else KitIcon.Add,
|
||||
contentDescription = "Add contact",
|
||||
contentDescription = stringResource(R.string.set_messages_add_contact),
|
||||
modifier = Modifier.testTag("messages-add-contact"),
|
||||
onClick = { addingContact = !addingContact },
|
||||
)
|
||||
@@ -92,15 +97,19 @@ fun ColumnScope.MessagesScreen(
|
||||
ScanAddressDialog(
|
||||
onFound = { raw ->
|
||||
scanning = false
|
||||
parseScannedAddress(raw)?.let { card ->
|
||||
scannedPrefill =
|
||||
if (card.handle != null &&
|
||||
card.relay.equals(phase.relay, ignoreCase = true)
|
||||
) {
|
||||
card.handle
|
||||
} else {
|
||||
"${card.fnid}@${card.relay}"
|
||||
}
|
||||
// A contact link first (8.12): its app form looks like an
|
||||
// address too, and reading it as one would drop the token.
|
||||
if (io.flamenet.e2e.LinkCodes.decodeContactLink(raw) != null) {
|
||||
model.openContactLink(raw)
|
||||
return@ScanAddressDialog
|
||||
}
|
||||
// A hello link's code, an older card, or a bare address,
|
||||
// bound by the fnid as Home's scanner is.
|
||||
val prefill = prefillFor(raw)
|
||||
if (prefill == null) {
|
||||
model.unreadableCode()
|
||||
} else {
|
||||
scannedPrefill = prefill
|
||||
addingContact = true
|
||||
}
|
||||
},
|
||||
@@ -117,11 +126,15 @@ fun ColumnScope.MessagesScreen(
|
||||
}
|
||||
}
|
||||
|
||||
HeldLinkBanner(model, phase)
|
||||
ExportNudge(model, phase)
|
||||
RequestsSection(model, phase)
|
||||
|
||||
PortalBody {
|
||||
OutlinedTextField(
|
||||
value = query.value,
|
||||
onValueChange = { query.value = it },
|
||||
placeholder = { Text("Search conversations") },
|
||||
placeholder = { Text(stringResource(R.string.set_messages_search)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("messages-search"),
|
||||
)
|
||||
@@ -130,7 +143,7 @@ fun ColumnScope.MessagesScreen(
|
||||
MessageHits(model, query.value)
|
||||
}
|
||||
|
||||
PortalBand("Recent")
|
||||
PortalBand(stringResource(R.string.set_messages_recent))
|
||||
BuddyCirclesRow(model, phase)
|
||||
|
||||
// The kit's listing holds every conversation, groups included.
|
||||
@@ -138,12 +151,12 @@ fun ColumnScope.MessagesScreen(
|
||||
|
||||
val showNewChat = remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
PortalBand("Chats")
|
||||
PortalBand(stringResource(R.string.set_messages_chats))
|
||||
// The way in, on the band itself, exactly as New group sits beside its
|
||||
// heading in the bar above.
|
||||
androidx.compose.material3.Icon(
|
||||
KitIcon.NewChat,
|
||||
contentDescription = "New chat",
|
||||
contentDescription = stringResource(R.string.set_messages_new_chat),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -157,17 +170,18 @@ fun ColumnScope.MessagesScreen(
|
||||
if (showNewChat.value) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { showNewChat.value = false },
|
||||
title = { Text("New chat") },
|
||||
title = { Text(stringResource(R.string.set_messages_new_chat)) },
|
||||
text = {
|
||||
if (phase.buddies.isEmpty()) {
|
||||
Text("Add a contact first, on Home, with their username or address.")
|
||||
Text(stringResource(R.string.set_messages_add_contact_first))
|
||||
} else {
|
||||
Column {
|
||||
phase.buddies.forEach { buddy ->
|
||||
val fallbackName = stringResource(R.string.set_messages_user_fallback, buddy.userId)
|
||||
val rowName =
|
||||
buddy.displayName
|
||||
.ifBlank { buddy.handle }
|
||||
.ifBlank { "user ${buddy.userId}" }
|
||||
.ifBlank { fallbackName }
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -199,30 +213,40 @@ fun ColumnScope.MessagesScreen(
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(onClick = { showNewChat.value = false }) { Text("Cancel") }
|
||||
androidx.compose.material3.TextButton(onClick = { showNewChat.value = false }) { Text(stringResource(R.string.common_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val shown =
|
||||
var showArchived by remember { mutableStateOf(false) }
|
||||
var menuFor by remember { mutableStateOf<Long?>(null) }
|
||||
// Requests are listed above, not here (14.5); archived ones under their
|
||||
// own heading; starred ones first.
|
||||
val listed =
|
||||
phase.buddies
|
||||
.filter { it.userId !in phase.requests }
|
||||
.filter {
|
||||
query.value.isBlank() ||
|
||||
it.displayName.contains(query.value, true) ||
|
||||
it.handle.contains(query.value, true)
|
||||
}.sortedWith(
|
||||
compareByDescending<io.flamenet.client.MessengerApi.Buddy> {
|
||||
Vault.threadForUser(it.userId) in phase.unread
|
||||
}.thenBy { it.displayName.ifBlank { it.handle } },
|
||||
}
|
||||
val archived = listed.filter { isArchived(phase, it.userId) }
|
||||
val shown =
|
||||
listed
|
||||
.filterNot { isArchived(phase, it.userId) }
|
||||
.sortedWith(
|
||||
compareByDescending<io.flamenet.client.MessengerApi.Buddy> { isStarred(phase, it.userId) }
|
||||
.thenByDescending { Vault.threadForUser(it.userId) in phase.unread }
|
||||
.thenBy { it.displayName.ifBlank { it.handle } },
|
||||
)
|
||||
|
||||
if (shown.isEmpty()) {
|
||||
if (shown.isEmpty() && archived.isEmpty()) {
|
||||
PortalBody {
|
||||
Text(
|
||||
if (phase.buddies.isEmpty()) {
|
||||
"No conversations yet. Add someone on Home, everyone you add is a tap away."
|
||||
stringResource(R.string.set_messages_empty_none)
|
||||
} else {
|
||||
"Nothing matches that search."
|
||||
stringResource(R.string.set_messages_empty_no_match)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
@@ -231,18 +255,29 @@ fun ColumnScope.MessagesScreen(
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().testTag("conversations")) {
|
||||
shown.forEach { buddy ->
|
||||
(if (showArchived) shown + archived else shown).forEach { buddy ->
|
||||
val fallbackName = stringResource(R.string.set_messages_user_fallback, buddy.userId)
|
||||
val name =
|
||||
buddy.displayName
|
||||
.ifBlank { buddy.handle }
|
||||
.ifBlank { "user ${buddy.userId}" }
|
||||
.ifBlank { fallbackName }
|
||||
val unread = Vault.threadForUser(buddy.userId) in phase.unread
|
||||
val thread = Vault.threadForUser(buddy.userId)
|
||||
PortalRow(
|
||||
modifier =
|
||||
Modifier
|
||||
.clickable { model.openChat(buddy.userId, name) }
|
||||
.testTag("conversation-${buddy.userId}"),
|
||||
.combinedClickable(
|
||||
onClick = { model.openChat(buddy.userId, name) },
|
||||
onLongClick = { menuFor = buddy.userId },
|
||||
).testTag("conversation-${buddy.userId}"),
|
||||
) {
|
||||
ConversationFlagsMenu(
|
||||
model,
|
||||
thread,
|
||||
phase.convFlags[thread] ?: io.flamenet.client.AccountSync.ConvFlags(),
|
||||
expanded = menuFor == buddy.userId,
|
||||
onDismiss = { menuFor = null },
|
||||
)
|
||||
Avatar(
|
||||
name = name,
|
||||
online = buddy.presence == "online",
|
||||
@@ -258,7 +293,12 @@ fun ColumnScope.MessagesScreen(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
if (unread) "New messages" else buddy.presence,
|
||||
when {
|
||||
isArchived(phase, buddy.userId) -> stringResource(R.string.set_messages_archived)
|
||||
unread -> stringResource(R.string.set_messages_new_messages)
|
||||
isStarred(phase, buddy.userId) -> stringResource(R.string.set_messages_starred, presenceLabel(buddy.presence))
|
||||
else -> presenceLabel(buddy.presence)
|
||||
},
|
||||
fontSize = 13.sp,
|
||||
color =
|
||||
if (unread) {
|
||||
@@ -274,6 +314,20 @@ fun ColumnScope.MessagesScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (archived.isNotEmpty()) {
|
||||
androidx.compose.material3.TextButton(
|
||||
onClick = { showArchived = !showArchived },
|
||||
modifier = Modifier.padding(horizontal = Space.md).testTag("archived-toggle"),
|
||||
) {
|
||||
Text(
|
||||
if (showArchived) {
|
||||
stringResource(R.string.set_messages_hide_archived)
|
||||
} else {
|
||||
stringResource(R.string.set_messages_archived_count, archived.size)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -298,7 +352,7 @@ private fun MessageHits(
|
||||
if (hits.isEmpty()) return
|
||||
|
||||
Text(
|
||||
"IN MESSAGES",
|
||||
stringResource(R.string.set_messages_in_messages),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Palette.SecondaryText,
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.core.app.NotificationCompat
|
||||
import io.flamenet.client.MessengerApi
|
||||
import io.flamenet.client.RelayClient
|
||||
import io.flamenet.e2e.Vault
|
||||
import io.flamenet.messenger.R
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -165,8 +166,8 @@ class MessengerService : Service() {
|
||||
val notice =
|
||||
NotificationCompat
|
||||
.Builder(this, CHANNEL_ONGOING)
|
||||
.setContentTitle("Flamenet is receiving")
|
||||
.setContentText("Messages arrive without Google or Apple in the middle.")
|
||||
.setContentTitle(getString(R.string.core_notif_receiving_title))
|
||||
.setContentText(getString(R.string.core_notif_receiving_text))
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
@@ -212,7 +213,10 @@ class MessengerService : Service() {
|
||||
relay = relay,
|
||||
api =
|
||||
MessengerApi(credential.relayBaseUrl, credential.username, credential.appPassword),
|
||||
)
|
||||
).also { s ->
|
||||
s.onWiped = { TodayData.forget(applicationContext) }
|
||||
s.attachmentCacheDir = cacheDir
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private suspend fun run() {
|
||||
@@ -315,7 +319,24 @@ class MessengerService : Service() {
|
||||
"call=$onACall touched=${touched.size} " +
|
||||
(outcome.exceptionOrNull()?.let { "failed=${it::class.simpleName}" } ?: "ok"),
|
||||
)
|
||||
if (touched.isNotEmpty()) notifyMessages(touched.size)
|
||||
// A card, an event or an invitation that came alone gets its own
|
||||
// words. A game's moves, offers and endings are said too, but they
|
||||
// never count as unread, so they are not among `touched` (DESIGN 29).
|
||||
val notice = s.takeNotice()
|
||||
val gameNews = s.takeGameNews()
|
||||
// A snoozed chat holds all of it, an ordinary message as well as
|
||||
// a game's news (owner, 0.32).
|
||||
val snoozed = runCatching { s.snoozedThreads() }.getOrDefault(emptySet())
|
||||
// Requests and copies from this account's own devices are quiet
|
||||
// (spec 14.5, 17.2): they count as unread, never as an alert, so
|
||||
// they come out before anything is raised.
|
||||
val loud = touched - s.quietThreads
|
||||
NotificationSettings.due(loud, notice, gameNews, snoozed)?.let { due ->
|
||||
notifyMessages(due.threads, notice = due.notice, s = s)
|
||||
}
|
||||
// Answers only redraw a card, and nothing here is drawing one
|
||||
// (§6.32: answers do not notify). Taken so they do not pile up.
|
||||
s.takeQuietlyChanged()
|
||||
// Reply reminders fire from THIS loop, after `receive()`, and the
|
||||
// order matters: an inbound message clears the reminder inside
|
||||
// `Session.record`, so a reply that has just landed cancels its own
|
||||
@@ -352,12 +373,14 @@ class MessengerService : Service() {
|
||||
s.roster().associate { it.userId to it.displayName.ifBlank { it.handle } }
|
||||
}.getOrDefault(names)
|
||||
// Falls back to something answerable rather than a bare number.
|
||||
return names[userId] ?: "Someone"
|
||||
return names[userId] ?: getString(R.string.core_someone)
|
||||
}
|
||||
|
||||
private fun notifyMessages(
|
||||
threads: Int,
|
||||
from: String? = null,
|
||||
notice: Session.Notice? = null,
|
||||
s: Session? = null,
|
||||
) {
|
||||
val settings = NotificationSettings(this)
|
||||
// The master switch and the per-kind one, both checked before a
|
||||
@@ -372,14 +395,27 @@ class MessengerService : Service() {
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
// Still no sender and no preview unless it was asked for.
|
||||
val text = settings.messageText(threads, from)
|
||||
val text =
|
||||
if (notice != null && threads == 1 && s != null) {
|
||||
// Never the words of a card: who sent it and, when it is
|
||||
// sealed, the day it opens. Only looked up when it will be shown.
|
||||
val name = if (settings.showPreviews) runCatching { nameFor(notice.from, s) }.getOrNull() else null
|
||||
val opensOn = (notice as? Session.Notice.Card)?.opensAtMillis?.let { CardArt.day(it) }
|
||||
settings.noticeText(notice, name, opensOn)
|
||||
} else {
|
||||
settings.messageText(threads, from)
|
||||
}
|
||||
// Their move and a draw offer come without a sound; an invitation,
|
||||
// an ending and everything else with one.
|
||||
val quiet = threads == 1 && NotificationSettings.isQuiet(notice)
|
||||
notify(
|
||||
CHANNEL_MESSAGES,
|
||||
2,
|
||||
text,
|
||||
NotificationCompat.PRIORITY_HIGH,
|
||||
if (quiet) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_HIGH,
|
||||
open,
|
||||
number = threads,
|
||||
silent = quiet,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -495,6 +531,8 @@ class MessengerService : Service() {
|
||||
open: PendingIntent,
|
||||
category: String? = null,
|
||||
number: Int = 0,
|
||||
/** Shown without a sound or a buzz, as a game's next move is (§6.33). */
|
||||
silent: Boolean = false,
|
||||
) {
|
||||
val n =
|
||||
NotificationCompat
|
||||
@@ -503,6 +541,7 @@ class MessengerService : Service() {
|
||||
.setContentText(text)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setPriority(priority)
|
||||
.setSilent(silent)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(open)
|
||||
.also { b -> category?.let { b.setCategory(it) } }
|
||||
@@ -521,10 +560,10 @@ class MessengerService : Service() {
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ONGOING,
|
||||
"Receiving",
|
||||
getString(R.string.core_channel_receiving),
|
||||
NotificationManager.IMPORTANCE_MIN,
|
||||
).apply {
|
||||
description = "The notice Android requires while Flamenet is receiving."
|
||||
description = getString(R.string.core_channel_receiving_description)
|
||||
// The always-on service notice must not put a dot on the icon.
|
||||
// a permanent badge reads as "you have unread mail", for ever.
|
||||
setShowBadge(false)
|
||||
@@ -533,14 +572,14 @@ class MessengerService : Service() {
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_MESSAGES,
|
||||
"Messages",
|
||||
getString(R.string.core_channel_messages),
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply { setShowBadge(true) },
|
||||
)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_CALLS,
|
||||
"Calls",
|
||||
getString(R.string.core_channel_calls),
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
setBypassDnd(false)
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.client.AccountSync
|
||||
import io.flamenet.client.WebSearch
|
||||
import io.flamenet.e2e.Vault
|
||||
import io.flamenet.messenger.R
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** A QR code for [text], drawn crisp at [side]. */
|
||||
@Composable
|
||||
internal fun QrImage(
|
||||
text: String,
|
||||
side: androidx.compose.ui.unit.Dp = 220.dp,
|
||||
tag: String,
|
||||
) {
|
||||
val bitmap = remember(text) { qrBitmap(text) } ?: return
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = stringResource(R.string.social_qr_code_desc),
|
||||
filterQuality = FilterQuality.None,
|
||||
modifier = Modifier.size(side).testTag(tag),
|
||||
)
|
||||
}
|
||||
|
||||
// -- Linking this account's devices (spec 17.3) ----------------------------------.
|
||||
|
||||
/** How a linked device left the set, in the words the spec asks for. */
|
||||
internal fun linkNoticeText(notice: io.flamenet.client.Conversations.LinkNotice): String {
|
||||
val who = notice.deviceId.take(8)
|
||||
return when (notice.reason) {
|
||||
"revoke" -> "Device $who is no longer linked. You removed it."
|
||||
"expire" -> "Device $who is no longer linked. The server retired it, because it was inactive or to make room for a new sign-in."
|
||||
else -> "Device $who is no longer linked. The server stopped listing it and gave no reason."
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Linking, in Account under "Your devices": show this device's code, scan
|
||||
* another's, or compare a number; which devices are linked; and why one
|
||||
* stopped being.
|
||||
*/
|
||||
@Composable
|
||||
fun LinkedDevicesSection(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
) {
|
||||
var scanning by remember { mutableStateOf(false) }
|
||||
var scanned by remember { mutableStateOf<String?>(null) }
|
||||
var withHistory by remember { mutableStateOf(true) }
|
||||
var numberFor by remember { mutableStateOf<String?>(null) }
|
||||
var number by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
LaunchedEffect(Unit) { model.refreshContactState() }
|
||||
|
||||
Text(stringResource(R.string.social_linked_devices_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.social_linked_devices_explainer),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
|
||||
phase.linkNotices.forEach { notice ->
|
||||
Text(linkNoticeText(notice), fontSize = 13.sp, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("link-notice"))
|
||||
}
|
||||
if (phase.linkNotices.isNotEmpty()) {
|
||||
TextButton(onClick = { model.dismissLinkNotices() }) { Text(stringResource(R.string.social_ok)) }
|
||||
}
|
||||
|
||||
val others = phase.registeredDevices.filter { it.deviceId != phase.deviceId }
|
||||
others.forEach { device ->
|
||||
val linked = device.deviceId in phase.linkedDevices
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = Space.xs).testTag("linked-${device.deviceId}"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(device.deviceId.take(8), fontSize = 14.sp, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace)
|
||||
Text(if (linked) stringResource(R.string.social_device_linked) else stringResource(R.string.social_device_signed_in_not_linked), fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
}
|
||||
if (linked) {
|
||||
TextButton(onClick = { model.unlinkDevice(device.deviceId) }, modifier = Modifier.testTag("unlink-${device.deviceId}")) {
|
||||
Text(stringResource(R.string.social_unlink))
|
||||
}
|
||||
} else {
|
||||
TextButton(
|
||||
onClick = {
|
||||
numberFor = device.deviceId
|
||||
number = null
|
||||
scope.launch { number = model.linkingNumber(device.deviceId) }
|
||||
},
|
||||
modifier = Modifier.testTag("link-by-number-${device.deviceId}"),
|
||||
) { Text(stringResource(R.string.social_link_by_number)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = withHistory, onCheckedChange = { withHistory = it }, modifier = Modifier.testTag("link-with-history"))
|
||||
Text(stringResource(R.string.social_link_with_history), fontSize = 13.sp)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||
TextButton(onClick = { scanning = true }, modifier = Modifier.testTag("link-scan")) { Text(stringResource(R.string.social_scan_device_code)) }
|
||||
TextButton(onClick = { model.beginLink() }, modifier = Modifier.testTag("link-show-code")) { Text(stringResource(R.string.social_link_this_device)) }
|
||||
}
|
||||
|
||||
phase.linkCode?.let { code ->
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Text(
|
||||
stringResource(R.string.social_link_code_instructions),
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
QrImage(code, tag = "link-code")
|
||||
TextButton(onClick = { model.cancelLink() }) { Text(stringResource(R.string.social_stop_showing_code)) }
|
||||
}
|
||||
|
||||
if (scanning) {
|
||||
ScanAddressDialog(
|
||||
onFound = { raw ->
|
||||
scanning = false
|
||||
scanned = raw
|
||||
},
|
||||
onDismiss = { scanning = false },
|
||||
)
|
||||
}
|
||||
|
||||
// The person confirms before anything is sent: a scanned code is a
|
||||
// device asking to read everything.
|
||||
scanned?.let { raw ->
|
||||
val code = io.flamenet.e2e.LinkCodes.decodeDeviceCode(raw)
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { scanned = null },
|
||||
title = { Text(stringResource(R.string.social_link_this_device_question)) },
|
||||
text = {
|
||||
Text(
|
||||
if (code == null) {
|
||||
stringResource(R.string.social_not_a_link_code)
|
||||
} else if (withHistory) {
|
||||
stringResource(R.string.social_link_confirm_with_history, code.deviceId.take(8))
|
||||
} else {
|
||||
stringResource(R.string.social_link_confirm_no_history, code.deviceId.take(8))
|
||||
},
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
if (code != null) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
scanned = null
|
||||
model.linkScanned(raw, withHistory)
|
||||
},
|
||||
modifier = Modifier.testTag("link-confirm"),
|
||||
) { Text(stringResource(R.string.social_link)) }
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { scanned = null }) { Text(stringResource(R.string.common_cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
numberFor?.let { deviceId ->
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { numberFor = null },
|
||||
title = { Text(stringResource(R.string.social_compare_number_title)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
stringResource(R.string.social_compare_number_body, deviceId.take(8)),
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Text(
|
||||
number ?: stringResource(R.string.social_reading_device_list),
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.testTag("link-number"),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (number != null) {
|
||||
TextButton(onClick = {
|
||||
numberFor = null
|
||||
model.linkByNumber(deviceId, withHistory)
|
||||
}) { Text(stringResource(R.string.social_numbers_match)) }
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { numberFor = null }) { Text(stringResource(R.string.common_cancel)) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On the new device, in the numeric fallback: an offer waits until the
|
||||
* person compares the number here too. A confirmation on one side links
|
||||
* nothing.
|
||||
*/
|
||||
@Composable
|
||||
fun HeldLinkBanner(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
) {
|
||||
val from = phase.heldLinkOffers.firstOrNull() ?: return
|
||||
var number by remember(from) { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(from) { number = model.linkingNumber(from) }
|
||||
PortalBand(stringResource(R.string.social_link_request))
|
||||
PortalBody {
|
||||
Text(stringResource(R.string.social_held_link_body, from.take(8)))
|
||||
Text(number ?: "...", fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, fontSize = 16.sp, modifier = Modifier.testTag("held-link-number"))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||
TextButton(onClick = { model.acceptHeldLink(from) }, enabled = number != null, modifier = Modifier.testTag("held-link-accept")) {
|
||||
Text(stringResource(R.string.social_numbers_match))
|
||||
}
|
||||
TextButton(onClick = { model.refuseHeldLink(from) }, modifier = Modifier.testTag("held-link-refuse")) { Text(stringResource(R.string.social_dont_link)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Requests (spec 14.5) ------------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* People who wrote and were never accepted. A quiet list, not an alert:
|
||||
* anybody who can get an introduction can put something here.
|
||||
*/
|
||||
@Composable
|
||||
fun RequestsSection(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
) {
|
||||
if (phase.requests.isEmpty()) return
|
||||
PortalBand(stringResource(R.string.social_requests_title, phase.requests.size))
|
||||
Column(Modifier.fillMaxWidth().testTag("requests")) {
|
||||
phase.requests.forEach { id ->
|
||||
val (name, certified) = phase.requestNames[id] ?: (stringResource(R.string.social_user_fallback, id) to false)
|
||||
PortalRow(modifier = Modifier.clickable { model.openChat(id, name) }.testTag("request-$id")) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(name, fontSize = 15.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
if (certified) stringResource(R.string.social_name_confirmed) else stringResource(R.string.social_name_not_confirmed),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = { model.acceptRequest(id) }, modifier = Modifier.testTag("request-accept-$id")) { Text(stringResource(R.string.common_accept)) }
|
||||
TextButton(onClick = { model.setBlocked(id, true) }, modifier = Modifier.testTag("request-block-$id")) {
|
||||
Text(stringResource(R.string.common_block), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
TextButton(onClick = { model.deleteRequest(id) }, modifier = Modifier.testTag("request-delete-$id")) { Text(stringResource(R.string.social_delete)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Star and archive for one conversation, as a small menu. */
|
||||
@Composable
|
||||
fun ConversationFlagsMenu(
|
||||
model: MessengerViewModel,
|
||||
threadId: String,
|
||||
flags: AccountSync.ConvFlags,
|
||||
expanded: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
androidx.compose.material3.DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
androidx.compose.material3.DropdownMenuItem(
|
||||
text = { Text(if (flags.starred) stringResource(R.string.social_unstar) else stringResource(R.string.social_star)) },
|
||||
onClick = {
|
||||
onDismiss()
|
||||
model.setStarred(threadId, !flags.starred)
|
||||
},
|
||||
modifier = Modifier.testTag("star-$threadId"),
|
||||
)
|
||||
androidx.compose.material3.DropdownMenuItem(
|
||||
text = { Text(if (flags.archived) stringResource(R.string.social_move_back_to_chats) else stringResource(R.string.social_archive)) },
|
||||
onClick = {
|
||||
onDismiss()
|
||||
model.setArchived(threadId, !flags.archived)
|
||||
},
|
||||
modifier = Modifier.testTag("archive-$threadId"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// -- The export nudge (decision DA9) ----------------------------------------------.
|
||||
|
||||
/**
|
||||
* A single device and no backup for a month: if this phone goes, everything
|
||||
* goes with it. Said once, with the way out.
|
||||
*/
|
||||
@Composable
|
||||
fun ExportNudge(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
) {
|
||||
if (!phase.exportNudge) return
|
||||
PortalBody {
|
||||
Text(
|
||||
stringResource(R.string.social_export_nudge_body),
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
TextButton(onClick = { model.showScreen(MessengerViewModel.Phase.Screen.ACCOUNT) }, modifier = Modifier.testTag("export-nudge")) {
|
||||
Text(stringResource(R.string.social_export_backup))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Who can find you, and contact links (spec 8.12) --------------------------------.
|
||||
|
||||
enum class ContactLinkKind { ONE_TIME, REUSABLE }
|
||||
|
||||
/**
|
||||
* Which links this relay lets the person make. A one-time link is a delivery
|
||||
* token (21.4) and needs no introduction, so every relay gets it; a reusable
|
||||
* one needs `POST /e2e/contact-links` (8.12).
|
||||
*/
|
||||
internal fun contactLinkKindsOffered(features: io.flamenet.client.RelayClient.RelayFeatures): List<ContactLinkKind> =
|
||||
if (features.contactLinks) {
|
||||
listOf(ContactLinkKind.ONE_TIME, ContactLinkKind.REUSABLE)
|
||||
} else {
|
||||
listOf(ContactLinkKind.ONE_TIME)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FindMeSection(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) { model.refreshListed() }
|
||||
if (phase.features.unlisted && phase.listed != null) {
|
||||
Text(stringResource(R.string.social_who_can_find_you), style = MaterialTheme.typography.titleMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
androidx.compose.material3.RadioButton(selected = phase.listed == true, onClick = { model.setListed(true) }, modifier = Modifier.testTag("listed"))
|
||||
Text(stringResource(R.string.social_listed_option))
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
androidx.compose.material3.RadioButton(selected = phase.listed == false, onClick = { model.setListed(false) }, modifier = Modifier.testTag("unlisted"))
|
||||
Text(stringResource(R.string.social_unlisted_option))
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.social_unlisted_note),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.md))
|
||||
}
|
||||
val kinds = contactLinkKindsOffered(phase.features)
|
||||
Text(stringResource(R.string.social_contact_links_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.social_contact_links_explainer),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||
TextButton(onClick = { model.createContactLink(reusable = false) }, modifier = Modifier.testTag("link-one-time")) { Text(stringResource(R.string.social_new_one_time_link)) }
|
||||
if (ContactLinkKind.REUSABLE in kinds) {
|
||||
TextButton(onClick = { model.createContactLink(reusable = true) }, modifier = Modifier.testTag("link-reusable")) { Text(stringResource(R.string.social_new_reusable_link)) }
|
||||
}
|
||||
}
|
||||
val shareLinkTitle = stringResource(R.string.social_share_link_chooser)
|
||||
phase.contactLinks.forEach { link ->
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Text(if (link.reusable) stringResource(R.string.social_reusable_link) else stringResource(R.string.social_one_time_link), fontWeight = FontWeight.Medium)
|
||||
QrImage(link.forms.flamenet, side = 160.dp, tag = "contact-link-qr")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||
TextButton(onClick = {
|
||||
val send =
|
||||
android.content.Intent(android.content.Intent.ACTION_SEND)
|
||||
.setType("text/plain")
|
||||
.putExtra(android.content.Intent.EXTRA_TEXT, link.forms.https)
|
||||
context.startActivity(android.content.Intent.createChooser(send, shareLinkTitle))
|
||||
}) { Text(stringResource(R.string.social_share)) }
|
||||
TextButton(onClick = { model.revokeContactLink(link.cap) }, modifier = Modifier.testTag("link-revoke")) {
|
||||
Text(stringResource(R.string.common_remove), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Web search ------------------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* Search the web through the relay, which asks an index and keeps nothing.
|
||||
* Shown only where the relay offers it.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.WebSearchScreen(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
var page by remember { mutableStateOf(1) }
|
||||
var results by remember { mutableStateOf<WebSearch.Page?>(null) }
|
||||
var problem by remember { mutableStateOf<String?>(null) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
val searchUnavailable = stringResource(R.string.social_search_unavailable)
|
||||
val searchNothingFound = stringResource(R.string.social_search_nothing_found)
|
||||
val searchRateLimited = stringResource(R.string.social_search_rate_limited)
|
||||
PortalHeader(title = stringResource(R.string.social_search_the_web), subtitle = phase.relay)
|
||||
if (!phase.features.webSearch) {
|
||||
PortalBody { Text(stringResource(R.string.social_web_search_not_offered)) }
|
||||
return
|
||||
}
|
||||
|
||||
fun run(p: Int) {
|
||||
if (query.isBlank() || busy) return
|
||||
busy = true
|
||||
problem = null
|
||||
scope.launch {
|
||||
val got = model.webSearch(query, p)
|
||||
busy = false
|
||||
got
|
||||
.onSuccess {
|
||||
page = p
|
||||
results = it
|
||||
problem =
|
||||
when {
|
||||
it.unavailable -> searchUnavailable
|
||||
it.results.isEmpty() -> searchNothingFound
|
||||
else -> null
|
||||
}
|
||||
}.onFailure { f ->
|
||||
results = null
|
||||
problem =
|
||||
when {
|
||||
f is io.flamenet.client.RelayException.RateLimited ->
|
||||
searchRateLimited
|
||||
f is IllegalArgumentException -> f.message
|
||||
else -> describeFailure(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PortalBody {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it.take(WebSearch.MAX_QUERY) },
|
||||
placeholder = { Text(stringResource(R.string.common_search)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("web-search-field"),
|
||||
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { run(1) }),
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Search),
|
||||
)
|
||||
TextButton(onClick = { run(1) }, enabled = !busy, modifier = Modifier.testTag("web-search-go")) { Text(if (busy) stringResource(R.string.social_searching) else stringResource(R.string.common_search)) }
|
||||
Text(
|
||||
stringResource(R.string.social_search_privacy_note),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
problem?.let { Text(it, modifier = Modifier.testTag("web-search-problem")) }
|
||||
}
|
||||
results?.let { found ->
|
||||
Column(Modifier.fillMaxWidth().testTag("web-search-results")) {
|
||||
found.results.forEach { r ->
|
||||
PortalRow(
|
||||
height = 104.dp,
|
||||
modifier =
|
||||
Modifier.clickable {
|
||||
runCatching {
|
||||
context.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(r.url)))
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(r.title.ifBlank { r.host }, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Text(r.host, fontSize = 12.sp, color = MaterialTheme.colorScheme.primary)
|
||||
if (r.snippet.isNotBlank()) Text(r.snippet, fontSize = 13.sp, maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found.sources.isNotEmpty()) {
|
||||
PortalBody { Text(stringResource(R.string.social_search_results_from, found.sources.joinToString(", ")), fontSize = 12.sp, color = Palette.SecondaryText) }
|
||||
}
|
||||
if (found.results.isNotEmpty() && page < WebSearch.MAX_PAGE) {
|
||||
TextButton(onClick = { run(page + 1) }, modifier = Modifier.padding(horizontal = Space.md)) { Text(stringResource(R.string.social_more_results)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a thread belongs in the conversation list, the Archived list, or neither. */
|
||||
internal fun isArchived(
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
userId: Long,
|
||||
): Boolean = phase.convFlags[Vault.threadForUser(userId)]?.archived == true
|
||||
|
||||
internal fun isStarred(
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
userId: Long,
|
||||
): Boolean = phase.convFlags[Vault.threadForUser(userId)]?.starred == true
|
||||
@@ -7,9 +7,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@@ -22,14 +24,24 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.messenger.R
|
||||
import io.flamenet.client.Obfs4Routing
|
||||
import io.flamenet.obfs4.BridgeLine
|
||||
import io.flamenet.obfs4.BridgeLineResult
|
||||
import io.flamenet.obfs4.Obfs4Stats
|
||||
import io.flamenet.obfs4.Obfs4Bridge
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Reaching Flamenet on a network that blocks it.
|
||||
* Reaching Flamenet on a network that blocks it (SPEC §24).
|
||||
*
|
||||
* The same panel sits in Settings when signed in and behind "Connect through a
|
||||
* bridge" on the sign-in and sign-up screens, before any account exists. Offers
|
||||
* from contacts and sharing a line need an account, so they appear only when
|
||||
* [model] is signed in and [buddies] is not empty.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.NetworkSection(
|
||||
@@ -38,25 +50,30 @@ fun ColumnScope.NetworkSection(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val settings = remember { Obfs4Settings(context) }
|
||||
var draft by remember { mutableStateOf(settings.bridgeLine ?: "") }
|
||||
var stored by remember { mutableStateOf(settings.bridgeLine) }
|
||||
// Bumped on save/remove so the live status below re-reads.
|
||||
var draft by remember { mutableStateOf("") }
|
||||
var refusal by remember { mutableStateOf<String?>(null) }
|
||||
// Bumped on every change so the lists and the live status re-read.
|
||||
var tick by remember { mutableIntStateOf(0) }
|
||||
// Who has been handed the line this sitting.
|
||||
var sharedWith by remember { mutableStateOf(setOf<Long>()) }
|
||||
var confirmShare by remember { mutableStateOf<io.flamenet.client.MessengerApi.Buddy?>(null) }
|
||||
var scanning by remember { mutableStateOf(false) }
|
||||
val askCamera =
|
||||
androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts
|
||||
.RequestPermission(),
|
||||
) { _ -> scanning = true }
|
||||
|
||||
@Suppress("UNUSED_EXPRESSION")
|
||||
tick
|
||||
|
||||
val lines = remember(tick) { settings.lines }
|
||||
val parsed = if (draft.isBlank()) null else BridgeLine.parse(draft)
|
||||
val isUnchanged = draft.trim() == (stored ?: "")
|
||||
|
||||
Text("Network", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.set_network_title), style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
// Reads the handshake counter and the bridge in force, never the stored
|
||||
// line.
|
||||
// Reads what the transport did, never the stored list.
|
||||
Text(
|
||||
statusLine(),
|
||||
statusLineText(),
|
||||
fontSize = 13.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.testTag("obfs4-status"),
|
||||
@@ -66,11 +83,11 @@ fun ColumnScope.NetworkSection(
|
||||
val offers = remember(tick) { model?.bridgeOffers().orEmpty() }
|
||||
if (offers.isNotEmpty()) {
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Text("Offered to you", fontWeight = FontWeight.Medium, fontSize = 15.sp)
|
||||
Text(stringResource(R.string.set_network_offered), fontWeight = FontWeight.Medium, fontSize = 15.sp)
|
||||
for (offer in offers) {
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Text(
|
||||
"${offer.fromName} shared a bridge",
|
||||
stringResource(R.string.set_network_shared_a_bridge, offer.fromName),
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.testTag("obfs4-offer-${offer.fromUser}"),
|
||||
)
|
||||
@@ -81,36 +98,23 @@ fun ColumnScope.NetworkSection(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
when (val parsed = BridgeLine.parse(offer.line)) {
|
||||
is BridgeLineResult.Bad -> {
|
||||
Text(
|
||||
// An offer that cannot be used says why, rather than
|
||||
// failing when it is accepted.
|
||||
parsed.problem.message,
|
||||
fontSize = 12.sp,
|
||||
color = Palette.Danger,
|
||||
)
|
||||
}
|
||||
|
||||
is BridgeLineResult.Ok -> {
|
||||
Text(
|
||||
"Reads as ${parsed.bridge.host} port ${parsed.bridge.port}",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reading(offer.line)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
KitPrimaryButton(
|
||||
text = "Use this bridge",
|
||||
text = stringResource(R.string.set_network_use_bridge),
|
||||
onClick = {
|
||||
if (settings.accept(offer.line) is BridgeLineResult.Ok) {
|
||||
draft = offer.line
|
||||
stored = settings.bridgeLine
|
||||
model?.dismissBridgeOffer(offer.fromUser)
|
||||
tick++
|
||||
when (val result = settings.accept(offer.line, from = offer.fromName)) {
|
||||
is Obfs4Settings.AddResult.Added, Obfs4Settings.AddResult.AlreadySaved -> {
|
||||
model?.dismissBridgeOffer(offer.fromUser)
|
||||
refusal = null
|
||||
}
|
||||
|
||||
else -> {
|
||||
refusal = addResultWords(context, result)
|
||||
}
|
||||
}
|
||||
tick++
|
||||
},
|
||||
enabled = parsedOk(offer.line),
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -118,7 +122,7 @@ fun ColumnScope.NetworkSection(
|
||||
)
|
||||
Spacer(Modifier.width(Space.sm))
|
||||
KitSecondaryButton(
|
||||
text = "Dismiss",
|
||||
text = stringResource(R.string.set_network_dismiss),
|
||||
onClick = {
|
||||
model?.dismissBridgeOffer(offer.fromUser)
|
||||
tick++
|
||||
@@ -130,11 +134,55 @@ fun ColumnScope.NetworkSection(
|
||||
}
|
||||
}
|
||||
|
||||
// The stored lines (§24.6).
|
||||
if (lines.isNotEmpty()) {
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Text(stringResource(R.string.set_network_your_bridges), fontWeight = FontWeight.Medium, fontSize = 15.sp)
|
||||
lines.forEachIndexed { index, stored ->
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Text(
|
||||
stored.line,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.testTag("obfs4-line-$index"),
|
||||
)
|
||||
Reading(stored.line)
|
||||
stored.offeredBy?.let {
|
||||
Text(stringResource(R.string.set_network_from, it), fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
}
|
||||
Text(
|
||||
stored.lastWorked?.let {
|
||||
stringResource(
|
||||
R.string.set_network_last_worked,
|
||||
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(it * 1000)),
|
||||
)
|
||||
} ?: stringResource(R.string.set_network_not_connected_yet),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
settings.remove(stored.line)
|
||||
tick++
|
||||
},
|
||||
modifier = Modifier.testTag("obfs4-remove-$index"),
|
||||
) { Text(stringResource(R.string.common_remove), color = Palette.Danger) }
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.set_network_tries_in_turn),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(Space.md))
|
||||
|
||||
OutlinedTextField(
|
||||
value = draft,
|
||||
onValueChange = { draft = it },
|
||||
onValueChange = {
|
||||
draft = it
|
||||
refusal = null
|
||||
},
|
||||
placeholder = { Text("obfs4 198.51.100.7:4444 …", fontSize = 13.sp) },
|
||||
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
minLines = 2,
|
||||
@@ -156,7 +204,7 @@ fun ColumnScope.NetworkSection(
|
||||
|
||||
is BridgeLineResult.Ok -> {
|
||||
Text(
|
||||
"Reads as ${parsed.bridge.host} port ${parsed.bridge.port}",
|
||||
stringResource(R.string.set_network_reads_as, parsed.bridge.host, parsed.bridge.port),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.testTag("obfs4-parsed"),
|
||||
@@ -175,47 +223,56 @@ fun ColumnScope.NetworkSection(
|
||||
Unit
|
||||
}
|
||||
}
|
||||
refusal?.let {
|
||||
Text(it, fontSize = 12.sp, color = Palette.Danger, modifier = Modifier.testTag("obfs4-refusal"))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
|
||||
KitPrimaryButton(
|
||||
text = "Save this bridge",
|
||||
text = stringResource(R.string.set_network_save),
|
||||
onClick = {
|
||||
// Goes through `save`, which parses again and stores only what it
|
||||
// Goes through `add`, which parses again and stores only what it
|
||||
// could use.
|
||||
if (settings.save(draft) is BridgeLineResult.Ok) {
|
||||
stored = settings.bridgeLine
|
||||
tick++
|
||||
when (val result = settings.add(draft)) {
|
||||
is Obfs4Settings.AddResult.Added -> {
|
||||
draft = ""
|
||||
refusal = null
|
||||
}
|
||||
|
||||
else -> {
|
||||
refusal = addResultWords(context, result)
|
||||
}
|
||||
}
|
||||
tick++
|
||||
},
|
||||
enabled = parsed is BridgeLineResult.Ok && !isUnchanged,
|
||||
enabled = parsed is BridgeLineResult.Ok,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "obfs4-save",
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
KitSecondaryButton(
|
||||
text = stringResource(R.string.set_network_scan_code),
|
||||
onClick = {
|
||||
val has =
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
android.Manifest.permission.CAMERA,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (has) scanning = true else askCamera.launch(android.Manifest.permission.CAMERA)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "obfs4-scan",
|
||||
)
|
||||
|
||||
if (stored != null) {
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
KitSecondaryButton(
|
||||
text = "Stop using a bridge",
|
||||
onClick = {
|
||||
settings.clear()
|
||||
draft = ""
|
||||
stored = null
|
||||
tick++
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "obfs4-remove",
|
||||
)
|
||||
}
|
||||
|
||||
// The other half of receiving one.
|
||||
if (stored != null && buddies.isNotEmpty()) {
|
||||
// The other half of receiving one (§24.3).
|
||||
val share = remember(tick) { settings.bridgeLine }
|
||||
if (share != null && model != null && buddies.isNotEmpty()) {
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Text("Share it with", fontWeight = FontWeight.Medium, fontSize = 15.sp)
|
||||
Text(stringResource(R.string.set_network_share_with), fontWeight = FontWeight.Medium, fontSize = 15.sp)
|
||||
Text(
|
||||
// Says who learns what.
|
||||
"They get the line and decide for themselves. Whoever runs the bridge will " +
|
||||
"see one more person connecting, and your contact will know you use it.",
|
||||
stringResource(R.string.set_network_share_note),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -230,11 +287,13 @@ fun ColumnScope.NetworkSection(
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
KitSecondaryButton(
|
||||
text = if (sharedWith.contains(buddy.userId)) "Sent" else "Send",
|
||||
onClick = {
|
||||
model?.shareBridge(buddy.userId)
|
||||
sharedWith = sharedWith + buddy.userId
|
||||
},
|
||||
text =
|
||||
if (sharedWith.contains(buddy.userId)) {
|
||||
stringResource(R.string.set_network_sent)
|
||||
} else {
|
||||
stringResource(R.string.common_send)
|
||||
},
|
||||
onClick = { confirmShare = buddy },
|
||||
// Not disabled after sending.
|
||||
tag = "obfs4-share-${buddy.userId}",
|
||||
)
|
||||
@@ -245,25 +304,84 @@ fun ColumnScope.NetworkSection(
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
|
||||
Text(
|
||||
"Paste the line a bridge provider gave you. It's one long line that starts with " +
|
||||
"obfs4, copy all of it, including the part after cert=.",
|
||||
stringResource(R.string.set_network_paste_hint),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
|
||||
Text(EXPLANATION, fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
Text(stringResource(R.string.set_network_explanation), fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
|
||||
confirmShare?.let { buddy ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmShare = null },
|
||||
title = { Text(stringResource(R.string.set_network_share_title)) },
|
||||
text = { Text(stringResource(R.string.set_network_share_note)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
model?.shareBridge(buddy.userId)
|
||||
sharedWith = sharedWith + buddy.userId
|
||||
confirmShare = null
|
||||
},
|
||||
modifier = Modifier.testTag("obfs4-share-confirm"),
|
||||
) { Text(stringResource(R.string.common_send)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmShare = null }) { Text(stringResource(R.string.common_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (scanning) {
|
||||
ScanAddressDialog(
|
||||
onFound = { raw ->
|
||||
scanning = false
|
||||
draft = raw.trim()
|
||||
refusal = null
|
||||
},
|
||||
onDismiss = { scanning = false },
|
||||
hint = stringResource(R.string.set_network_scan_hint),
|
||||
denied = stringResource(R.string.set_network_scan_denied),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** What reading a line gave (§24.2): the problem, or where it points. */
|
||||
@Composable
|
||||
private fun Reading(line: String) {
|
||||
when (val parsed = BridgeLine.parse(line)) {
|
||||
is BridgeLineResult.Bad -> {
|
||||
// An offer that cannot be used says why, rather than failing when
|
||||
// it is accepted.
|
||||
Text(parsed.problem.message, fontSize = 12.sp, color = Palette.Danger)
|
||||
}
|
||||
|
||||
is BridgeLineResult.Ok -> {
|
||||
Text(
|
||||
stringResource(R.string.set_network_reads_as, parsed.bridge.host, parsed.bridge.port),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
parsed.caveat?.let { Text(it, fontSize = 12.sp, color = Palette.SecondaryText) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parsedOk(line: String) = BridgeLine.parse(line) is BridgeLineResult.Ok
|
||||
|
||||
/**
|
||||
* What is actually happening, as opposed to what is stored.
|
||||
* What is actually happening, as opposed to what is stored (§24.6).
|
||||
*
|
||||
* @param bridge the line that carried the last connection, else the first to try.
|
||||
* @param handshakes above zero when [bridge] carried the last connection.
|
||||
*/
|
||||
internal fun statusLine(
|
||||
bridge: io.flamenet.obfs4.Obfs4Bridge? = Obfs4Routing.bridge,
|
||||
handshakes: Int = Obfs4Stats.completedHandshakes(),
|
||||
bridge: Obfs4Bridge? = Obfs4Routing.lastCarried ?: Obfs4Routing.bridge,
|
||||
handshakes: Int = if (Obfs4Routing.lastCarried != null) 1 else 0,
|
||||
count: Int = Obfs4Routing.bridges.size,
|
||||
everyLineFailed: Boolean = Obfs4Routing.everyLineFailed,
|
||||
): String =
|
||||
when {
|
||||
bridge == null -> {
|
||||
@@ -277,7 +395,19 @@ internal fun statusLine(
|
||||
"always did."
|
||||
}
|
||||
|
||||
// Never a direct connection while a line is stored.
|
||||
everyLineFailed -> {
|
||||
"No bridge could be reached. The app tried each one in turn and keeps trying, " +
|
||||
"and it won't connect without a bridge while one is saved. Remove them all " +
|
||||
"to connect directly."
|
||||
}
|
||||
|
||||
// Not "protected".
|
||||
count > 1 -> {
|
||||
"$count bridges saved. Nothing has gone through them yet, the app will try them " +
|
||||
"the next time it goes online."
|
||||
}
|
||||
|
||||
else -> {
|
||||
"Bridge saved: ${bridge.host}:${bridge.port}. Nothing has gone through it yet, the " +
|
||||
"app will try the next time it goes online."
|
||||
@@ -285,15 +415,35 @@ internal fun statusLine(
|
||||
}
|
||||
|
||||
/**
|
||||
* Says what it does not protect, in the same breath as what it does.
|
||||
* The refusal for a line that could not be added, in the phone's language.
|
||||
* [Obfs4Settings.AddResult.message] keeps the English, which tests read.
|
||||
*/
|
||||
private const val EXPLANATION =
|
||||
"Some networks block Flamenet outright, or recognise it and slow it down. A bridge is a " +
|
||||
"machine somewhere else that your app talks to instead, in a way that doesn't look " +
|
||||
"like anything in particular, not like Flamenet, and not like a video call or a web " +
|
||||
"page either. The bridge passes your traffic on to your server.\n\n" +
|
||||
"Two things worth being clear about. Your server sees exactly what it saw before, so " +
|
||||
"this doesn't hide anything from whoever runs it. And whoever runs the bridge can see " +
|
||||
"that you're connecting to them, though not what you're saying, your messages stay " +
|
||||
"end-to-end encrypted the whole way, as they always are.\n\n" +
|
||||
"If Flamenet already works where you are, you don't need this."
|
||||
private fun addResultWords(
|
||||
context: android.content.Context,
|
||||
result: Obfs4Settings.AddResult,
|
||||
): String =
|
||||
when (result) {
|
||||
Obfs4Settings.AddResult.TooMany -> context.getString(R.string.set_network_too_many)
|
||||
Obfs4Settings.AddResult.AlreadySaved -> context.getString(R.string.set_network_already_saved)
|
||||
else -> result.message
|
||||
}
|
||||
|
||||
/**
|
||||
* [statusLine] in the phone's language, for the screen. The English function
|
||||
* stays because tests pin it without a Context; the branches here must stay
|
||||
* in step with it, including that nothing is said to go around a saved bridge.
|
||||
*/
|
||||
@Composable
|
||||
internal fun statusLineText(
|
||||
bridge: Obfs4Bridge? = Obfs4Routing.lastCarried ?: Obfs4Routing.bridge,
|
||||
handshakes: Int = if (Obfs4Routing.lastCarried != null) 1 else 0,
|
||||
count: Int = Obfs4Routing.bridges.size,
|
||||
everyLineFailed: Boolean = Obfs4Routing.everyLineFailed,
|
||||
): String =
|
||||
when {
|
||||
bridge == null -> stringResource(R.string.set_network_status_direct)
|
||||
handshakes > 0 -> stringResource(R.string.set_network_status_through, bridge.host, bridge.port)
|
||||
everyLineFailed -> stringResource(R.string.set_network_status_none_reached)
|
||||
count > 1 -> pluralStringResource(R.plurals.set_network_status_saved_many, count, count)
|
||||
else -> stringResource(R.string.set_network_status_saved, bridge.host, bridge.port)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.messenger.R
|
||||
|
||||
/**
|
||||
* What this phone's notifications are allowed to say.
|
||||
@@ -37,7 +39,7 @@ fun ColumnScope.NotificationSection() {
|
||||
var previews by remember { mutableStateOf(settings.showPreviews) }
|
||||
var sounds by remember { mutableStateOf(settings.inAppSounds) }
|
||||
|
||||
Text("Notifications", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.set_notify_title), style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
@Composable
|
||||
fun row(
|
||||
@@ -59,20 +61,20 @@ fun ColumnScope.NotificationSection() {
|
||||
}
|
||||
}
|
||||
|
||||
row("Notify me", "notify-enabled", enabled) { on ->
|
||||
row(stringResource(R.string.set_notify_enabled), "notify-enabled", enabled) { on ->
|
||||
settings.enabled = on
|
||||
enabled = on
|
||||
}
|
||||
if (enabled) {
|
||||
row("New messages", "notify-messages", newMessages) { on ->
|
||||
row(stringResource(R.string.set_notify_new_messages), "notify-messages", newMessages) { on ->
|
||||
settings.newMessages = on
|
||||
newMessages = on
|
||||
}
|
||||
row("Show who it is from", "notify-previews", previews) { on ->
|
||||
row(stringResource(R.string.set_notify_show_sender), "notify-previews", previews) { on ->
|
||||
settings.showPreviews = on
|
||||
previews = on
|
||||
}
|
||||
row("Sound while the app is open", "notify-sounds", sounds) { on ->
|
||||
row(stringResource(R.string.set_notify_sound_in_app), "notify-sounds", sounds) { on ->
|
||||
settings.inAppSounds = on
|
||||
sounds = on
|
||||
}
|
||||
@@ -92,20 +94,18 @@ fun ColumnScope.NotificationSection() {
|
||||
if (manager?.canUseFullScreenIntent() == false) {
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Text(
|
||||
"Calls will not ring full screen",
|
||||
stringResource(R.string.set_notify_fsi_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.testTag("notify-fsi-title"),
|
||||
)
|
||||
Text(
|
||||
"Android is holding back the permission that lets an incoming call " +
|
||||
"take over the screen, so a call shows as a banner for a couple of " +
|
||||
"seconds and then goes. Grant it and calls ring properly.",
|
||||
stringResource(R.string.set_notify_fsi_body),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
KitSecondaryButton(
|
||||
text = "Let calls ring",
|
||||
text = stringResource(R.string.set_notify_fsi_grant),
|
||||
onClick = {
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
@@ -127,11 +127,9 @@ fun ColumnScope.NotificationSection() {
|
||||
// cautious.
|
||||
Text(
|
||||
if (previews) {
|
||||
"Your lock screen will name whoever wrote to you. Anyone who can see your " +
|
||||
"phone can see that, which is sometimes more revealing than the message."
|
||||
stringResource(R.string.set_notify_previews_on_note)
|
||||
} else {
|
||||
"Notifications say how many messages arrived and nothing else, no name, no " +
|
||||
"words. Calls still ring."
|
||||
stringResource(R.string.set_notify_previews_off_note)
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import android.content.Context
|
||||
import io.flamenet.e2e.Vault
|
||||
|
||||
/**
|
||||
* What a notification is allowed to say, and whether to post one at all.
|
||||
@@ -10,6 +11,9 @@ class NotificationSettings(
|
||||
) {
|
||||
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/** For the notification text in the phone's language; see [messageText]. */
|
||||
private val res = context.resources
|
||||
|
||||
/** The master switch. Off means no notification of any kind is posted. */
|
||||
var enabled: Boolean
|
||||
get() = prefs.getBoolean(ENABLED, true)
|
||||
@@ -38,13 +42,40 @@ class NotificationSettings(
|
||||
fun messageText(
|
||||
threads: Int,
|
||||
from: String?,
|
||||
): String = messageText(showPreviews, threads, from)
|
||||
): String =
|
||||
// The same three cases as the companion's messageText, which stays in
|
||||
// English because NotificationTextTest pins it without a Context.
|
||||
when {
|
||||
showPreviews && threads == 1 && !from.isNullOrBlank() ->
|
||||
res.getString(io.flamenet.messenger.R.string.core_notif_new_message_from, from)
|
||||
threads == 1 -> res.getString(io.flamenet.messenger.R.string.core_notif_new_message)
|
||||
else -> res.getString(io.flamenet.messenger.R.string.core_notif_new_messages_in_chats, threads)
|
||||
}
|
||||
|
||||
/** The text for a card or an event that arrived alone. */
|
||||
fun noticeText(
|
||||
notice: Session.Notice,
|
||||
from: String?,
|
||||
opensOn: String?,
|
||||
): String = noticeText(showPreviews, notice, from, opensOn)
|
||||
|
||||
/** The same question for a reply reminder. */
|
||||
fun replyReminderText(
|
||||
count: Int,
|
||||
from: String?,
|
||||
): String = replyReminderText(showPreviews, count, from)
|
||||
): String =
|
||||
when {
|
||||
showPreviews && count == 1 && !from.isNullOrBlank() ->
|
||||
res.getString(io.flamenet.messenger.R.string.core_notif_no_reply_from, from)
|
||||
count == 1 -> res.getString(io.flamenet.messenger.R.string.core_notif_no_reply)
|
||||
else -> res.getString(io.flamenet.messenger.R.string.core_notif_no_reply_in_chats, count)
|
||||
}
|
||||
|
||||
/** One poll's notification: how many chats it covers, and the notice that words it. */
|
||||
data class Due(
|
||||
val threads: Int,
|
||||
val notice: Session.Notice?,
|
||||
)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
@@ -62,6 +93,82 @@ class NotificationSettings(
|
||||
else -> "New messages in $threads chats"
|
||||
}
|
||||
|
||||
/**
|
||||
* The line for a card, an event or a game's news that was the only
|
||||
* thing to arrive (§6.31 to §6.33). Never the words of a card, sealed
|
||||
* or not: a notification is read on a lock screen. With previews off,
|
||||
* or nobody to name, it is the generic line any message gets.
|
||||
*
|
||||
* @param opensOn the day a sealed card opens, as the reader's calendar
|
||||
* writes it, or null when it is not sealed.
|
||||
*/
|
||||
fun noticeText(
|
||||
showPreviews: Boolean,
|
||||
notice: Session.Notice,
|
||||
from: String?,
|
||||
opensOn: String?,
|
||||
): String {
|
||||
val generic = messageText(showPreviews = false, threads = 1, from = null)
|
||||
if (!showPreviews) return generic
|
||||
val name = from?.takeIf { it.isNotBlank() }
|
||||
return when (notice) {
|
||||
is Session.Notice.Card ->
|
||||
when {
|
||||
name == null -> generic
|
||||
opensOn != null -> "$name sent you a card to open on $opensOn"
|
||||
else -> "$name sent you a card"
|
||||
}
|
||||
|
||||
is Session.Notice.Invite -> if (name == null) generic else "$name invited you: ${notice.title}"
|
||||
|
||||
is Session.Notice.Changed ->
|
||||
if (notice.cancelled) "${notice.title} was cancelled" else "${notice.title} changed"
|
||||
|
||||
// §6.33: "Sam invited you to play chess" (DESIGN 29), "Your
|
||||
// move: chess with Sam", and how a game ended.
|
||||
is Session.Notice.GameInvite ->
|
||||
if (name == null) generic else "$name invited you to play ${ChatGames.lowerName(notice.type)}"
|
||||
|
||||
is Session.Notice.GameTurn ->
|
||||
"Your move: ${ChatGames.lowerName(notice.type)}" + (name?.let { " with $it" } ?: "")
|
||||
|
||||
is Session.Notice.GameOver ->
|
||||
ChatGames.name(notice.type) + (name?.let { " with $it" } ?: "") + ": " + notice.result
|
||||
|
||||
is Session.Notice.GameDrawOffer -> if (name == null) generic else "$name offers a draw: ${ChatGames.name(notice.type)}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Said without a sound: their move and a draw offer (§6.33). An
|
||||
* invitation and an ending come with one (DESIGN 29), like a message.
|
||||
*/
|
||||
fun isQuiet(notice: Session.Notice?): Boolean =
|
||||
notice is Session.Notice.GameTurn || notice is Session.Notice.GameDrawOffer
|
||||
|
||||
/**
|
||||
* What one poll's notification is about, or null for none. A snoozed
|
||||
* chat holds every notification until the snooze ends, an ordinary
|
||||
* message's as well as a game's, and what came still counts as
|
||||
* unread. Game news that is not unread (DESIGN 29) is said only when
|
||||
* no chat has anything else to say.
|
||||
*
|
||||
* @param touched the chats the poll made unread.
|
||||
* @param snoozed the chats snoozed now, by thread id.
|
||||
*/
|
||||
fun due(
|
||||
touched: Set<String>,
|
||||
notice: Session.Notice?,
|
||||
gameNews: Session.Notice?,
|
||||
snoozed: Set<String>,
|
||||
): Due? {
|
||||
val heard = touched - snoozed
|
||||
if (heard.isNotEmpty()) return Due(heard.size, notice.takeIf { heard == touched })
|
||||
// Games are one-to-one (§6.33), so the sender names the chat.
|
||||
val news = gameNews?.takeIf { Vault.threadForUser(it.from) !in snoozed } ?: return null
|
||||
return Due(1, news)
|
||||
}
|
||||
|
||||
fun replyReminderText(
|
||||
showPreviews: Boolean,
|
||||
count: Int,
|
||||
|
||||
@@ -3,72 +3,190 @@ package io.flamenet.messenger.app
|
||||
import android.content.Context
|
||||
import io.flamenet.client.Obfs4Routing
|
||||
import io.flamenet.obfs4.BridgeLine
|
||||
import io.flamenet.obfs4.BridgeLineProblem
|
||||
import io.flamenet.obfs4.BridgeLineResult
|
||||
import io.flamenet.obfs4.Obfs4Bridge
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* The bridge line this phone uses, if any.
|
||||
* The bridge lines this phone uses, SPEC §24.6: up to eight, in the order they
|
||||
* were added. A line the person pasted and one accepted from a contact are
|
||||
* stored alike; who offered it is kept only to show beside it.
|
||||
*
|
||||
* In the app's private preferences, which `dataExtractionRules` keeps out of
|
||||
* every backup and device transfer (§24.5).
|
||||
*/
|
||||
class Obfs4Settings(
|
||||
context: Context,
|
||||
) {
|
||||
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* The stored line, exactly as it was pasted.
|
||||
*/
|
||||
var bridgeLine: String?
|
||||
get() = prefs.getString(BRIDGE_LINE, null)
|
||||
private set(value) {
|
||||
prefs
|
||||
.edit()
|
||||
.apply {
|
||||
if (value.isNullOrBlank()) remove(BRIDGE_LINE) else putString(BRIDGE_LINE, value)
|
||||
}.apply()
|
||||
/** One stored line. */
|
||||
data class StoredLine(
|
||||
val line: String,
|
||||
val addedAt: Long,
|
||||
val offeredBy: String?,
|
||||
val lastWorked: Long?,
|
||||
)
|
||||
|
||||
/** What adding a line came to. */
|
||||
sealed interface AddResult {
|
||||
val message: String
|
||||
|
||||
data class Added(
|
||||
val bridge: Obfs4Bridge,
|
||||
val caveat: String?,
|
||||
) : AddResult {
|
||||
override val message = ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a line and starts using it, or reports why it cannot be used.
|
||||
*/
|
||||
fun save(line: String): BridgeLineResult {
|
||||
data class Unreadable(
|
||||
val problem: BridgeLineProblem,
|
||||
) : AddResult {
|
||||
override val message get() = problem.message
|
||||
}
|
||||
|
||||
object TooMany : AddResult {
|
||||
override val message = "You can keep up to 8 bridges. Remove one before adding another."
|
||||
}
|
||||
|
||||
object AlreadySaved : AddResult {
|
||||
override val message = "This bridge is already in your list."
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored lines, in the order they were added. */
|
||||
val lines: List<StoredLine>
|
||||
get() {
|
||||
prefs.getString(LINES, null)?.let { return decode(it) }
|
||||
// A one-line build stored its line on its own. Take it, and remove
|
||||
// the old copy.
|
||||
val old = prefs.getString(BRIDGE_LINE, null)?.trim()
|
||||
prefs.edit().remove(BRIDGE_LINE).apply()
|
||||
if (old.isNullOrEmpty()) return emptyList()
|
||||
val migrated = listOf(StoredLine(old, 0, null, null))
|
||||
write(migrated)
|
||||
return migrated
|
||||
}
|
||||
|
||||
/** The line to hand a contact: the one in use now, else the first. */
|
||||
val bridgeLine: String?
|
||||
get() {
|
||||
val all = lines
|
||||
val live = Obfs4Routing.order().firstOrNull()
|
||||
return all.firstOrNull { BridgeLine.parse(it.line).let { r -> r is BridgeLineResult.Ok && r.bridge == live } }?.line
|
||||
?: all.firstOrNull()?.line
|
||||
}
|
||||
|
||||
private fun decode(text: String): List<StoredLine> =
|
||||
runCatching {
|
||||
val array = JSONArray(text)
|
||||
(0 until array.length()).mapNotNull { i ->
|
||||
val o = array.optJSONObject(i) ?: return@mapNotNull null
|
||||
val line = o.optString("line").takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
StoredLine(
|
||||
line = line,
|
||||
addedAt = o.optLong("addedAt", 0),
|
||||
offeredBy = o.optString("offeredBy").takeIf { o.has("offeredBy") && it.isNotEmpty() },
|
||||
lastWorked = if (o.has("lastWorked")) o.optLong("lastWorked") else null,
|
||||
)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun write(all: List<StoredLine>) {
|
||||
if (all.isEmpty()) {
|
||||
prefs.edit().remove(LINES).apply()
|
||||
return
|
||||
}
|
||||
val array = JSONArray()
|
||||
for (l in all) {
|
||||
array.put(
|
||||
JSONObject()
|
||||
.put("line", l.line)
|
||||
.put("addedAt", l.addedAt)
|
||||
.apply {
|
||||
l.offeredBy?.let { put("offeredBy", it) }
|
||||
l.lastWorked?.let { put("lastWorked", it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
prefs.edit().putString(LINES, array.toString()).apply()
|
||||
}
|
||||
|
||||
/** Puts the stored list into force, the line that last worked first. */
|
||||
private fun route(all: List<StoredLine>) {
|
||||
val parsed = all.mapNotNull { l -> (BridgeLine.parse(l.line) as? BridgeLineResult.Ok)?.let { l to it.bridge } }
|
||||
val latest = parsed.filter { it.first.lastWorked != null }.maxByOrNull { it.first.lastWorked!! }?.second
|
||||
Obfs4Routing.onWorked = { bridge -> markWorked(bridge) }
|
||||
Obfs4Routing.use(parsed.map { it.second }, lastWorked = latest)
|
||||
}
|
||||
|
||||
/** Adds a line at the end of the list and starts using it. */
|
||||
fun add(
|
||||
line: String,
|
||||
offeredBy: String? = null,
|
||||
): AddResult {
|
||||
val trimmed = line.trim()
|
||||
val result = BridgeLine.parse(trimmed)
|
||||
if (result is BridgeLineResult.Ok) {
|
||||
bridgeLine = trimmed
|
||||
Obfs4Routing.use(result.bridge)
|
||||
}
|
||||
return result
|
||||
val bridge =
|
||||
when (val result = BridgeLine.parse(trimmed)) {
|
||||
is BridgeLineResult.Bad -> return AddResult.Unreadable(result.problem)
|
||||
is BridgeLineResult.Ok -> result
|
||||
}
|
||||
val all = lines
|
||||
val same =
|
||||
all.any { stored ->
|
||||
(BridgeLine.parse(stored.line) as? BridgeLineResult.Ok)?.bridge?.let {
|
||||
it.host.equals(bridge.bridge.host, ignoreCase = true) && it.port == bridge.bridge.port
|
||||
} ?: false
|
||||
}
|
||||
if (same) return AddResult.AlreadySaved
|
||||
if (all.size >= MAX_LINES) return AddResult.TooMany
|
||||
val next = all + StoredLine(trimmed, System.currentTimeMillis() / 1000, offeredBy, null)
|
||||
write(next)
|
||||
route(next)
|
||||
return AddResult.Added(bridge.bridge, bridge.caveat)
|
||||
}
|
||||
|
||||
/** Stop using a bridge, and drop the connections that were using one. */
|
||||
/**
|
||||
* Removes a line. Connections it carried close at once (§24.7). Removing
|
||||
* the last turns bridges off.
|
||||
*/
|
||||
fun remove(line: String) {
|
||||
val next = lines.filter { it.line != line }
|
||||
write(next)
|
||||
route(next)
|
||||
}
|
||||
|
||||
/** Stop using bridges altogether. */
|
||||
fun clear() {
|
||||
bridgeLine = null
|
||||
Obfs4Routing.use(null)
|
||||
write(emptyList())
|
||||
prefs.edit().remove(BRIDGE_LINE).apply()
|
||||
Obfs4Routing.use(emptyList())
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the stored line into force.
|
||||
*/
|
||||
fun applyAtStartup() {
|
||||
val line = bridgeLine ?: return
|
||||
when (val result = BridgeLine.parse(line)) {
|
||||
is BridgeLineResult.Ok -> {
|
||||
Obfs4Routing.use(result.bridge)
|
||||
}
|
||||
|
||||
is BridgeLineResult.Bad -> {
|
||||
bridgeLine = null
|
||||
Obfs4Routing.use(null)
|
||||
}
|
||||
}
|
||||
/** Records that a line carried a connection that reached the far end. */
|
||||
fun markWorked(bridge: Obfs4Bridge) {
|
||||
val all = lines
|
||||
val i =
|
||||
all.indexOfFirst { (BridgeLine.parse(it.line) as? BridgeLineResult.Ok)?.bridge == bridge }
|
||||
if (i < 0) return
|
||||
write(all.toMutableList().also { it[i] = it[i].copy(lastWorked = System.currentTimeMillis() / 1000) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a bridge a contact offered.
|
||||
*/
|
||||
fun accept(offeredLine: String): BridgeLineResult = save(offeredLine)
|
||||
/** Puts the stored lines into force. */
|
||||
fun applyAtStartup() = route(lines)
|
||||
|
||||
/** Accept a bridge a contact offered: it joins the list. */
|
||||
fun accept(
|
||||
offeredLine: String,
|
||||
from: String,
|
||||
): AddResult = add(offeredLine, offeredBy = from)
|
||||
|
||||
private companion object {
|
||||
const val PREFS = "flamenet.obfs4"
|
||||
const val BRIDGE_LINE = "bridge-line"
|
||||
const val LINES = "bridge-lines"
|
||||
const val MAX_LINES = 8
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,13 @@ object ParentalControls {
|
||||
|
||||
fun callsDisabled(context: Context) = isEnabled(context) && prefs(context).getBoolean(CALLS_DISABLED, false)
|
||||
|
||||
/**
|
||||
* The Today tab and the small-web directory behind it: hidden whenever
|
||||
* supervision is on (DESIGN 31). Headlines and links to the open web are
|
||||
* not a gate a parent releases one by one.
|
||||
*/
|
||||
fun todayHidden(context: Context) = isEnabled(context)
|
||||
|
||||
fun setGate(
|
||||
context: Context,
|
||||
key: String,
|
||||
|
||||
@@ -20,8 +20,10 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.messenger.R
|
||||
|
||||
/**
|
||||
* Parental controls on the Account screen: a parent's PIN guarding what this
|
||||
@@ -41,16 +43,12 @@ fun ParentalControlsSection() {
|
||||
@Suppress("UNUSED_EXPRESSION")
|
||||
tick
|
||||
|
||||
Text("Parental controls", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.set_parental_title), style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
when {
|
||||
!ParentalControls.isEnabled(context) -> {
|
||||
Text(
|
||||
"A parent sets a PIN on this phone. While supervision is on, " +
|
||||
"adding contacts asks for the PIN, and stories and calls can " +
|
||||
"be switched off, all enforced on this device. Nothing about " +
|
||||
"supervision is sent to any server. Use it alongside Family " +
|
||||
"Link, which governs the phone itself.",
|
||||
stringResource(R.string.set_parental_intro),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -58,7 +56,7 @@ fun ParentalControlsSection() {
|
||||
OutlinedTextField(
|
||||
value = newPin,
|
||||
onValueChange = { newPin = it },
|
||||
placeholder = { Text("Parent PIN (at least 4 digits)", fontSize = 14.sp) },
|
||||
placeholder = { Text(stringResource(R.string.set_parental_pin_placeholder), fontSize = 14.sp) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth().testTag("parental-pin"),
|
||||
@@ -67,7 +65,7 @@ fun ParentalControlsSection() {
|
||||
OutlinedTextField(
|
||||
value = repeatPin,
|
||||
onValueChange = { repeatPin = it },
|
||||
placeholder = { Text("Repeat PIN", fontSize = 14.sp) },
|
||||
placeholder = { Text(stringResource(R.string.set_parental_repeat_pin), fontSize = 14.sp) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth().testTag("parental-pin-repeat"),
|
||||
@@ -83,12 +81,12 @@ fun ParentalControlsSection() {
|
||||
},
|
||||
enabled = newPin.length >= 4 && newPin == repeatPin,
|
||||
modifier = Modifier.testTag("parental-enable"),
|
||||
) { Text("Turn on supervision") }
|
||||
) { Text(stringResource(R.string.set_parental_turn_on)) }
|
||||
}
|
||||
|
||||
!unlocked -> {
|
||||
Text(
|
||||
"Supervision is on. The settings behind this are the parent's to change.",
|
||||
stringResource(R.string.set_parental_on_note),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -99,7 +97,7 @@ fun ParentalControlsSection() {
|
||||
pinEntry = it
|
||||
wrong = false
|
||||
},
|
||||
placeholder = { Text("Parent PIN", fontSize = 14.sp) },
|
||||
placeholder = { Text(stringResource(R.string.set_parental_pin), fontSize = 14.sp) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
isError = wrong,
|
||||
@@ -107,7 +105,7 @@ fun ParentalControlsSection() {
|
||||
)
|
||||
if (wrong) {
|
||||
Text(
|
||||
"That's not the PIN.",
|
||||
stringResource(R.string.set_parental_wrong_pin),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
@@ -120,17 +118,16 @@ fun ParentalControlsSection() {
|
||||
},
|
||||
enabled = pinEntry.isNotEmpty(),
|
||||
modifier = Modifier.testTag("parental-unlock"),
|
||||
) { Text("Unlock") }
|
||||
) { Text(stringResource(R.string.common_unlock)) }
|
||||
}
|
||||
|
||||
else -> {
|
||||
Column {
|
||||
GateRow("Ask the PIN to add a contact", ParentalControls.CONTACTS_GATED) { tick++ }
|
||||
GateRow("Hide stories", ParentalControls.STORIES_HIDDEN) { tick++ }
|
||||
GateRow("Turn off calls", ParentalControls.CALLS_DISABLED) { tick++ }
|
||||
GateRow(stringResource(R.string.set_parental_gate_contacts), ParentalControls.CONTACTS_GATED) { tick++ }
|
||||
GateRow(stringResource(R.string.set_parental_gate_stories), ParentalControls.STORIES_HIDDEN) { tick++ }
|
||||
GateRow(stringResource(R.string.set_parental_gate_calls), ParentalControls.CALLS_DISABLED) { tick++ }
|
||||
Text(
|
||||
"With calls off, this phone neither places calls nor rings, a " +
|
||||
"caller is answered busy.",
|
||||
stringResource(R.string.set_parental_calls_off_note),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -142,7 +139,7 @@ fun ParentalControlsSection() {
|
||||
tick++
|
||||
},
|
||||
modifier = Modifier.testTag("parental-disable"),
|
||||
) { Text("Turn off supervision") }
|
||||
) { Text(stringResource(R.string.set_parental_turn_off)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -28,24 +30,26 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.messenger.R
|
||||
|
||||
/**
|
||||
* Stickers, from the shipped pack.
|
||||
* Stickers, one section per shipped pack, in manifest order.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val stickers = StickerPack.all(context)
|
||||
val packs = StickerPack.packs(context).filter { it.stickers.isNotEmpty() }
|
||||
|
||||
if (stickers.isEmpty()) {
|
||||
// The manifest failed to load.
|
||||
if (packs.isEmpty()) {
|
||||
// No manifest would load.
|
||||
Text(
|
||||
"Stickers could not be loaded on this device.",
|
||||
stringResource(R.string.main_stickers_failed),
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.padding(Kit.gutter),
|
||||
@@ -58,7 +62,7 @@ fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) {
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 220.dp)
|
||||
.heightIn(max = 260.dp)
|
||||
.clip(RoundedCornerShape(Radius.large))
|
||||
.background(Palette.SurfaceAlt)
|
||||
.testTag("sticker-picker"),
|
||||
@@ -66,23 +70,66 @@ fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) {
|
||||
androidx.compose.foundation.layout
|
||||
.PaddingValues(Space.sm),
|
||||
) {
|
||||
items(stickers, key = { it.id }) { sticker ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(Space.xs)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.clickable { onPick(sticker) }
|
||||
.semantics { contentDescription = sticker.name }
|
||||
.testTag("sticker-pick-${sticker.id}"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(sticker.glyph, fontSize = 34.sp, modifier = Modifier.padding(Space.sm))
|
||||
packs.forEach { pack ->
|
||||
item(key = "pack-${pack.id}", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Text(
|
||||
pack.name,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Palette.SecondaryText,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = Space.xs, vertical = Space.xs)
|
||||
.testTag("sticker-pack-${pack.id}"),
|
||||
)
|
||||
}
|
||||
items(pack.stickers, key = { "${it.pack}/${it.id}" }) { sticker ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(Space.xs)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.clickable { onPick(sticker) }
|
||||
.semantics { contentDescription = sticker.name }
|
||||
.testTag("sticker-pick-${sticker.id}"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
StickerArt(StickerPack.Resolved(sticker.image, sticker.glyph, sticker.name), size = 48.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One sticker as drawn: its picture when the pack ships one and it loads,
|
||||
* otherwise its glyph. Described by the caller, so this adds no label.
|
||||
*/
|
||||
@Composable
|
||||
fun StickerArt(
|
||||
sticker: StickerPack.Resolved,
|
||||
size: androidx.compose.ui.unit.Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val picture = remember(sticker.image) { sticker.image?.let { StickerPack.image(context, it) } }
|
||||
if (picture != null) {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = picture,
|
||||
contentDescription = null,
|
||||
modifier = modifier.size(size),
|
||||
)
|
||||
} else {
|
||||
// Glyphs are set in sp at about two thirds of the box, which is how
|
||||
// the old 64sp sticker sat in its space.
|
||||
Text(
|
||||
sticker.glyph,
|
||||
fontSize = (size.value * 0.68f).sp,
|
||||
modifier = modifier.padding(Space.xs),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emoji, straight into the draft.
|
||||
*/
|
||||
@@ -116,13 +163,16 @@ fun ColumnScope.EmojiPicker(onPick: (String) -> Unit) {
|
||||
.testTag("emoji-picker"),
|
||||
verticalArrangement = Arrangement.spacedBy(Space.xs),
|
||||
) {
|
||||
if (recents.isNotEmpty()) EmojiSection("Recent", recents, ::pick)
|
||||
EMOJI_GROUPS.forEach { (name, emoji) -> EmojiSection(name, emoji, ::pick) }
|
||||
if (recents.isNotEmpty()) EmojiSection("Recent", stringResource(R.string.main_emoji_recent), recents, ::pick)
|
||||
EMOJI_GROUPS.forEach { (name, emoji) -> EmojiSection(name, stringResource(emojiGroupLabel(name)), emoji, ::pick) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiSection(
|
||||
// The English group name, kept for the test tag so tests do not depend
|
||||
// on the device language.
|
||||
tag: String,
|
||||
title: String,
|
||||
emoji: List<String>,
|
||||
onPick: (String) -> Unit,
|
||||
@@ -149,7 +199,7 @@ private fun EmojiSection(
|
||||
// Scoped by section, as on iOS: the same emoji can sit
|
||||
// in Recent AND its own group, and a tag on the glyph
|
||||
// alone is ambiguous to a test.
|
||||
.testTag("emoji-$title-$e"),
|
||||
.testTag("emoji-$tag-$e"),
|
||||
) {
|
||||
Text(e, fontSize = 24.sp, modifier = Modifier.padding(Space.xs))
|
||||
}
|
||||
@@ -157,6 +207,15 @@ private fun EmojiSection(
|
||||
}
|
||||
}
|
||||
|
||||
/** The shown name of an EMOJI_GROUPS entry. */
|
||||
private fun emojiGroupLabel(name: String): Int =
|
||||
when (name) {
|
||||
"Smileys" -> R.string.main_emoji_smileys
|
||||
"Gestures" -> R.string.main_emoji_gestures
|
||||
"Hearts" -> R.string.main_emoji_hearts
|
||||
else -> R.string.main_emoji_things
|
||||
}
|
||||
|
||||
/**
|
||||
* The same set as iOS, grouped the same way, kept in step on purpose.
|
||||
*/
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -26,11 +27,13 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.e2e.Poll
|
||||
import io.flamenet.e2e.PollBook
|
||||
import io.flamenet.messenger.R
|
||||
|
||||
/**
|
||||
* A poll in a group, and what it is waiting for (§6.29).
|
||||
@@ -62,10 +65,10 @@ fun ColumnScope.PollCard(
|
||||
// your vote and sees the tally, and subtracting gives it, with nobody
|
||||
// to collude with. The word is not offered for a pair.
|
||||
Text(
|
||||
if (poll.members.size > 2) "Anonymous poll" else "Poll",
|
||||
if (poll.members.size > 2) stringResource(R.string.social_poll_anonymous) else stringResource(R.string.social_poll),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Palette.SecondaryText,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(poll.question, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
||||
|
||||
@@ -89,15 +92,15 @@ fun ColumnScope.PollCard(
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
"You chose ${poll.options[poll.myChoice ?: 0]}.",
|
||||
stringResource(R.string.social_poll_you_chose, poll.options[poll.myChoice ?: 0]),
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
waitingLine(poll, standing, name),
|
||||
fontSize = 13.sp,
|
||||
color = Palette.SecondaryText,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -105,6 +108,7 @@ fun ColumnScope.PollCard(
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun waitingLine(
|
||||
poll: PollBook.Entry,
|
||||
standing: PollBook.Standing,
|
||||
@@ -113,27 +117,28 @@ private fun waitingLine(
|
||||
when (standing) {
|
||||
is PollBook.Standing.WaitingForVotes ->
|
||||
if (poll.myChoice == null) {
|
||||
"Nobody sees a count until everyone has voted."
|
||||
stringResource(R.string.social_poll_hidden_until_all_voted)
|
||||
} else {
|
||||
"Waiting for ${list(standing.from, name)} to vote."
|
||||
stringResource(R.string.social_poll_waiting_for_votes, list(standing.from, name))
|
||||
}
|
||||
|
||||
is PollBook.Standing.WaitingForSums ->
|
||||
"Everyone voted. Counting, waiting for ${list(standing.from, name)}."
|
||||
stringResource(R.string.social_poll_waiting_for_sums, list(standing.from, name))
|
||||
|
||||
is PollBook.Standing.Counted -> ""
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun list(
|
||||
ids: List<Long>,
|
||||
name: (Long) -> String,
|
||||
): String {
|
||||
val names = ids.map(name)
|
||||
return when (names.size) {
|
||||
0 -> "nobody"
|
||||
0 -> stringResource(R.string.social_poll_nobody)
|
||||
1 -> names[0]
|
||||
2 -> "${names[0]} and ${names[1]}"
|
||||
else -> names.dropLast(1).joinToString(", ") + " and " + names.last()
|
||||
2 -> stringResource(R.string.social_poll_name_pair, names[0], names[1])
|
||||
else -> stringResource(R.string.social_poll_name_pair, names.dropLast(1).joinToString(", "), names.last())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,14 +179,12 @@ private fun ColumnScope.Results(
|
||||
// The guarantee, stated as it actually is.
|
||||
Text(
|
||||
if (poll.members.size > 2) {
|
||||
"Nobody was sent anybody's vote. Everyone else here would have to " +
|
||||
"work together to know how you voted."
|
||||
stringResource(R.string.social_poll_guarantee_group)
|
||||
} else {
|
||||
"Nobody was sent your vote, but in a poll of two the other person " +
|
||||
"can work it out from the result."
|
||||
stringResource(R.string.social_poll_guarantee_pair)
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -201,13 +204,13 @@ fun NewPollDialog(
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("New poll") },
|
||||
title = { Text(stringResource(R.string.social_poll_new)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||
OutlinedTextField(
|
||||
value = question,
|
||||
onValueChange = { question = it },
|
||||
label = { Text("Ask something") },
|
||||
label = { Text(stringResource(R.string.social_poll_ask_something)) },
|
||||
singleLine = false,
|
||||
modifier = Modifier.fillMaxWidth().testTag("poll-question"),
|
||||
)
|
||||
@@ -217,34 +220,29 @@ fun NewPollDialog(
|
||||
onValueChange = { text ->
|
||||
options = options.toMutableList().also { it[i] = text }
|
||||
},
|
||||
label = { Text("Option ${i + 1}") },
|
||||
label = { Text(stringResource(R.string.social_poll_option, i + 1)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("poll-new-option-$i"),
|
||||
)
|
||||
}
|
||||
if (options.size < Poll.MAX_OPTIONS) {
|
||||
TextButton(onClick = { options = options + "" }) {
|
||||
Text("Add an option")
|
||||
Text(stringResource(R.string.social_poll_add_option))
|
||||
}
|
||||
}
|
||||
// Said before the poll is asked, not after, because it is what
|
||||
// somebody is deciding on.
|
||||
Text(
|
||||
when {
|
||||
members < 2 -> "A poll needs somebody else in the group."
|
||||
members < 2 -> stringResource(R.string.social_poll_needs_members)
|
||||
members == 2 ->
|
||||
"In a group of two this is not anonymous: the other " +
|
||||
"person can work out your vote from the result. " +
|
||||
"It is still a poll."
|
||||
stringResource(R.string.social_poll_pair_note)
|
||||
|
||||
else ->
|
||||
"Nobody is sent anybody's vote, not even you as the " +
|
||||
"person asking. Everyone other than the voter would " +
|
||||
"have to work together to know how one person voted. " +
|
||||
"Everyone has to vote before there is a result."
|
||||
stringResource(R.string.social_poll_group_note)
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -256,9 +254,9 @@ fun NewPollDialog(
|
||||
onDismiss()
|
||||
},
|
||||
modifier = Modifier.testTag("poll-ask"),
|
||||
) { Text("Ask") }
|
||||
) { Text(stringResource(R.string.social_poll_ask)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } },
|
||||
)
|
||||
Spacer(Modifier.width(0.dp))
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ fun ChannelHeader(
|
||||
.padding(start = 4.dp, end = 4.dp, top = 8.dp, bottom = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
PortalHeaderIcon(KitIcon.Back, "Back", onBack, Modifier.testTag("back"))
|
||||
PortalHeaderIcon(KitIcon.Back, androidx.compose.ui.res.stringResource(io.flamenet.messenger.R.string.common_back), onBack, Modifier.testTag("back"))
|
||||
if (leading != null) {
|
||||
leading()
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ class SafReferences(
|
||||
|
||||
override fun read(bookmark: String): ByteArray =
|
||||
context.contentResolver.openInputStream(Uri.parse(bookmark))?.use { it.readBytes() }
|
||||
?: throw LocalVault.VaultException("That file isn't where it was, it may have been moved or deleted.")
|
||||
?: throw LocalVault.VaultException(context.getString(io.flamenet.messenger.R.string.main_files_file_gone))
|
||||
|
||||
/**
|
||||
* Zero when the provider will not say, which is a real answer and not a
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.e2e.Vault
|
||||
import io.flamenet.messenger.R
|
||||
|
||||
/**
|
||||
* Two things the iPhone app started saying on 2026-09-23 (audit F2 and F5),
|
||||
* said here too. Both were facts the app already had and kept to itself.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The address this device had before this sign-in derived a different one, or
|
||||
* null, which is the ordinary case (finding F5).
|
||||
*
|
||||
* The account key comes from the passphrase and a salt the account service
|
||||
* holds and hands back at every sign-in. It is never written to this phone. So
|
||||
* that service can serve a different salt, the phone derives a different fnid,
|
||||
* and the same service verifies it: the sign-in succeeds and the person now
|
||||
* holds a different identity. The new fnid used to replace the old one in the
|
||||
* saved credential without anything comparing the two, which made the
|
||||
* substitution silent, and silence is what makes it worth doing.
|
||||
*
|
||||
* Only for the SAME account on the SAME relay. A different account on a shared
|
||||
* phone is a different person, not a changed address.
|
||||
*/
|
||||
object AddressChange {
|
||||
fun previousAddress(
|
||||
previous: Vault.Credential?,
|
||||
sameAccount: Boolean,
|
||||
fnid: String?,
|
||||
host: String,
|
||||
): String? {
|
||||
if (previous == null || !sameAccount) return null
|
||||
val was = previous.fnid ?: return null
|
||||
// No new address is not a changed one: it means this sign-in could not
|
||||
// derive one, and AddressCard already says so.
|
||||
val now = fnid ?: return null
|
||||
if (was == now) return null
|
||||
return "$was@$host"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversations where a send fell back from sealed to identified, and when
|
||||
* (SPEC §14.5, finding F2).
|
||||
*
|
||||
* §14.5: "a user who has enabled sealed sending SHOULD be told when a message
|
||||
* could not be sent sealed." The sealing record in Privacy settings counts
|
||||
* the fallback, which answers "how often" and never "did this one".
|
||||
*
|
||||
* In memory only, as on iOS. The fallback is a fact about a moment, the notice
|
||||
* exists to be seen at that moment, and the running total already carries it
|
||||
* across restarts. A later sealed send does not clear it: the message that went
|
||||
* identified still did.
|
||||
*/
|
||||
object UnsealedSends {
|
||||
fun record(
|
||||
seen: Map<Long, Long>,
|
||||
peerUserId: Long,
|
||||
wasSealed: Boolean,
|
||||
at: Long,
|
||||
): Map<Long, Long> = if (wasSealed) seen else seen + (peerUserId to at)
|
||||
}
|
||||
|
||||
/**
|
||||
* Said in the conversation where it happened. A note, not an alarm: nothing
|
||||
* was readable by anyone, and the usual cause is the other person reinstalling.
|
||||
* Same words as the iPhone app.
|
||||
*/
|
||||
@Composable
|
||||
fun UnsealedSendNotice(name: String) {
|
||||
Surface(
|
||||
color = Palette.SurfaceAlt,
|
||||
shape = RoundedCornerShape(Radius.medium),
|
||||
modifier = Modifier.fillMaxWidth().testTag("unsealed-send"),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(Space.md)) {
|
||||
Text(
|
||||
stringResource(R.string.social_unsealed_title),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.social_unsealed_body, name),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Beside the address it replaced. Same words as the iPhone app. */
|
||||
@Composable
|
||||
fun AddressChangedNotice(was: String) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(bottom = Space.md).testTag("address-changed")) {
|
||||
Text(
|
||||
stringResource(R.string.social_address_changed_title),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.social_address_changed_body, was),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -14,52 +14,58 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import io.flamenet.messenger.R
|
||||
|
||||
/**
|
||||
* Point the camera at somebody's address, the other half of [AddressCard].
|
||||
*/
|
||||
|
||||
/** What a valid code carries. `handle` only when the QR's author included it. */
|
||||
/** What a valid code carries: an address, and nothing else is taken from it. */
|
||||
internal data class ScannedAddress(
|
||||
val fnid: String,
|
||||
val relay: String,
|
||||
val handle: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* `flamenet:<fnid>@<relay>[?u=<handle>&k=…]`, or the bare `fnid@relay` somebody
|
||||
* retypes.
|
||||
* A hello link (§6.34), which is what an Address screen's code holds now; the
|
||||
* bare `fnid@relay` somebody retypes; or `flamenet:<fnid>@<relay>[?u=<handle>&k=…]`,
|
||||
* the card older builds draw, whose query is ignored. Null for anything else,
|
||||
* a code that only looks like an address included.
|
||||
*/
|
||||
internal fun parseScannedAddress(text: String): ScannedAddress? {
|
||||
HelloLink.address(text)?.let { address ->
|
||||
val at = address.indexOf('@')
|
||||
return ScannedAddress(address.substring(0, at), address.substring(at + 1))
|
||||
}
|
||||
var s = text.trim()
|
||||
if (s.lowercase().startsWith("flamenet:")) s = s.substring("flamenet:".length)
|
||||
var handle: String? = null
|
||||
val q = s.indexOf('?')
|
||||
if (q >= 0) {
|
||||
s.substring(q + 1).split('&').forEach { pair ->
|
||||
if (pair.startsWith("u=")) handle = pair.substring(2)
|
||||
}
|
||||
s = s.substring(0, q)
|
||||
}
|
||||
val at = s.indexOf('@')
|
||||
if (at <= 0 || at == s.length - 1) return null
|
||||
val fnid = s.substring(0, at).trim()
|
||||
val relay = s.substring(at + 1).trim().lowercase()
|
||||
if (fnid.isEmpty() || relay.isEmpty() || !relay.contains('.')) return null
|
||||
// A relay is a hostname.
|
||||
if (relay.contains('/') || relay.contains(':')) return null
|
||||
return ScannedAddress(fnid, relay, handle?.takeIf { it.isNotBlank() })
|
||||
s = s.substringBefore('?')
|
||||
// The same rule as a hello link, so a code holding `mailto:someone@host`
|
||||
// or a web address with an @ in it is not taken for a person.
|
||||
val address = HelloLink.canonical(s.trim()) ?: return null
|
||||
val at = address.indexOf('@')
|
||||
return ScannedAddress(address.substring(0, at), address.substring(at + 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* What an add screen is filled with for a scanned code, on Home and on
|
||||
* Messages alike: the address, bound by the fnid and never by a handle the
|
||||
* code also names (audit LOW, DESIGN 32). Null when the code holds no address.
|
||||
*/
|
||||
internal fun prefillFor(scanned: String): String? = parseScannedAddress(scanned)?.let { "${it.fnid}@${it.relay}" }
|
||||
|
||||
/** Full-screen camera dialog. Calls [onFound] once with the raw decoded text. */
|
||||
@Composable
|
||||
fun ScanAddressDialog(
|
||||
onFound: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
hint: String = stringResource(R.string.social_scan_point_at_code),
|
||||
denied: String = stringResource(R.string.social_scan_camera_denied),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val granted =
|
||||
@@ -77,8 +83,7 @@ fun ScanAddressDialog(
|
||||
// Says what to do, not just that it failed.
|
||||
Column(modifier = Modifier.align(Alignment.Center).padding(24.dp)) {
|
||||
Text(
|
||||
"Flamenet cannot use the camera. Allow it in Android " +
|
||||
"settings, or paste their address into Add contact instead.",
|
||||
denied,
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.testTag("scan-denied"),
|
||||
@@ -95,7 +100,7 @@ fun ScanAddressDialog(
|
||||
onRelease = { view -> (view.tag as? QrScan)?.stop() },
|
||||
)
|
||||
Text(
|
||||
"Point at the code on their Address screen.",
|
||||
hint,
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
modifier =
|
||||
@@ -111,7 +116,7 @@ fun ScanAddressDialog(
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 24.dp, end = 8.dp)
|
||||
.testTag("scan-cancel"),
|
||||
) { Text("Cancel", color = Color.White) }
|
||||
) { Text(stringResource(R.string.common_cancel), color = Color.White) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,4 +84,20 @@ data class SiteConfig(
|
||||
"This server keeps its own accounts."
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same four sentences as [identityDescription], as string resources,
|
||||
* so the sign-in screen shows them in the phone's language. The English
|
||||
* property stays because SiteConfigIdentityTest pins it without a Context;
|
||||
* the branches here must stay in step with it.
|
||||
*/
|
||||
@get:androidx.annotation.StringRes
|
||||
val identityDescriptionRes: Int
|
||||
get() =
|
||||
when {
|
||||
probeFailed -> io.flamenet.messenger.R.string.main_identity_unreachable
|
||||
discovered == null -> io.flamenet.messenger.R.string.main_identity_checking
|
||||
accountProvider == AccountProvider.SHARED -> io.flamenet.messenger.R.string.main_identity_shared
|
||||
else -> io.flamenet.messenger.R.string.main_identity_local
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +1,133 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* The shipped sticker set, read from the same manifest the iOS app reads.
|
||||
* The shipped sticker packs, read from the same manifests the iOS app reads.
|
||||
*
|
||||
* Every manifest under `assets/stickers/` is a pack: the glyph set first, then
|
||||
* the FlameNet art, then anything else by id. A sticker draws as its image when
|
||||
* the pack ships one, as its glyph when it does not or the image will not load,
|
||||
* and as the pack's fallback when this build does not know the id. A pack this
|
||||
* build does not have at all draws as the core fallback (docs/STICKERS.md).
|
||||
*/
|
||||
object StickerPack {
|
||||
data class Sticker(
|
||||
val pack: String,
|
||||
val id: String,
|
||||
val glyph: String,
|
||||
val name: String,
|
||||
/** A file beside the manifest, `stickers/<pack>/<image>`, or null. */
|
||||
val image: String? = null,
|
||||
)
|
||||
|
||||
data class Pack(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val fallback: String,
|
||||
val stickers: List<Sticker>,
|
||||
)
|
||||
|
||||
/** What one reference draws as. */
|
||||
data class Resolved(
|
||||
/** The asset path of the picture, or null to draw [glyph]. */
|
||||
val image: String?,
|
||||
val glyph: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
const val CORE = "flamenet.core"
|
||||
const val RETRO = "flamenet.retro"
|
||||
|
||||
private var loaded: Map<String, Sticker>? = null
|
||||
private var order: List<Sticker> = emptyList()
|
||||
private var fallbackGlyph = "🙂"
|
||||
/** Manifest order in the picker: the glyphs, then the art. */
|
||||
private val PICKER_ORDER = listOf(CORE, RETRO)
|
||||
|
||||
/** Every sticker in the shipped pack, in manifest order. */
|
||||
fun all(context: Context): List<Sticker> {
|
||||
ensure(context)
|
||||
return order
|
||||
}
|
||||
private const val DEFAULT_FALLBACK = "\uD83D\uDE42"
|
||||
|
||||
/**
|
||||
* What to draw for a reference.
|
||||
*/
|
||||
fun glyph(
|
||||
context: Context,
|
||||
pack: String,
|
||||
id: String,
|
||||
): String {
|
||||
ensure(context)
|
||||
if (pack != CORE) return fallbackGlyph
|
||||
return loaded?.get(id)?.glyph ?: fallbackGlyph
|
||||
}
|
||||
@Volatile private var loaded: List<Pack>? = null
|
||||
|
||||
fun name(
|
||||
/** Every pack, in picker order. Empty when no manifest would load. */
|
||||
fun packs(context: Context): List<Pack> =
|
||||
loaded ?: synchronized(this) {
|
||||
loaded ?: load(context).also { loaded = it }
|
||||
}
|
||||
|
||||
/** What to draw for a reference. */
|
||||
fun resolve(
|
||||
context: Context,
|
||||
pack: String,
|
||||
id: String,
|
||||
): String {
|
||||
ensure(context)
|
||||
if (pack != CORE) return "Sticker"
|
||||
return loaded?.get(id)?.name ?: "Sticker"
|
||||
}
|
||||
): Resolved = resolve(packs(context), pack, id)
|
||||
|
||||
private fun ensure(context: Context) {
|
||||
if (loaded != null) return
|
||||
// Failure is empty, not a crash.
|
||||
val text =
|
||||
runCatching {
|
||||
context.assets
|
||||
.open("stickers/$CORE.json")
|
||||
.bufferedReader()
|
||||
.use { it.readText() }
|
||||
}.getOrNull() ?: run {
|
||||
loaded = emptyMap()
|
||||
return
|
||||
}
|
||||
/** The image for a resolved sticker, or null when there is none or it will not decode. */
|
||||
fun image(
|
||||
context: Context,
|
||||
path: String,
|
||||
): ImageBitmap? = AssetImages.load(context, path)
|
||||
|
||||
val json =
|
||||
runCatching { JSONObject(text) }.getOrNull()
|
||||
?: run {
|
||||
loaded = emptyMap()
|
||||
return
|
||||
private fun load(context: Context): List<Pack> {
|
||||
// Failure is an empty list, never a crash: the picker says so.
|
||||
val names = runCatching { context.assets.list("stickers")?.toList() }.getOrNull().orEmpty()
|
||||
val packs =
|
||||
names
|
||||
.filter { it.endsWith(".json") }
|
||||
.mapNotNull { file ->
|
||||
runCatching {
|
||||
context.assets.open("stickers/$file").bufferedReader().use { it.readText() }
|
||||
}.getOrNull()?.let(::parse)
|
||||
}
|
||||
return ordered(packs)
|
||||
}
|
||||
|
||||
// -- Pure, so the rules can be checked without a device --------------------.
|
||||
|
||||
fallbackGlyph = json.optString("fallback").ifBlank { "🙂" }
|
||||
/** One manifest, or null when it is not one. Stickers without an id are skipped. */
|
||||
fun parse(text: String): Pack? {
|
||||
val json = runCatching { JSONObject(text) }.getOrNull() ?: return null
|
||||
val id = json.optString("pack").takeIf { it.isNotBlank() } ?: return null
|
||||
val fallback = json.optString("fallback").ifBlank { DEFAULT_FALLBACK }
|
||||
val array = json.optJSONArray("stickers")
|
||||
val list = mutableListOf<Sticker>()
|
||||
for (i in 0 until (array?.length() ?: 0)) {
|
||||
val o = array!!.optJSONObject(i) ?: continue
|
||||
val id = o.optString("id")
|
||||
if (id.isBlank()) continue
|
||||
list +=
|
||||
val stickers =
|
||||
(0 until (array?.length() ?: 0)).mapNotNull { i ->
|
||||
val o = array!!.optJSONObject(i) ?: return@mapNotNull null
|
||||
val sid = o.optString("id").takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
Sticker(
|
||||
id = id,
|
||||
glyph = o.optString("glyph").ifBlank { fallbackGlyph },
|
||||
name = o.optString("name").ifBlank { id },
|
||||
pack = id,
|
||||
id = sid,
|
||||
glyph = o.optString("glyph").ifBlank { fallback },
|
||||
name = o.optString("name").ifBlank { sid },
|
||||
// Only a plain file name: a manifest must not point the
|
||||
// loader anywhere outside its own pack's folder.
|
||||
image =
|
||||
o
|
||||
.optString("image")
|
||||
.takeIf { it.isNotBlank() && '/' !in it && '\\' !in it && it != ".." }
|
||||
?.let { "stickers/$id/$it" },
|
||||
)
|
||||
}
|
||||
return Pack(id, json.optString("name").ifBlank { id }, fallback, stickers)
|
||||
}
|
||||
|
||||
/** Core first, then the art, then any other pack by id; one copy of each. */
|
||||
fun ordered(packs: List<Pack>): List<Pack> {
|
||||
val unique = packs.distinctBy { it.id }
|
||||
val known = PICKER_ORDER.mapNotNull { id -> unique.firstOrNull { it.id == id } }
|
||||
return known + unique.filter { it.id !in PICKER_ORDER }.sortedBy { it.id }
|
||||
}
|
||||
|
||||
fun resolve(
|
||||
packs: List<Pack>,
|
||||
pack: String,
|
||||
id: String,
|
||||
): Resolved {
|
||||
val p = packs.firstOrNull { it.id == pack }
|
||||
val s = p?.stickers?.firstOrNull { it.id == id }
|
||||
return when {
|
||||
s != null -> Resolved(s.image, s.glyph, s.name)
|
||||
p != null -> Resolved(null, p.fallback, "Sticker")
|
||||
else -> Resolved(null, packs.firstOrNull { it.id == CORE }?.fallback ?: DEFAULT_FALLBACK, "Sticker")
|
||||
}
|
||||
order = list
|
||||
loaded = list.associateBy { it.id }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,12 +34,15 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.e2e.Vault
|
||||
import io.flamenet.messenger.R
|
||||
|
||||
/**
|
||||
* Stories as a feed you scroll, which is the kit's Timeline.
|
||||
@@ -76,17 +79,17 @@ fun ColumnScope.StoriesFeed(
|
||||
|
||||
// A menu, not a picker.
|
||||
var choosing by remember { mutableStateOf(false) }
|
||||
PortalHeader(title = "Stories") {
|
||||
PortalHeader(title = stringResource(R.string.social_stories_title)) {
|
||||
Box {
|
||||
PortalHeaderIcon(
|
||||
icon = KitIcon.Add,
|
||||
contentDescription = "New story",
|
||||
contentDescription = stringResource(R.string.social_new_story),
|
||||
modifier = Modifier.testTag("new-story"),
|
||||
onClick = { choosing = true },
|
||||
)
|
||||
DropdownMenu(expanded = choosing, onDismissRequest = { choosing = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Photo or video") },
|
||||
text = { Text(stringResource(R.string.social_story_photo_or_video)) },
|
||||
onClick = {
|
||||
choosing = false
|
||||
pickStory.launch("*/*")
|
||||
@@ -94,7 +97,7 @@ fun ColumnScope.StoriesFeed(
|
||||
modifier = Modifier.testTag("new-story-photo"),
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Several photos") },
|
||||
text = { Text(stringResource(R.string.social_story_several_photos)) },
|
||||
onClick = {
|
||||
choosing = false
|
||||
pickStorySet.launch(
|
||||
@@ -107,7 +110,7 @@ fun ColumnScope.StoriesFeed(
|
||||
modifier = Modifier.testTag("new-story-set"),
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Just words") },
|
||||
text = { Text(stringResource(R.string.social_story_just_words)) },
|
||||
onClick = {
|
||||
choosing = false
|
||||
writingWords = true
|
||||
@@ -164,28 +167,27 @@ private fun StorySetComposerDialog(
|
||||
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { model.discardStagedStorySet() },
|
||||
title = { Text("New story") },
|
||||
title = { Text(stringResource(R.string.social_new_story)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||
Text(
|
||||
"${items.size} pictures, posted as one story. The caption goes " +
|
||||
"on the first.",
|
||||
pluralStringResource(R.plurals.social_story_set_summary, items.size, items.size),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.testTag("story-set-summary"),
|
||||
)
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = caption.value,
|
||||
onValueChange = { caption.value = it },
|
||||
label = { Text("Say something (optional)") },
|
||||
label = { Text(stringResource(R.string.social_story_say_something_optional)) },
|
||||
singleLine = false,
|
||||
modifier = Modifier.fillMaxWidth().testTag("story-set-caption"),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs)) {
|
||||
listOf(
|
||||
Session.AudienceMode.EVERYONE to "Everyone",
|
||||
Session.AudienceMode.ONLY to "Only…",
|
||||
Session.AudienceMode.EXCEPT to "All except…",
|
||||
Session.AudienceMode.EVERYONE to stringResource(R.string.social_audience_everyone),
|
||||
Session.AudienceMode.ONLY to stringResource(R.string.social_audience_only),
|
||||
Session.AudienceMode.EXCEPT to stringResource(R.string.social_audience_all_except),
|
||||
).forEach { (m, label) ->
|
||||
androidx.compose.material3.FilterChip(
|
||||
selected = mode.value == m,
|
||||
@@ -228,12 +230,12 @@ private fun StorySetComposerDialog(
|
||||
)
|
||||
},
|
||||
modifier = Modifier.testTag("story-set-post"),
|
||||
) { Text("Post") }
|
||||
) { Text(stringResource(R.string.social_post)) }
|
||||
},
|
||||
dismissButton = {
|
||||
androidx.compose.material3.TextButton(
|
||||
onClick = { model.discardStagedStorySet() },
|
||||
) { Text("Cancel") }
|
||||
) { Text(stringResource(R.string.common_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -253,21 +255,24 @@ private fun StoryCard(
|
||||
*/
|
||||
setCount: Int = 1,
|
||||
) {
|
||||
val youLabel = stringResource(R.string.common_you)
|
||||
val unknownAuthor = stringResource(R.string.social_user_fallback, story.authorId)
|
||||
val author =
|
||||
remember(story.authorId, phase.buddies) {
|
||||
phase.buddies
|
||||
.firstOrNull { it.userId == story.authorId }
|
||||
?.let { it.displayName.ifBlank { it.handle } }
|
||||
?: if (story.mine) "You" else "user ${story.authorId}"
|
||||
?: if (story.mine) youLabel else unknownAuthor
|
||||
}
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val remaining =
|
||||
remember(story.id) {
|
||||
val seconds = story.expires - System.currentTimeMillis() / 1000.0
|
||||
when {
|
||||
story.expires <= 0L -> "no expiry"
|
||||
seconds <= 0 -> "expired"
|
||||
seconds < 3600 -> "${(seconds / 60).toInt()} min left"
|
||||
else -> "${(seconds / 3600).toInt()} h left"
|
||||
story.expires <= 0L -> context.getString(R.string.social_story_no_expiry)
|
||||
seconds <= 0 -> context.getString(R.string.social_story_expired)
|
||||
seconds < 3600 -> context.getString(R.string.social_story_minutes_left, (seconds / 60).toInt())
|
||||
else -> context.getString(R.string.social_story_hours_left, (seconds / 3600).toInt())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,10 +303,23 @@ private fun StoryCard(
|
||||
// The deadline, in the slot the kit gives a description, and
|
||||
// §6.23's count beside it: a card that silently stands for
|
||||
// five pictures is a card that loses four.
|
||||
// §6.15: a story whose words changed says so.
|
||||
val edited = if (story.editedAt != null) " · Edited" else ""
|
||||
Text(
|
||||
if (setCount > 1) "$remaining · $setCount pictures" else remaining,
|
||||
(
|
||||
if (setCount > 1) {
|
||||
pluralStringResource(
|
||||
R.plurals.social_story_remaining_with_count,
|
||||
setCount,
|
||||
remaining,
|
||||
setCount,
|
||||
)
|
||||
} else {
|
||||
remaining
|
||||
}
|
||||
) + edited,
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.testTag("story-set-count-${story.id}"),
|
||||
)
|
||||
}
|
||||
@@ -321,39 +339,32 @@ private fun StoryCard(
|
||||
|
||||
Spacer(Modifier.height(Space.lg))
|
||||
|
||||
// The image. 375x320 in the kit; an aspect ratio here, because the kit
|
||||
// is drawn on one canvas and this has to hold on every phone.
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(240.dp)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.SurfaceAlt),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// The card's own image, cropped to FILL the box, not the chat
|
||||
// bubble's 260dp thumbnail, which floated small on a full-width
|
||||
// backboard and read as a broken layout. §6.13, an embedded live
|
||||
// stream.
|
||||
val stream = story.streamUrl
|
||||
val streamHost = story.streamHost
|
||||
if (stream != null && streamHost != null &&
|
||||
// DESIGN item 27: the whole picture, never cut. The card takes the
|
||||
// picture's own shape between a floor and a ceiling (`StoryFrame`),
|
||||
// with plain bars past either. §6.13, an embedded live stream, sits in
|
||||
// the frame of its poster.
|
||||
val stream = story.streamUrl
|
||||
val streamHost = story.streamHost
|
||||
val ref = story.ref
|
||||
when {
|
||||
stream != null && streamHost != null &&
|
||||
io.flamenet.e2e.StreamDisclosure
|
||||
.acceptable(stream)
|
||||
) {
|
||||
StoryStreamGate(stream, streamHost)
|
||||
} else {
|
||||
val ref = story.ref
|
||||
when {
|
||||
// Words alone, set large on a tinted band, the way every
|
||||
// other messenger draws one.
|
||||
ref == null -> TextStoryCard(story.caption)
|
||||
.acceptable(stream) -> {
|
||||
StoryPhotoFrame(null, ref?.width ?: 0, ref?.height ?: 0) { StoryStreamGate(stream, streamHost) }
|
||||
}
|
||||
|
||||
ref.mime.startsWith("video/") -> StoryVideoCard(ref, model)
|
||||
// Words alone, set large on a tinted band, the way every other
|
||||
// messenger draws one.
|
||||
ref == null -> {
|
||||
TextStoryCard(story.caption)
|
||||
}
|
||||
|
||||
else -> StoryImage(ref, model)
|
||||
}
|
||||
ref.mime.startsWith("video/") -> {
|
||||
StoryPhotoFrame(null, ref.width, ref.height) { StoryVideoCard(ref, model) }
|
||||
}
|
||||
|
||||
else -> {
|
||||
StoryImage(ref, model)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,10 +404,12 @@ private fun StoryActions(
|
||||
modifier = Modifier.testTag("story-tally"),
|
||||
) {
|
||||
tally.entries.sortedBy { it.key }.forEach { (emoji, count) ->
|
||||
Text("$emoji $count", fontSize = 13.sp, color = Palette.SecondaryText)
|
||||
Text("$emoji $count", fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
}
|
||||
MyStoryControls(story, model)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -431,7 +444,7 @@ private fun StoryActions(
|
||||
}
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Text(
|
||||
"Reply",
|
||||
stringResource(R.string.social_story_reply),
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
color = Palette.Accent,
|
||||
@@ -439,10 +452,10 @@ private fun StoryActions(
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Text(
|
||||
"Mute",
|
||||
stringResource(R.string.common_mute),
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
color = Palette.SecondaryText,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.clickable { muting = true }.testTag("story-mute"),
|
||||
)
|
||||
// Reporting a story, which Android could not do. iOS has had it since
|
||||
@@ -451,10 +464,10 @@ private fun StoryActions(
|
||||
if (!story.mine) {
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Text(
|
||||
"Report",
|
||||
stringResource(R.string.social_story_report),
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
color = Palette.SecondaryText,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier =
|
||||
Modifier
|
||||
.clickable { reporting = true }
|
||||
@@ -464,9 +477,10 @@ private fun StoryActions(
|
||||
}
|
||||
|
||||
if (reporting) {
|
||||
val thisPerson = stringResource(R.string.social_this_person)
|
||||
// The SAME sheet a message report uses, deliberately.
|
||||
ReportSheet(
|
||||
authorName = authorName.ifBlank { "this person" },
|
||||
authorName = authorName.ifBlank { thisPerson },
|
||||
preview = story.caption,
|
||||
onDismiss = { reporting = false },
|
||||
onSend = { reason, includeContent, note ->
|
||||
@@ -484,20 +498,19 @@ private fun StoryActions(
|
||||
var draft by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = { replying = false },
|
||||
title = { Text("Reply privately") },
|
||||
title = { Text(stringResource(R.string.social_reply_privately)) },
|
||||
// Says where it goes.
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"Goes to them in your conversation. Nobody else who " +
|
||||
"saw this story can read it.",
|
||||
stringResource(R.string.social_reply_privately_note),
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
OutlinedTextField(
|
||||
value = draft,
|
||||
onValueChange = { draft = it },
|
||||
placeholder = { Text("Message") },
|
||||
placeholder = { Text(stringResource(R.string.social_story_reply_placeholder)) },
|
||||
modifier = Modifier.fillMaxWidth().testTag("story-reply-field"),
|
||||
)
|
||||
}
|
||||
@@ -510,22 +523,20 @@ private fun StoryActions(
|
||||
},
|
||||
enabled = draft.isNotBlank(),
|
||||
modifier = Modifier.testTag("story-reply-send"),
|
||||
) { Text("Send") }
|
||||
) { Text(stringResource(R.string.common_send)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { replying = false }) { Text("Cancel") } },
|
||||
dismissButton = { TextButton(onClick = { replying = false }) { Text(stringResource(R.string.common_cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
if (muting) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { muting = false },
|
||||
title = { Text("Mute their stories?") },
|
||||
title = { Text(stringResource(R.string.social_mute_stories_title)) },
|
||||
// Says what muting is NOT.
|
||||
text = {
|
||||
Text(
|
||||
"Their stories stop showing on this phone. They aren't " +
|
||||
"told, and their messages still arrive as usual. You " +
|
||||
"can turn it back on from their contact page.",
|
||||
stringResource(R.string.social_mute_stories_body),
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
},
|
||||
@@ -536,13 +547,168 @@ private fun StoryActions(
|
||||
model.setStoriesMuted(story.authorId, true)
|
||||
},
|
||||
modifier = Modifier.testTag("story-mute-confirm"),
|
||||
) { Text("Mute") }
|
||||
) { Text(stringResource(R.string.common_mute)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { muting = false }) { Text("Cancel") } },
|
||||
dismissButton = { TextButton(onClick = { muting = false }) { Text(stringResource(R.string.common_cancel)) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** What the stories screens say about taking one down or changing its words. */
|
||||
object StoryWords {
|
||||
/** DESIGN item 25, word for word, before a story comes down. */
|
||||
const val TAKE_DOWN_WARNING =
|
||||
"This removes it from your friends' phones when their app gets the message. " +
|
||||
"Anyone who already saw it may have kept a copy, and older versions of the app " +
|
||||
"keep it until it expires."
|
||||
|
||||
private const val TODAYS_AUDIENCE =
|
||||
"It went to your story audience as it is now, since this story was posted before " +
|
||||
"the app kept who it went to."
|
||||
|
||||
/** After a take-down: done, anyone the message has not reached, and which audience was used. */
|
||||
fun takenDown(change: Session.StoryChange): String {
|
||||
val total = change.reached + change.missed
|
||||
val reach =
|
||||
if (change.missed == 0) {
|
||||
"Taken down."
|
||||
} else {
|
||||
"Taken down here. ${change.missed} of $total could not be reached right now, so " +
|
||||
"their copy stays until it expires."
|
||||
}
|
||||
return if (change.asPosted) reach else "$reach $TODAYS_AUDIENCE"
|
||||
}
|
||||
|
||||
/** Whether [draft] may be sent as a story's new words: never empty, and not what it says already. */
|
||||
fun canSave(
|
||||
draft: String,
|
||||
current: String,
|
||||
): Boolean = draft.isNotBlank() && draft.trim() != current
|
||||
|
||||
/** After an edit: nothing to say unless somebody still sees the old words. */
|
||||
fun edited(change: Session.StoryChange): String? {
|
||||
val total = change.reached + change.missed
|
||||
val reach =
|
||||
if (change.missed == 0) {
|
||||
null
|
||||
} else {
|
||||
"Changed here. ${change.missed} of $total could not be reached right now, so they " +
|
||||
"still see the old words."
|
||||
}
|
||||
return if (change.asPosted) reach else listOfNotNull(reach, TODAYS_AUDIENCE).joinToString(" ")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* My own story: change its words, or take it down for everyone it went to
|
||||
* (§6.15 for stories, §6.35), in one row.
|
||||
*/
|
||||
@Composable
|
||||
private fun MyStoryControls(
|
||||
story: Vault.Story,
|
||||
model: MessengerViewModel,
|
||||
) {
|
||||
var editing by remember(story.id) { mutableStateOf(false) }
|
||||
var takingDown by remember(story.id) { mutableStateOf(false) }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs), modifier = Modifier.fillMaxWidth()) {
|
||||
KitSecondaryButton(
|
||||
text = if (story.textOnly) "Edit words" else "Edit caption",
|
||||
onClick = { editing = true },
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "story-edit",
|
||||
)
|
||||
KitSecondaryButton(
|
||||
text = "Take down",
|
||||
onClick = { takingDown = true },
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "story-takedown",
|
||||
danger = true,
|
||||
)
|
||||
}
|
||||
|
||||
if (takingDown) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { takingDown = false },
|
||||
title = { Text(if (story.set != null) "Take down this story and all its pictures?" else "Take down this story?") },
|
||||
text = { Text(StoryWords.TAKE_DOWN_WARNING, fontSize = 14.sp, modifier = Modifier.testTag("story-takedown-warning")) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
takingDown = false
|
||||
model.takeDownStory(story)
|
||||
},
|
||||
modifier = Modifier.testTag("story-takedown-confirm"),
|
||||
) { Text("Take down", color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { takingDown = false }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
var draft by remember(story.id) { mutableStateOf(story.caption) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { editing = false },
|
||||
title = { Text(if (story.textOnly) "Edit your story" else "Edit the caption") },
|
||||
text = {
|
||||
StoryCaptionEditor(
|
||||
draft = draft,
|
||||
onDraft = { draft = it },
|
||||
onTakeDownInstead = {
|
||||
editing = false
|
||||
takingDown = true
|
||||
},
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
editing = false
|
||||
model.editStoryCaption(story, draft.trim())
|
||||
},
|
||||
enabled = StoryWords.canSave(draft, story.caption),
|
||||
modifier = Modifier.testTag("story-edit-save"),
|
||||
) { Text("Save") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { editing = false }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The words being edited, and what an edit cannot do: leave a story empty
|
||||
* (§6.15). Taking it down is offered instead.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StoryCaptionEditor(
|
||||
draft: String,
|
||||
onDraft: (String) -> Unit,
|
||||
onTakeDownInstead: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||
OutlinedTextField(
|
||||
value = draft,
|
||||
onValueChange = onDraft,
|
||||
modifier = Modifier.fillMaxWidth().testTag("story-edit-field"),
|
||||
)
|
||||
Text(
|
||||
"Everyone who has it sees that it was edited.",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (draft.isBlank()) {
|
||||
Text(
|
||||
"An edit cannot leave it empty. To remove it, take it down.",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.testTag("story-edit-empty"),
|
||||
)
|
||||
TextButton(onClick = onTakeDownInstead, modifier = Modifier.testTag("story-edit-takedown")) {
|
||||
Text("Take down instead")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Caption + a deliberate Post for a picked story image, at the shell level so
|
||||
* whichever screen picked it (the strip or the Stories tab) gets the same
|
||||
@@ -588,39 +754,21 @@ fun StoryComposerDialog(
|
||||
}
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { model.discardStagedStory() },
|
||||
title = { Text("New story") },
|
||||
title = { Text(stringResource(R.string.social_new_story)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||
image?.let {
|
||||
androidx.compose.foundation.layout.Box(contentAlignment = Alignment.Center) {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = it,
|
||||
contentDescription = "Story preview",
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.small)),
|
||||
)
|
||||
if (draft.duration > 0) {
|
||||
Text(
|
||||
"▶ ${draft.duration.toInt()}s",
|
||||
fontSize = 22.sp,
|
||||
color = androidx.compose.ui.graphics.Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
image?.let { StoryComposerPreview(it, draft.width, draft.height, draft.duration) }
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = caption.value,
|
||||
onValueChange = { caption.value = it },
|
||||
placeholder = { Text("Add a caption…") },
|
||||
placeholder = { Text(stringResource(R.string.social_story_add_caption)) },
|
||||
modifier = Modifier.fillMaxWidth().testTag("story-caption"),
|
||||
)
|
||||
// Who THIS story goes to.
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = streamUrl.value,
|
||||
onValueChange = { streamUrl.value = it },
|
||||
label = { Text("Live stream link (optional)", fontSize = 12.sp) },
|
||||
label = { Text(stringResource(R.string.social_story_stream_link_label), fontSize = 12.sp) },
|
||||
singleLine = true,
|
||||
isError = !streamOk,
|
||||
modifier = Modifier.fillMaxWidth().testTag("story-stream-field"),
|
||||
@@ -630,17 +778,14 @@ fun StoryComposerDialog(
|
||||
// moment of attaching one.
|
||||
Text(
|
||||
if (streamOk) {
|
||||
"Your picture becomes the cover. Anyone who taps play connects " +
|
||||
"to that site, which will see their IP address, they are " +
|
||||
"told before it happens."
|
||||
stringResource(R.string.social_story_stream_ok_note)
|
||||
} else {
|
||||
"That needs to be a full http:// or https:// link with a site " +
|
||||
"name in it."
|
||||
stringResource(R.string.social_story_stream_bad_link)
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color =
|
||||
if (streamOk) {
|
||||
Palette.SecondaryText
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
},
|
||||
@@ -657,7 +802,7 @@ fun StoryComposerDialog(
|
||||
androidx.compose.material3.FilterChip(
|
||||
selected = toGroup.value == null,
|
||||
onClick = { toGroup.value = null },
|
||||
label = { Text("My contacts", fontSize = 12.sp) },
|
||||
label = { Text(stringResource(R.string.social_story_dest_my_contacts), fontSize = 12.sp) },
|
||||
modifier = Modifier.testTag("story-dest-contacts"),
|
||||
)
|
||||
phase.groups.forEach { group ->
|
||||
@@ -672,18 +817,16 @@ fun StoryComposerDialog(
|
||||
}
|
||||
if (toGroup.value != null) {
|
||||
Text(
|
||||
"Everyone in that group receives it and it disappears after " +
|
||||
"a day. Your story audience does not apply, and people " +
|
||||
"outside the group are not sent it.",
|
||||
stringResource(R.string.social_story_group_note),
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs)) {
|
||||
listOf(
|
||||
Session.AudienceMode.EVERYONE to "Everyone",
|
||||
Session.AudienceMode.ONLY to "Only…",
|
||||
Session.AudienceMode.EXCEPT to "All except…",
|
||||
Session.AudienceMode.EVERYONE to stringResource(R.string.social_audience_everyone),
|
||||
Session.AudienceMode.ONLY to stringResource(R.string.social_audience_only),
|
||||
Session.AudienceMode.EXCEPT to stringResource(R.string.social_audience_all_except),
|
||||
).forEach { (m, label) ->
|
||||
androidx.compose.material3.FilterChip(
|
||||
selected = mode.value == m,
|
||||
@@ -719,19 +862,19 @@ fun StoryComposerDialog(
|
||||
Text(
|
||||
when (mode.value) {
|
||||
Session.AudienceMode.EVERYONE -> {
|
||||
"Goes to all your contacts and disappears after a day."
|
||||
stringResource(R.string.social_story_audience_everyone_note)
|
||||
}
|
||||
|
||||
Session.AudienceMode.ONLY -> {
|
||||
"Goes only to the ${chosen.value.size} chosen, and disappears after a day."
|
||||
pluralStringResource(R.plurals.social_story_audience_only_note, chosen.value.size, chosen.value.size)
|
||||
}
|
||||
|
||||
Session.AudienceMode.EXCEPT -> {
|
||||
"Goes to everyone but the ${chosen.value.size} chosen, and disappears after a day."
|
||||
pluralStringResource(R.plurals.social_story_audience_except_note, chosen.value.size, chosen.value.size)
|
||||
}
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -749,16 +892,39 @@ fun StoryComposerDialog(
|
||||
// Refuses to post a stream URL it could not disclose.
|
||||
enabled = streamOk,
|
||||
modifier = Modifier.testTag("story-post"),
|
||||
) { Text("Post") }
|
||||
) { Text(stringResource(R.string.social_post)) }
|
||||
},
|
||||
dismissButton = {
|
||||
androidx.compose.material3.TextButton(
|
||||
onClick = { model.discardStagedStory() },
|
||||
) { Text("Cancel") }
|
||||
) { Text(stringResource(R.string.common_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The picked picture as friends will see it in their feed (DESIGN item 27):
|
||||
* the same shape and the same bars as the feed card, scaled to the composer.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StoryComposerPreview(
|
||||
image: androidx.compose.ui.graphics.ImageBitmap,
|
||||
width: Int,
|
||||
height: Int,
|
||||
durationSeconds: Double,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.testTag("story-preview")) {
|
||||
StoryPhotoFrame(image, width, height)
|
||||
if (durationSeconds > 0) {
|
||||
Text(
|
||||
"▶ ${durationSeconds.toInt()}s",
|
||||
fontSize = 22.sp,
|
||||
color = androidx.compose.ui.graphics.Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The story cards themselves, everyone's stories, or one honest message when
|
||||
* there are none.
|
||||
@@ -782,16 +948,14 @@ fun ColumnScope.StoriesCards(
|
||||
Icon(
|
||||
KitIcon.Stories,
|
||||
contentDescription = null,
|
||||
tint = Palette.SecondaryText,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(Space.xxl),
|
||||
)
|
||||
Text("Nothing here yet", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.social_stories_empty_title), fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
"A story goes to your contacts and disappears after a day, on " +
|
||||
"your device and theirs, because their app deletes it too. " +
|
||||
"It is not a secret you can take back.",
|
||||
stringResource(R.string.social_stories_empty_body),
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
@@ -844,14 +1008,18 @@ private fun StoryVideoCard(
|
||||
|
||||
failed.value -> {
|
||||
Text(
|
||||
"Video unavailable",
|
||||
stringResource(R.string.social_video_unavailable),
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Text("Loading…", fontSize = 14.sp, color = Palette.SecondaryText)
|
||||
Text(
|
||||
stringResource(R.string.common_loading),
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -877,27 +1045,22 @@ private fun StoryImage(
|
||||
if (bitmap.value == null) failed.value = true
|
||||
}
|
||||
val loaded = bitmap.value
|
||||
when {
|
||||
loaded != null -> {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = loaded.asImageBitmap(),
|
||||
contentDescription = "Story image",
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
failed.value -> {
|
||||
Text(
|
||||
"Image unavailable",
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Text("Loading…", fontSize = 14.sp, color = Palette.SecondaryText)
|
||||
}
|
||||
// The decoded picture's own shape when there is one: the envelope's size
|
||||
// is the sender's claim, the pixels are not.
|
||||
StoryPhotoFrame(
|
||||
bitmap = loaded?.asImageBitmap(),
|
||||
photoWidth = loaded?.width ?: ref.width,
|
||||
photoHeight = loaded?.height ?: ref.height,
|
||||
) {
|
||||
Text(
|
||||
if (failed.value) {
|
||||
stringResource(R.string.social_image_unavailable)
|
||||
} else {
|
||||
stringResource(R.string.common_loading)
|
||||
},
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -938,12 +1101,12 @@ private fun TextStoryComposer(
|
||||
var text by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("New story") },
|
||||
title = { Text(stringResource(R.string.social_new_story)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
placeholder = { Text("Say something") },
|
||||
placeholder = { Text(stringResource(R.string.social_story_say_something)) },
|
||||
modifier = Modifier.fillMaxWidth().testTag("story-text-field"),
|
||||
)
|
||||
},
|
||||
@@ -952,8 +1115,8 @@ private fun TextStoryComposer(
|
||||
onClick = { onPost(text.trim()) },
|
||||
enabled = text.isNotBlank(),
|
||||
modifier = Modifier.testTag("story-text-post"),
|
||||
) { Text("Post") }
|
||||
) { Text(stringResource(R.string.social_post)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||