Compare commits
@@ -1 +1 @@
|
|||||||
be8093f7c76c42cac5b826f40db95db372bdd34b
|
4b4042f72880758ee2dea7713f7a2cb63fa12f73
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Why this repository pins flamenet-e2e
|
# 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`,
|
whose protocol vectors the `unit-tests` job checks against. `AccountKdfTest`,
|
||||||
`PQVectorTest` and `PublishedVectorsTest` all read them.
|
`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,
|
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
|
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
|
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
|
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.
|
`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
|
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
|
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.
|
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
|
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
|
reading of our own Swift and JavaScript. So the engine is held to
|
||||||
`test/vectors/protocol-v1.json` in
|
`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
|
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
|
copied here, because a copied vector file drifts and a drifting vector file
|
||||||
stops being a check. `PublishedVectorsTest` looks for it beside this checkout
|
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
|
// 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.
|
// 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.
|
// From here they move together and match: 25 with 0.25, 26 with 0.26.
|
||||||
versionCode = 30
|
versionCode = 32
|
||||||
versionName = "0.30"
|
versionName = "0.32"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
getByName("main") { java.srcDir("src/main/kotlin") }
|
getByName("main") { java.srcDir("src/main/kotlin") }
|
||||||
getByName("androidTest") { java.srcDir("src/androidTest/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 }
|
buildFeatures { compose = true }
|
||||||
@@ -198,6 +202,8 @@ android {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":engine"))
|
implementation(project(":engine"))
|
||||||
implementation(project(":client"))
|
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.
|
// A direct dependency rather than leaning on `:client` to re-export it.
|
||||||
// The settings screen reads bridge lines and the handshake counter itself,
|
// The settings screen reads bridge lines and the handshake counter itself,
|
||||||
// so the app uses these types; borrowing them through another
|
// 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 {
|
} else {
|
||||||
"https"
|
"https"
|
||||||
}
|
}
|
||||||
// The contact on the other side. `peerjvmone` is a standing account on
|
// The contacts on the other side, both created for this run by
|
||||||
// relay1; the reply case needs somebody answering AS it, which is the
|
// scripts/instrumented.sh. They used to be `peerjvmone`, one standing
|
||||||
// interop Peer in `autoreply` (see PROVEN below). Override with
|
// account on relay1 that ran out of prekeys and filled with messages nobody
|
||||||
// -Pandroid.testInstrumentationRunnerArguments.peer=<handle>, and run the
|
// collected, so adding it and delivering to it failed in some runs and not
|
||||||
// reply case ALONE: one autoreply peer pins the first fresh account it
|
// others. The reply case gets its own peer, running `autoreply`, because
|
||||||
// sees and drops a second one, so two ChatFlow methods cannot share it.
|
// one autoreply peer answers the first new account that writes to it and
|
||||||
|
// drops the rest.
|
||||||
private val peerHandle: String
|
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
|
private lateinit var handle: String
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
@@ -117,14 +124,14 @@ class ChatFlowTest {
|
|||||||
|
|
||||||
signUp()
|
signUp()
|
||||||
compose.onNodeWithTag("add-contact").performClick()
|
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
|
// Adding is gated on a lookup: the button stays disabled until the
|
||||||
// handle resolves, so a typo cannot become a contact.
|
// handle resolves, so a typo cannot become a contact.
|
||||||
compose.onNodeWithTag("lookup-contact").performClick()
|
compose.onNodeWithTag("lookup-contact").performClick()
|
||||||
awaitTag("lookup-result", 30_000)
|
awaitTag("lookup-result", 30_000)
|
||||||
compose.onNodeWithTag("confirm-contact").performClick()
|
compose.onNodeWithTag("confirm-contact").performClick()
|
||||||
awaitNode(hasText(peerHandle, substring = true), 60_000)
|
awaitNode(hasText(echoPeer, substring = true), 60_000)
|
||||||
compose.onNode(hasText(peerHandle, substring = true)).performClick()
|
compose.onNode(hasText(echoPeer, substring = true)).performClick()
|
||||||
awaitTag("draft", 20_000)
|
awaitTag("draft", 20_000)
|
||||||
|
|
||||||
val text = "ping ${System.currentTimeMillis() % 100000}"
|
val text = "ping ${System.currentTimeMillis() % 100000}"
|
||||||
@@ -134,15 +141,10 @@ class ChatFlowTest {
|
|||||||
|
|
||||||
// The app polls every few seconds; the peer echoes every couple.
|
// The app polls every few seconds; the peer echoes every couple.
|
||||||
val echo = "echo: $text"
|
val echo = "echo: $text"
|
||||||
val arrived =
|
// A missing echo is a failure. This used to log a warning and return,
|
||||||
runCatching {
|
// which passed the test while proving nothing; with the script running
|
||||||
awaitNode(hasText(echo, substring = true), 90_000)
|
// the echo peer, silence means the round trip is broken.
|
||||||
true
|
awaitNode(hasText(echo, substring = true), 90_000)
|
||||||
}.getOrDefault(false)
|
|
||||||
if (!arrived) {
|
|
||||||
android.util.Log.w("fnchat", "no echo for '$text', is the peer running autoreply?")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
compose.onNode(hasText(echo, substring = true)).assertIsDisplayed()
|
compose.onNode(hasText(echo, substring = true)).assertIsDisplayed()
|
||||||
android.util.Log.i("fnchat", "round trip complete: $echo")
|
android.util.Log.i("fnchat", "round trip complete: $echo")
|
||||||
}
|
}
|
||||||
@@ -150,9 +152,14 @@ class ChatFlowTest {
|
|||||||
// -- Helpers -------------------------------------------------------------.
|
// -- Helpers -------------------------------------------------------------.
|
||||||
|
|
||||||
private fun signUp() {
|
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").performTextClearance()
|
||||||
compose.onNodeWithTag("relay").performTextInput(relay)
|
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("email").performTextInput("$handle@example.invalid")
|
||||||
compose.onNodeWithTag("handle").performTextInput(handle)
|
compose.onNodeWithTag("handle").performTextInput(handle)
|
||||||
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
|
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
|
||||||
|
|||||||
@@ -44,9 +44,21 @@ class CrossPlatformTest {
|
|||||||
settle()
|
settle()
|
||||||
if (nodesWithTag("nav-contacts").isNotEmpty()) return // already signed in
|
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").performTextClearance()
|
||||||
|
|
||||||
compose.onNodeWithTag("relay").performTextInput(relay)
|
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("email").performTextInput("$handle@example.invalid")
|
||||||
compose.onNodeWithTag("handle").performTextInput(handle)
|
compose.onNodeWithTag("handle").performTextInput(handle)
|
||||||
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
|
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
|
||||||
|
|||||||
@@ -56,7 +56,11 @@ class CrudSurfaceTest {
|
|||||||
} else {
|
} else {
|
||||||
"https"
|
"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
|
private lateinit var handle: String
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
@@ -83,9 +87,14 @@ class CrudSurfaceTest {
|
|||||||
compose.onNodeWithTag("toggle-reset").performClick()
|
compose.onNodeWithTag("toggle-reset").performClick()
|
||||||
|
|
||||||
// Registering a local account asks for an email; signing in does not.
|
// 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").performTextClearance()
|
||||||
compose.onNodeWithTag("relay").performTextInput(relay)
|
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()
|
compose.onNodeWithTag("email").assertExists()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,9 +227,14 @@ class CrudSurfaceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun signUp() {
|
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").performTextClearance()
|
||||||
compose.onNodeWithTag("relay").performTextInput(relay)
|
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("email").performTextInput("$handle@example.invalid")
|
||||||
compose.onNodeWithTag("handle").performTextInput(handle)
|
compose.onNodeWithTag("handle").performTextInput(handle)
|
||||||
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
|
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ class FeatureUseTest {
|
|||||||
InstrumentationRegistry.getArguments().getString("relay")
|
InstrumentationRegistry.getArguments().getString("relay")
|
||||||
?: "relay1.flamenet.io"
|
?: "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. */
|
/** A local relay is plain HTTP; anything else is not. */
|
||||||
private val relayScheme: String get() =
|
private val relayScheme: String get() =
|
||||||
if (relay.startsWith("10.0.2.2") ||
|
if (relay.startsWith("10.0.2.2") ||
|
||||||
@@ -120,8 +130,13 @@ class FeatureUseTest {
|
|||||||
compose.onNodeWithTag("create-group").performClick()
|
compose.onNodeWithTag("create-group").performClick()
|
||||||
|
|
||||||
// The group list carries a `group-<id>` row once the relay answers.
|
// 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(
|
awaitNodeMatching(
|
||||||
Regex("group-\\d+"),
|
Regex("group-[0-9a-f]{16,}"),
|
||||||
150_000,
|
150_000,
|
||||||
"the group never appeared in the list after Create",
|
"the group never appeared in the list after Create",
|
||||||
)
|
)
|
||||||
@@ -191,18 +206,22 @@ class FeatureUseTest {
|
|||||||
compose.onNodeWithTag("add-contact").performScrollTo()
|
compose.onNodeWithTag("add-contact").performScrollTo()
|
||||||
compose.onNodeWithTag("add-contact").performClick()
|
compose.onNodeWithTag("add-contact").performClick()
|
||||||
awaitTag("new-contact", 20_000)
|
awaitTag("new-contact", 20_000)
|
||||||
compose.onNodeWithTag("new-contact").performTextInput(PEER)
|
compose.onNodeWithTag("new-contact").performTextInput(peer)
|
||||||
// Look up, THEN add.
|
// Look up, THEN add.
|
||||||
compose.onNodeWithTag("lookup-contact").performScrollTo()
|
compose.onNodeWithTag("lookup-contact").performScrollTo()
|
||||||
compose.onNodeWithTag("lookup-contact").performClick()
|
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").performScrollTo()
|
||||||
compose.onNodeWithTag("confirm-contact").performClick()
|
compose.onNodeWithTag("confirm-contact").performClick()
|
||||||
// `buddy-circle-N` as well as `buddy-N`.
|
// `buddy-circle-N` as well as `buddy-N`.
|
||||||
awaitNodeMatching(
|
awaitNodeMatching(
|
||||||
Regex("buddy-(circle-)?\\d+"),
|
Regex("buddy-(circle-)?\\d+"),
|
||||||
150_000,
|
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() {
|
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").performTextClearance()
|
||||||
compose.onNodeWithTag("relay").performTextInput(relay)
|
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("email").performTextInput("$handle@example.invalid")
|
||||||
compose.onNodeWithTag("handle").performTextInput(handle)
|
compose.onNodeWithTag("handle").performTextInput(handle)
|
||||||
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
|
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
|
||||||
@@ -329,9 +353,4 @@ class FeatureUseTest {
|
|||||||
readTimeout = 8000
|
readTimeout = 8000
|
||||||
}.responseCode in 200..299
|
}.responseCode in 200..299
|
||||||
}.getOrDefault(false)
|
}.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
|
if (!reachable(relay)) return
|
||||||
|
|
||||||
val name = "$handle$tag"
|
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").performTextClearance()
|
||||||
compose.onNodeWithTag("relay").performTextInput(relay)
|
compose.onNodeWithTag("relay").performTextInput(relay)
|
||||||
compose.onNodeWithTag("toggle-register").performClick()
|
|
||||||
|
|
||||||
// Wait for discovery BEFORE deciding anything about the form.
|
// Wait for discovery BEFORE deciding anything about the form.
|
||||||
awaitTag("identity-mode", 30_000)
|
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" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</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>
|
</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.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import io.flamenet.e2e.QrCode
|
import io.flamenet.e2e.QrCode
|
||||||
|
import io.flamenet.messenger.R
|
||||||
import android.graphics.Color as AndroidColor
|
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
|
@Composable
|
||||||
fun AddressCard(
|
fun AddressCard(
|
||||||
fnid: String?,
|
fnid: String?,
|
||||||
relay: String,
|
relay: String,
|
||||||
handle: String? = null,
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
Column(Modifier.fillMaxWidth()) {
|
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))
|
Spacer(Modifier.height(Space.sm))
|
||||||
|
|
||||||
if (fnid == null) {
|
if (fnid == null) {
|
||||||
// Said plainly rather than hidden.
|
// Said plainly rather than hidden.
|
||||||
Text(
|
Text(
|
||||||
"This device could not derive your address. You can still message " +
|
stringResource(R.string.social_address_not_derived, relay),
|
||||||
"people on $relay, but nobody on another relay can reach you.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
modifier = Modifier.testTag("address-missing"),
|
modifier = Modifier.testTag("address-missing"),
|
||||||
)
|
)
|
||||||
@@ -58,13 +60,11 @@ fun AddressCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val address = "$fnid@$relay"
|
val address = "$fnid@$relay"
|
||||||
// The handle rides in the CODE only, never in the copyable string, it
|
// The code holds the hello link, so a phone's own camera opens the
|
||||||
// lets a same-relay scanner resolve the account through the ordinary
|
// relay's page for it and this app's scanner reads the address out of
|
||||||
// username lookup.
|
// it. Null only for an address that is not one, which nothing derives.
|
||||||
val encoded =
|
val hello = HelloLink.forAddress(address)
|
||||||
"flamenet:$address" +
|
val bitmap = remember(hello, address) { qrBitmap(hello ?: address) }
|
||||||
(handle?.takeIf { it.isNotBlank() }?.let { "?u=$it" } ?: "")
|
|
||||||
val bitmap = remember(encoded) { qrBitmap(encoded) }
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -73,7 +73,12 @@ fun AddressCard(
|
|||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
Image(
|
Image(
|
||||||
bitmap = bitmap.asImageBitmap(),
|
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
|
// None, or the modules blur when scaled up and the code
|
||||||
// stops scanning, the default filtering is smooth.
|
// stops scanning, the default filtering is smooth.
|
||||||
filterQuality = FilterQuality.None,
|
filterQuality = FilterQuality.None,
|
||||||
@@ -104,20 +109,75 @@ fun AddressCard(
|
|||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { copy(context, "Flamenet address", address) },
|
onClick = { copy(context, "Flamenet address", address) },
|
||||||
modifier = Modifier.testTag("copy-address"),
|
modifier = Modifier.testTag("copy-address"),
|
||||||
) { Text("Copy address") }
|
) { Text(stringResource(R.string.social_copy_address)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(Space.sm))
|
||||||
Text(
|
Text(
|
||||||
"Derived from your passphrase, not issued by a server. Sign in anywhere " +
|
stringResource(R.string.social_address_explainer),
|
||||||
"with the same passphrase and this stays the same, which is what " +
|
|
||||||
"lets it outlive any one relay.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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(
|
private fun copy(
|
||||||
context: Context,
|
context: Context,
|
||||||
label: String,
|
label: String,
|
||||||
@@ -130,7 +190,7 @@ private fun copy(
|
|||||||
/**
|
/**
|
||||||
* One module per pixel plus a quiet zone; Compose scales it up.
|
* One module per pixel plus a quiet zone; Compose scales it up.
|
||||||
*/
|
*/
|
||||||
private fun qrBitmap(
|
internal fun qrBitmap(
|
||||||
text: String,
|
text: String,
|
||||||
quiet: Int = 4,
|
quiet: Int = 4,
|
||||||
): Bitmap? {
|
): Bitmap? {
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import io.flamenet.messenger.R
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A fingerprint, a face, or the device PIN in front of the app.
|
* A fingerprint, a face, or the device PIN in front of the app.
|
||||||
@@ -160,9 +162,9 @@ class AppLock(
|
|||||||
if (seconds <= 0) return null
|
if (seconds <= 0) return null
|
||||||
return if (seconds >= 60) {
|
return if (seconds >= 60) {
|
||||||
val minutes = (seconds + 59) / 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 {
|
} 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(
|
prompt.authenticate(
|
||||||
BiometricPrompt.PromptInfo
|
BiometricPrompt.PromptInfo
|
||||||
.Builder()
|
.Builder()
|
||||||
.setTitle("Unlock Flamenet")
|
.setTitle(activity.getString(R.string.set_lock_prompt_title))
|
||||||
.setSubtitle("Your messages are locked on this device.")
|
.setSubtitle(activity.getString(R.string.set_lock_prompt_subtitle))
|
||||||
.setAllowedAuthenticators(AUTHENTICATORS)
|
.setAllowedAuthenticators(AUTHENTICATORS)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
@@ -243,14 +245,14 @@ fun LockScreen(onUnlock: () -> Unit) {
|
|||||||
modifier = Modifier.size(Space.xxl),
|
modifier = Modifier.size(Space.xxl),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.lg))
|
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))
|
Spacer(Modifier.height(Space.sm))
|
||||||
Text(
|
Text(
|
||||||
"Unlock this device to read your messages.",
|
stringResource(R.string.set_lock_locked_body),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.xl))
|
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.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import io.flamenet.client.UpdateCheck
|
import io.flamenet.client.UpdateCheck
|
||||||
|
import io.flamenet.messenger.R
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@@ -195,19 +197,11 @@ fun UpdateAvailableDialog(baseUrl: String?) {
|
|||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showing = false },
|
onDismissRequest = { showing = false },
|
||||||
title = { Text("Version ${release.versionName} is out") },
|
title = { Text(stringResource(R.string.set_update_version_out, release.versionName)) },
|
||||||
text = {
|
text = {
|
||||||
|
val newerBuild = stringResource(R.string.set_update_newer_build)
|
||||||
Text(
|
Text(
|
||||||
buildString {
|
stringResource(R.string.set_update_body, release.notes.ifBlank { newerBuild }),
|
||||||
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.")
|
|
||||||
},
|
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -218,7 +212,7 @@ fun UpdateAvailableDialog(baseUrl: String?) {
|
|||||||
showing = false
|
showing = false
|
||||||
},
|
},
|
||||||
modifier = Modifier.testTag("update-download"),
|
modifier = Modifier.testTag("update-download"),
|
||||||
) { Text("Download", fontWeight = FontWeight.Medium) }
|
) { Text(stringResource(R.string.set_update_download), fontWeight = FontWeight.Medium) }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -228,7 +222,7 @@ fun UpdateAvailableDialog(baseUrl: String?) {
|
|||||||
showing = false
|
showing = false
|
||||||
},
|
},
|
||||||
modifier = Modifier.testTag("update-later"),
|
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 pc = ensureFactory().createPeerConnection(config, observer) ?: return null
|
||||||
val source = ensureFactory().createAudioSource(MediaConstraints())
|
val source = ensureFactory().createAudioSource(MediaConstraints())
|
||||||
val track = ensureFactory().createAudioTrack("fnaudio", source)
|
val track = ensureFactory().createAudioTrack("fnaudio", source)
|
||||||
@@ -413,6 +415,7 @@ class CallManager(
|
|||||||
// Supervision: enforced where the call would start, not where the
|
// Supervision: enforced where the call would start, not where the
|
||||||
// button is drawn, every entry point hits the same wall.
|
// button is drawn, every entry point hits the same wall.
|
||||||
if (ParentalControls.callsDisabled(context)) return
|
if (ParentalControls.callsDisabled(context)) return
|
||||||
|
if (CallPolicy.bridgeRefusal() != null) return
|
||||||
if (state != State.IDLE && state != State.ENDED) return
|
if (state != State.IDLE && state != State.ENDED) return
|
||||||
val callId = UUID.randomUUID().toString()
|
val callId = UUID.randomUUID().toString()
|
||||||
// `current` BEFORE the state change.
|
// `current` BEFORE the state change.
|
||||||
@@ -494,7 +497,7 @@ class CallManager(
|
|||||||
Content.CallSignal.OFFER -> {
|
Content.CallSignal.OFFER -> {
|
||||||
// Supervision: a device with calls off does not ring, the
|
// Supervision: a device with calls off does not ring, the
|
||||||
// caller is answered busy, like any other unavailable state.
|
// caller is answered busy, like any other unavailable state.
|
||||||
if (ParentalControls.callsDisabled(context)) {
|
if (ParentalControls.callsDisabled(context) || CallPolicy.bridgeRefusal() != null) {
|
||||||
runCatching {
|
runCatching {
|
||||||
signal(
|
signal(
|
||||||
fromUser,
|
fromUser,
|
||||||
@@ -967,6 +970,7 @@ class CallManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun accept(turn: JSONObject?) {
|
fun accept(turn: JSONObject?) {
|
||||||
|
if (CallPolicy.bridgeRefusal() != null) return
|
||||||
val (fromUser, offer) = pendingOffer ?: return
|
val (fromUser, offer) = pendingOffer ?: return
|
||||||
pendingOffer = null
|
pendingOffer = null
|
||||||
tellOwnDevices?.invoke(
|
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.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.flamenet.messenger.R
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,7 +100,7 @@ fun CallScreenBody(
|
|||||||
Text(status, fontSize = 14.sp, color = Palette.SecondaryText)
|
Text(status, fontSize = 14.sp, color = Palette.SecondaryText)
|
||||||
// Said plainly and always: it is the reason to use this at all.
|
// Said plainly and always: it is the reason to use this at all.
|
||||||
Text(
|
Text(
|
||||||
"End-to-end encrypted",
|
stringResource(R.string.set_call_e2e),
|
||||||
fontSize = 10.sp,
|
fontSize = 10.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
modifier = Modifier.padding(top = 2.dp),
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
@@ -215,23 +217,23 @@ fun LiveCallScreen(
|
|||||||
val status =
|
val status =
|
||||||
when (call.state) {
|
when (call.state) {
|
||||||
CallManager.State.RINGING -> {
|
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 -> {
|
CallManager.State.DIALLING -> {
|
||||||
"Calling…"
|
stringResource(R.string.set_call_calling)
|
||||||
}
|
}
|
||||||
|
|
||||||
CallManager.State.CONNECTING -> {
|
CallManager.State.CONNECTING -> {
|
||||||
"Connecting…"
|
stringResource(R.string.common_connecting)
|
||||||
}
|
}
|
||||||
|
|
||||||
CallManager.State.ACTIVE -> {
|
CallManager.State.ACTIVE -> {
|
||||||
"On a call"
|
stringResource(R.string.set_call_on_a_call)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
"Call ended"
|
stringResource(R.string.set_call_ended)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +244,7 @@ fun LiveCallScreen(
|
|||||||
CallAction(
|
CallAction(
|
||||||
"decline",
|
"decline",
|
||||||
KitIcon.CallEnd,
|
KitIcon.CallEnd,
|
||||||
"Decline",
|
stringResource(R.string.common_decline),
|
||||||
tint = MaterialTheme.colorScheme.error,
|
tint = MaterialTheme.colorScheme.error,
|
||||||
iconColor = Color.White,
|
iconColor = Color.White,
|
||||||
run = model::declineCall,
|
run = model::declineCall,
|
||||||
@@ -250,7 +252,7 @@ fun LiveCallScreen(
|
|||||||
CallAction(
|
CallAction(
|
||||||
"accept",
|
"accept",
|
||||||
KitIcon.Call,
|
KitIcon.Call,
|
||||||
"Answer",
|
stringResource(R.string.common_answer),
|
||||||
isPrimary = true,
|
isPrimary = true,
|
||||||
tint = Palette.Teal,
|
tint = Palette.Teal,
|
||||||
iconColor = Color.White,
|
iconColor = Color.White,
|
||||||
@@ -264,7 +266,7 @@ fun LiveCallScreen(
|
|||||||
CallAction(
|
CallAction(
|
||||||
"mute",
|
"mute",
|
||||||
if (call.muted) KitIcon.MicOff else KitIcon.Mic,
|
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,
|
tint = if (call.muted) MaterialTheme.colorScheme.primary else null,
|
||||||
iconColor = if (call.muted) Color.White else null,
|
iconColor = if (call.muted) Color.White else null,
|
||||||
run = { model.setCallMuted(!call.muted) },
|
run = { model.setCallMuted(!call.muted) },
|
||||||
@@ -272,7 +274,7 @@ fun LiveCallScreen(
|
|||||||
CallAction(
|
CallAction(
|
||||||
"end",
|
"end",
|
||||||
KitIcon.CallEnd,
|
KitIcon.CallEnd,
|
||||||
"End",
|
stringResource(R.string.set_call_end),
|
||||||
isPrimary = true,
|
isPrimary = true,
|
||||||
tint = MaterialTheme.colorScheme.error,
|
tint = MaterialTheme.colorScheme.error,
|
||||||
iconColor = Color.White,
|
iconColor = Color.White,
|
||||||
@@ -287,7 +289,7 @@ fun LiveCallScreen(
|
|||||||
CallAction(
|
CallAction(
|
||||||
"handoff",
|
"handoff",
|
||||||
KitIcon.Handoff,
|
KitIcon.Handoff,
|
||||||
"Move",
|
stringResource(R.string.set_call_move),
|
||||||
run = model::offerHandoff,
|
run = model::offerHandoff,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -301,7 +303,7 @@ fun LiveCallScreen(
|
|||||||
CallAction(
|
CallAction(
|
||||||
"end",
|
"end",
|
||||||
KitIcon.CallEnd,
|
KitIcon.CallEnd,
|
||||||
"Cancel",
|
stringResource(R.string.common_cancel),
|
||||||
isPrimary = true,
|
isPrimary = true,
|
||||||
tint = MaterialTheme.colorScheme.error,
|
tint = MaterialTheme.colorScheme.error,
|
||||||
iconColor = Color.White,
|
iconColor = Color.White,
|
||||||
@@ -346,8 +348,7 @@ fun LiveCallScreen(
|
|||||||
floating = floating,
|
floating = floating,
|
||||||
warning =
|
warning =
|
||||||
if (call.relayless && call.state != CallManager.State.ACTIVE) {
|
if (call.relayless && call.state != CallManager.State.ACTIVE) {
|
||||||
"This server has no call relay, so the call only connects if a " +
|
stringResource(R.string.set_call_no_relay_warning)
|
||||||
"direct path exists between the two networks."
|
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
@@ -381,7 +382,7 @@ private fun VideoCallBody(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
"Waiting for their camera…",
|
stringResource(R.string.set_call_waiting_camera),
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
)
|
)
|
||||||
@@ -558,9 +559,9 @@ fun ColumnScope.HandoffBanner(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(Space.sm),
|
horizontalArrangement = Arrangement.spacedBy(Space.sm),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
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(
|
Text(
|
||||||
"On your other device, with ${phase.call.peerName}",
|
stringResource(R.string.set_call_take_other_device, phase.call.peerName),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
@@ -568,7 +569,7 @@ fun ColumnScope.HandoffBanner(
|
|||||||
TextButton(
|
TextButton(
|
||||||
onClick = { model.takeHandoff() },
|
onClick = { model.takeHandoff() },
|
||||||
modifier = Modifier.testTag("handoff-take"),
|
modifier = Modifier.testTag("handoff-take"),
|
||||||
) { Text("Take") }
|
) { Text(stringResource(R.string.set_call_take)) }
|
||||||
TextButton(onClick = { model.dismissHandoff() }) { Text("Not now") }
|
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.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.flamenet.messenger.R
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls placed and received, the kit's `08 Calls-Listing`.
|
* Calls placed and received, the kit's `08 Calls-Listing`.
|
||||||
@@ -41,11 +43,11 @@ fun ColumnScope.CallsScreen(
|
|||||||
androidx.compose.runtime.mutableStateOf(false)
|
androidx.compose.runtime.mutableStateOf(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
PortalHeader(title = "Calls") {
|
PortalHeader(title = stringResource(R.string.set_calls_title)) {
|
||||||
// The way IN.
|
// The way IN.
|
||||||
PortalHeaderIcon(
|
PortalHeaderIcon(
|
||||||
icon = KitIcon.Call,
|
icon = KitIcon.Call,
|
||||||
contentDescription = "New call",
|
contentDescription = stringResource(R.string.set_calls_new_call),
|
||||||
modifier = Modifier.testTag("new-call"),
|
modifier = Modifier.testTag("new-call"),
|
||||||
onClick = { showNewCall.value = true },
|
onClick = { showNewCall.value = true },
|
||||||
)
|
)
|
||||||
@@ -54,10 +56,10 @@ fun ColumnScope.CallsScreen(
|
|||||||
if (showNewCall.value) {
|
if (showNewCall.value) {
|
||||||
androidx.compose.material3.AlertDialog(
|
androidx.compose.material3.AlertDialog(
|
||||||
onDismissRequest = { showNewCall.value = false },
|
onDismissRequest = { showNewCall.value = false },
|
||||||
title = { Text("New call") },
|
title = { Text(stringResource(R.string.set_calls_new_call)) },
|
||||||
text = {
|
text = {
|
||||||
if (phase.buddies.isEmpty()) {
|
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 {
|
} else {
|
||||||
Column {
|
Column {
|
||||||
phase.buddies.forEach { buddy ->
|
phase.buddies.forEach { buddy ->
|
||||||
@@ -87,7 +89,7 @@ fun ColumnScope.CallsScreen(
|
|||||||
Spacer(Modifier.width(Space.md))
|
Spacer(Modifier.width(Space.md))
|
||||||
Icon(
|
Icon(
|
||||||
KitIcon.Video,
|
KitIcon.Video,
|
||||||
contentDescription = "Video call",
|
contentDescription = stringResource(R.string.set_calls_video_call),
|
||||||
tint = Palette.Accent,
|
tint = Palette.Accent,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -105,7 +107,7 @@ fun ColumnScope.CallsScreen(
|
|||||||
confirmButton = {
|
confirmButton = {
|
||||||
androidx.compose.material3.TextButton(
|
androidx.compose.material3.TextButton(
|
||||||
onClick = { showNewCall.value = false },
|
onClick = { showNewCall.value = false },
|
||||||
) { Text("Cancel") }
|
) { Text(stringResource(R.string.common_cancel)) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -123,12 +125,9 @@ fun ColumnScope.CallsScreen(
|
|||||||
tint = Palette.SecondaryText,
|
tint = Palette.SecondaryText,
|
||||||
modifier = Modifier.size(Space.xxl),
|
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(
|
Text(
|
||||||
"Calls you place and receive appear here. Start one from a " +
|
stringResource(R.string.set_calls_empty_body),
|
||||||
"conversation, they go directly between the two phones, " +
|
|
||||||
"so this list is the only record of them and it is on " +
|
|
||||||
"this device.",
|
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
@@ -142,7 +141,7 @@ fun ColumnScope.CallsScreen(
|
|||||||
phase.buddies
|
phase.buddies
|
||||||
.firstOrNull { it.userId == entry.peerId }
|
.firstOrNull { it.userId == entry.peerId }
|
||||||
?.let { it.displayName.ifBlank { it.handle } }
|
?.let { it.displayName.ifBlank { it.handle } }
|
||||||
?: "user ${entry.peerId}"
|
?: stringResource(R.string.set_calls_user_fallback, entry.peerId)
|
||||||
PortalRow(
|
PortalRow(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -176,12 +175,15 @@ fun ColumnScope.CallsScreen(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
Text(
|
Text(
|
||||||
buildString {
|
stringResource(
|
||||||
append(if (entry.incoming) "Incoming" else "Outgoing")
|
when {
|
||||||
if (!entry.connected) append(" · missed")
|
entry.incoming && entry.connected -> R.string.set_calls_line_incoming
|
||||||
append(" · ")
|
entry.incoming -> R.string.set_calls_line_incoming_missed
|
||||||
append(relativeTime(entry.at))
|
entry.connected -> R.string.set_calls_line_outgoing
|
||||||
},
|
else -> R.string.set_calls_line_outgoing_missed
|
||||||
|
},
|
||||||
|
relativeTime(entry.at),
|
||||||
|
),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = if (entry.connected) Palette.SecondaryText else Palette.Pink,
|
color = if (entry.connected) Palette.SecondaryText else Palette.Pink,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -201,7 +203,7 @@ fun ColumnScope.CallsScreen(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
KitIcon.Video,
|
KitIcon.Video,
|
||||||
contentDescription = "Video call $name",
|
contentDescription = stringResource(R.string.set_calls_video_call_name, name),
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.size(Kit.icon),
|
modifier = Modifier.size(Kit.icon),
|
||||||
)
|
)
|
||||||
@@ -219,7 +221,7 @@ fun ColumnScope.CallsScreen(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
KitIcon.Call,
|
KitIcon.Call,
|
||||||
contentDescription = "Call $name",
|
contentDescription = stringResource(R.string.set_calls_call_name, name),
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.size(Kit.icon),
|
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.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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 androidx.compose.ui.unit.sp
|
||||||
import io.flamenet.client.MessengerApi
|
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
|
* 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) }
|
var confirmingRemove by remember { mutableStateOf(false) }
|
||||||
val isBlocked = buddy.userId in phase.blocked
|
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 =
|
val initials =
|
||||||
remember(name) {
|
remember(name) {
|
||||||
name
|
name
|
||||||
@@ -71,7 +75,7 @@ fun ColumnScope.ContactDetail(
|
|||||||
.size(Kit.icon + Space.md)
|
.size(Kit.icon + Space.md)
|
||||||
.clip(RoundedCornerShape(Radius.small))
|
.clip(RoundedCornerShape(Radius.small))
|
||||||
.clickable(onClick = onClose)
|
.clickable(onClick = onClose)
|
||||||
.semantics { contentDescription = "Back" }
|
.semantics { contentDescription = backLabel }
|
||||||
.testTag("contact-back"),
|
.testTag("contact-back"),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
@@ -137,7 +141,7 @@ fun ColumnScope.ContactDetail(
|
|||||||
buddy.handle
|
buddy.handle
|
||||||
.takeIf { it.isNotBlank() && !it.equals(name, true) }
|
.takeIf { it.isNotBlank() && !it.equals(name, true) }
|
||||||
?.let { "@$it" },
|
?.let { "@$it" },
|
||||||
buddy.presence.takeIf { it.isNotBlank() },
|
presenceLabel(buddy.presence).takeIf { it.isNotBlank() },
|
||||||
).joinToString(" · "),
|
).joinToString(" · "),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
@@ -163,18 +167,18 @@ fun ColumnScope.ContactDetail(
|
|||||||
if (isBlocked) Palette.SecondaryText else MaterialTheme.colorScheme.primary,
|
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)
|
model.openChat(buddy.userId, buddy.displayName)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
RailDivider()
|
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)
|
model.requestCall(buddy.userId)
|
||||||
}
|
}
|
||||||
RailDivider()
|
RailDivider()
|
||||||
RailButton(
|
RailButton(
|
||||||
if (isBlocked) KitIcon.PersonAdd else KitIcon.Block,
|
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",
|
"contact-block",
|
||||||
) { model.setBlocked(buddy.userId, !isBlocked) }
|
) { model.setBlocked(buddy.userId, !isBlocked) }
|
||||||
}
|
}
|
||||||
@@ -182,18 +186,19 @@ fun ColumnScope.ContactDetail(
|
|||||||
|
|
||||||
Spacer(Modifier.height(Space.xl))
|
Spacer(Modifier.height(Space.xl))
|
||||||
|
|
||||||
PortalBand("Their details")
|
PortalBand(stringResource(R.string.social_their_details))
|
||||||
Column(Modifier.padding(vertical = Space.sm)) {
|
Column(Modifier.padding(vertical = Space.sm)) {
|
||||||
DetailRow("Username", buddy.handle.ifBlank { ", " })
|
DetailRow(stringResource(R.string.social_detail_username), buddy.handle.ifBlank { ", " })
|
||||||
DetailRow("User ID", buddy.userId.toString())
|
DetailRow(stringResource(R.string.social_detail_user_id), buddy.userId.toString())
|
||||||
DetailRow("Status", buddy.presence.ifBlank { "unknown" })
|
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)) {
|
Column(Modifier.padding(vertical = Space.sm)) {
|
||||||
DetailRow(
|
DetailRow(
|
||||||
"Clear this conversation",
|
stringResource(R.string.social_clear_conversation),
|
||||||
"Removes it from this phone only. Their copy is theirs.",
|
stringResource(R.string.social_clear_conversation_note),
|
||||||
tag = "contact-clear",
|
tag = "contact-clear",
|
||||||
) {
|
) {
|
||||||
model.clearHistory(
|
model.clearHistory(
|
||||||
@@ -204,8 +209,8 @@ fun ColumnScope.ContactDetail(
|
|||||||
|
|
||||||
// Confirms in place rather than in a dialog.
|
// Confirms in place rather than in a dialog.
|
||||||
DetailRow(
|
DetailRow(
|
||||||
if (confirmingRemove) "Really remove, this cannot be undone" else "Remove contact",
|
if (confirmingRemove) stringResource(R.string.social_really_remove) else stringResource(R.string.social_remove_contact),
|
||||||
"Takes them off your list. It does not block them.",
|
stringResource(R.string.social_remove_contact_note),
|
||||||
tag = "contact-remove",
|
tag = "contact-remove",
|
||||||
danger = true,
|
danger = true,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -29,11 +29,13 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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 androidx.compose.ui.unit.sp
|
||||||
|
import io.flamenet.messenger.R
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crashes and freezes this phone recorded about this app.
|
* Crashes and freezes this phone recorded about this app.
|
||||||
@@ -62,12 +64,9 @@ fun ColumnScope.DiagnosticsSection() {
|
|||||||
internal fun ColumnScope.DiagnosticsSection(reports: List<AppDiagnostics.Report>) {
|
internal fun ColumnScope.DiagnosticsSection(reports: List<AppDiagnostics.Report>) {
|
||||||
var open by remember { mutableStateOf<AppDiagnostics.Report?>(null) }
|
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(
|
Text(
|
||||||
"Nothing here has left your phone. Android records a report when the app " +
|
stringResource(R.string.set_diag_intro),
|
||||||
"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.",
|
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
@@ -75,7 +74,7 @@ internal fun ColumnScope.DiagnosticsSection(reports: List<AppDiagnostics.Report>
|
|||||||
|
|
||||||
if (reports.isEmpty()) {
|
if (reports.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
"No crashes or freezes recorded.",
|
stringResource(R.string.set_diag_empty),
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
modifier = Modifier.testTag("diagnostics-empty"),
|
modifier = Modifier.testTag("diagnostics-empty"),
|
||||||
@@ -92,7 +91,7 @@ internal fun ColumnScope.DiagnosticsSection(reports: List<AppDiagnostics.Report>
|
|||||||
.padding(vertical = Space.xs)
|
.padding(vertical = Space.xs)
|
||||||
.testTag("diagnostic-${report.id}"),
|
.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(
|
Text(
|
||||||
report.summary,
|
report.summary,
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
@@ -100,7 +99,7 @@ internal fun ColumnScope.DiagnosticsSection(reports: List<AppDiagnostics.Report>
|
|||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"${AppDiagnostics.stamp(report.at)} · build ${report.appVersion}",
|
stringResource(R.string.set_diag_stamp_build, AppDiagnostics.stamp(report.at), report.appVersion),
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
@@ -130,6 +129,7 @@ private fun ReportDialog(
|
|||||||
val clipboard = LocalClipboardManager.current
|
val clipboard = LocalClipboardManager.current
|
||||||
var saved by remember { mutableStateOf<String?>(null) }
|
var saved by remember { mutableStateOf<String?>(null) }
|
||||||
var copied by remember { mutableStateOf(false) }
|
var copied by remember { mutableStateOf(false) }
|
||||||
|
val savedMessage = stringResource(R.string.set_diag_saved)
|
||||||
|
|
||||||
val saveLauncher =
|
val saveLauncher =
|
||||||
rememberLauncherForActivityResult(
|
rememberLauncherForActivityResult(
|
||||||
@@ -142,14 +142,14 @@ private fun ReportDialog(
|
|||||||
it.write(report.detail.toByteArray(Charsets.UTF_8))
|
it.write(report.detail.toByteArray(Charsets.UTF_8))
|
||||||
} ?: error("nothing to write to")
|
} ?: error("nothing to write to")
|
||||||
}.fold(
|
}.fold(
|
||||||
onSuccess = { "Saved. Attach it to a message to the address above." },
|
onSuccess = { savedMessage },
|
||||||
onFailure = { e -> "Couldn't write the file: ${e.message}" },
|
onFailure = { e -> context.getString(R.string.set_diag_write_failed, e.message) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text(report.kind.title) },
|
title = { Text(kindTitle(report.kind)) },
|
||||||
// Everything except the trace is fixed, and only the trace scrolls.
|
// Everything except the trace is fixed, and only the trace scrolls.
|
||||||
// With the whole dialog in one scrolling column the address and the
|
// 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
|
// save button sat below the explanation and off the bottom of a small
|
||||||
@@ -158,7 +158,7 @@ private fun ReportDialog(
|
|||||||
text = {
|
text = {
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
"${AppDiagnostics.stamp(report.at)} · build ${report.appVersion}",
|
stringResource(R.string.set_diag_stamp_build, AppDiagnostics.stamp(report.at), report.appVersion),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
@@ -168,9 +168,7 @@ private fun ReportDialog(
|
|||||||
// which is the difference between an informed share and a
|
// which is the difference between an informed share and a
|
||||||
// hopeful one.
|
// hopeful one.
|
||||||
Text(
|
Text(
|
||||||
"This report names the parts of Flamenet that were running and the kind " +
|
stringResource(R.string.set_diag_report_contents),
|
||||||
"of phone you have. It does not contain your messages, your contacts " +
|
|
||||||
"or your keys.",
|
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
@@ -182,12 +180,12 @@ private fun ReportDialog(
|
|||||||
copied = true
|
copied = true
|
||||||
},
|
},
|
||||||
modifier = Modifier.testTag("diagnostics-address"),
|
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))
|
Spacer(Modifier.width(Space.sm))
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { saveLauncher.launch(fileName(report)) },
|
onClick = { saveLauncher.launch(fileName(report)) },
|
||||||
modifier = Modifier.testTag("diagnostics-save"),
|
modifier = Modifier.testTag("diagnostics-save"),
|
||||||
) { Text("Save…") }
|
) { Text(stringResource(R.string.set_diag_save)) }
|
||||||
}
|
}
|
||||||
saved?.let {
|
saved?.let {
|
||||||
Spacer(Modifier.height(Space.xs))
|
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. */
|
/** A name somebody can find again in their downloads. */
|
||||||
internal fun fileName(report: AppDiagnostics.Report): String =
|
internal fun fileName(report: AppDiagnostics.Report): String =
|
||||||
"flamenet-${report.kind.name.lowercase()}-${report.at}.txt"
|
"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.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.flamenet.messenger.R
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How this phone draws things.
|
* How this phone draws things.
|
||||||
@@ -32,7 +34,7 @@ fun ColumnScope.DisplaySection() {
|
|||||||
var larger by remember { mutableStateOf(settings.largerType) }
|
var larger by remember { mutableStateOf(settings.largerType) }
|
||||||
var appearance by remember { mutableStateOf(settings.appearance) }
|
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
|
@Composable
|
||||||
fun row(
|
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
|
settings.showOnlineStatus = on
|
||||||
online = 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
|
settings.showTimestamps = on
|
||||||
times = 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
|
settings.largerType = on
|
||||||
larger = on
|
larger = on
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(Space.sm))
|
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)) {
|
Row(modifier = Modifier.fillMaxWidth().padding(vertical = Space.xs)) {
|
||||||
DisplaySettings.Appearance.entries.forEach { option ->
|
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(
|
KitSecondaryButton(
|
||||||
text = if (option == appearance) "✓ ${option.label}" else option.label,
|
text = if (option == appearance) stringResource(R.string.set_display_selected, label) else label,
|
||||||
onClick = {
|
onClick = {
|
||||||
settings.appearance = option
|
settings.appearance = option
|
||||||
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
|
// Says the thing the label alone cannot: this hides them from you, not you
|
||||||
// from them.
|
// from them.
|
||||||
Text(
|
Text(
|
||||||
"These change what this phone shows you. Your contacts are still told when " +
|
stringResource(R.string.set_display_footer),
|
||||||
"you are online whichever way this is set, hiding yourself is not " +
|
|
||||||
"something the app can do yet.\n\nAppearance takes effect next time the " +
|
|
||||||
"app starts.",
|
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
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.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import io.flamenet.client.LocalVault
|
import io.flamenet.client.LocalVault
|
||||||
|
import io.flamenet.messenger.R
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -51,7 +54,7 @@ fun ColumnScope.FilesScreen(
|
|||||||
) {
|
) {
|
||||||
val files = phase.files ?: MessengerViewModel.FilesState()
|
val files = phase.files ?: MessengerViewModel.FilesState()
|
||||||
|
|
||||||
PortalHeader(title = "Files")
|
PortalHeader(title = stringResource(R.string.main_files))
|
||||||
|
|
||||||
when {
|
when {
|
||||||
!files.hasIdentity -> FilesSetUp(model, files)
|
!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
|
// iOS never did. iOS says "your passphrase" and takes whatever is typed, so
|
||||||
// a person naturally uses their Flamenet one and it simply works.
|
// a person naturally uses their Flamenet one and it simply works.
|
||||||
Text(
|
Text(
|
||||||
"Use your Flamenet passphrase, the one you sign in with. Files on " +
|
stringResource(R.string.main_files_setup_intro),
|
||||||
"this phone are encrypted with it, and Flamenet keeps no copy, so " +
|
|
||||||
"nobody can open them by picking up your unlocked phone.",
|
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(Space.sm))
|
||||||
Text(
|
Text(
|
||||||
"If you forget it, the files are gone for good, there's no reset, " +
|
stringResource(R.string.main_files_setup_warning),
|
||||||
"because there's nothing anywhere that could do one.",
|
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
color = Palette.Warning,
|
color = Palette.Warning,
|
||||||
)
|
)
|
||||||
@@ -122,7 +122,7 @@ private fun ColumnScope.FilesSetUp(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = passphrase,
|
value = passphrase,
|
||||||
onValueChange = { passphrase = it },
|
onValueChange = { passphrase = it },
|
||||||
label = { Text("Your Flamenet passphrase") },
|
label = { Text(stringResource(R.string.main_files_your_passphrase)) },
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth().testTag("files-new-passphrase"),
|
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
|
// already sign in with, so asking twice only suggested it was a different
|
||||||
// one, which is the opposite of what the paragraph above says.
|
// one, which is the opposite of what the paragraph above says.
|
||||||
KitPrimaryButton(
|
KitPrimaryButton(
|
||||||
text = files.busy ?: "Set up Files",
|
text = files.busy ?: stringResource(R.string.main_files_set_up),
|
||||||
onClick = { model.createFilesIdentity(passphrase) },
|
onClick = { model.createFilesIdentity(passphrase) },
|
||||||
enabled = files.busy == null && passphrase.isNotEmpty(),
|
enabled = files.busy == null && passphrase.isNotEmpty(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -150,7 +150,7 @@ private fun ColumnScope.FilesUnlock(
|
|||||||
|
|
||||||
// Not "your Files passphrase".
|
// Not "your Files passphrase".
|
||||||
Text(
|
Text(
|
||||||
"Enter your Flamenet passphrase to open them.",
|
stringResource(R.string.main_files_unlock_prompt),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
@@ -158,14 +158,14 @@ private fun ColumnScope.FilesUnlock(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = passphrase,
|
value = passphrase,
|
||||||
onValueChange = { passphrase = it },
|
onValueChange = { passphrase = it },
|
||||||
label = { Text("Passphrase") },
|
label = { Text(stringResource(R.string.main_passphrase)) },
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth().testTag("files-passphrase"),
|
modifier = Modifier.fillMaxWidth().testTag("files-passphrase"),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.md))
|
Spacer(Modifier.height(Space.md))
|
||||||
KitPrimaryButton(
|
KitPrimaryButton(
|
||||||
text = files.busy ?: "Unlock",
|
text = files.busy ?: stringResource(R.string.common_unlock),
|
||||||
onClick = { model.unlockFiles(passphrase) },
|
onClick = { model.unlockFiles(passphrase) },
|
||||||
enabled = files.busy == null && passphrase.isNotEmpty(),
|
enabled = files.busy == null && passphrase.isNotEmpty(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -183,6 +183,7 @@ private fun ColumnScope.FilesBrowser(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var pending by remember { mutableStateOf<PendingFile?>(null) }
|
var pending by remember { mutableStateOf<PendingFile?>(null) }
|
||||||
var namingFolder by remember { mutableStateOf(false) }
|
var namingFolder by remember { mutableStateOf(false) }
|
||||||
|
val untitled = stringResource(R.string.main_files_untitled)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `OpenDocument`, never `GetContent`.
|
* `OpenDocument`, never `GetContent`.
|
||||||
@@ -191,7 +192,7 @@ private fun ColumnScope.FilesBrowser(
|
|||||||
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
uri ?: return@rememberLauncherForActivityResult
|
uri ?: return@rememberLauncherForActivityResult
|
||||||
val saf = SafReferences(context)
|
val saf = SafReferences(context)
|
||||||
pending = PendingFile(uri, saf.displayName(uri) ?: "Untitled")
|
pending = PendingFile(uri, saf.displayName(uri) ?: untitled)
|
||||||
}
|
}
|
||||||
|
|
||||||
FilesSectionSwitch(model, files)
|
FilesSectionSwitch(model, files)
|
||||||
@@ -203,10 +204,9 @@ private fun ColumnScope.FilesBrowser(
|
|||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
val empty =
|
val empty =
|
||||||
if (files.section == LocalVault.Storage.VAULT) {
|
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 {
|
} else {
|
||||||
"No linked files here. A link points at a file where it already is, " +
|
stringResource(R.string.main_files_linked_empty)
|
||||||
"nothing is copied, and nothing is encrypted until you send it."
|
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(Space.md))
|
Spacer(Modifier.height(Space.md))
|
||||||
Text(empty, fontSize = 13.sp, color = Palette.SecondaryText)
|
Text(empty, fontSize = 13.sp, color = Palette.SecondaryText)
|
||||||
@@ -218,14 +218,14 @@ private fun ColumnScope.FilesBrowser(
|
|||||||
Spacer(Modifier.height(Space.md))
|
Spacer(Modifier.height(Space.md))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||||
KitSecondaryButton(
|
KitSecondaryButton(
|
||||||
text = "Add a file",
|
text = stringResource(R.string.main_files_add_file),
|
||||||
// Everything: the vault does not care what a file is.
|
// Everything: the vault does not care what a file is.
|
||||||
onClick = { pick.launch(arrayOf("*/*")) },
|
onClick = { pick.launch(arrayOf("*/*")) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
tag = "files-add",
|
tag = "files-add",
|
||||||
)
|
)
|
||||||
KitSecondaryButton(
|
KitSecondaryButton(
|
||||||
text = "New folder",
|
text = stringResource(R.string.main_files_new_folder),
|
||||||
onClick = { namingFolder = true },
|
onClick = { namingFolder = true },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
tag = "files-new-folder",
|
tag = "files-new-folder",
|
||||||
@@ -234,14 +234,14 @@ private fun ColumnScope.FilesBrowser(
|
|||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(Space.sm))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||||
KitSecondaryButton(
|
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() },
|
onClick = { model.filesToggleSelecting() },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
tag = "files-select",
|
tag = "files-select",
|
||||||
)
|
)
|
||||||
if (files.selecting) {
|
if (files.selecting) {
|
||||||
KitSecondaryButton(
|
KitSecondaryButton(
|
||||||
text = "Delete ${files.selected.size}",
|
text = stringResource(R.string.main_files_delete_count, files.selected.size),
|
||||||
onClick = { model.filesDeleteSelected() },
|
onClick = { model.filesDeleteSelected() },
|
||||||
enabled = files.selected.isNotEmpty(),
|
enabled = files.selected.isNotEmpty(),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
@@ -249,7 +249,7 @@ private fun ColumnScope.FilesBrowser(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
KitSecondaryButton(
|
KitSecondaryButton(
|
||||||
text = "Lock",
|
text = stringResource(R.string.main_files_lock),
|
||||||
onClick = { model.lockFiles() },
|
onClick = { model.lockFiles() },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
tag = "files-lock",
|
tag = "files-lock",
|
||||||
@@ -296,13 +296,13 @@ private fun ColumnScope.FilesSectionSwitch(
|
|||||||
) {
|
) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||||
KitChip(
|
KitChip(
|
||||||
text = "In the vault",
|
text = stringResource(R.string.main_files_in_vault),
|
||||||
selected = files.section == LocalVault.Storage.VAULT,
|
selected = files.section == LocalVault.Storage.VAULT,
|
||||||
onClick = { model.filesSetSection(LocalVault.Storage.VAULT) },
|
onClick = { model.filesSetSection(LocalVault.Storage.VAULT) },
|
||||||
tag = "files-section-vault",
|
tag = "files-section-vault",
|
||||||
)
|
)
|
||||||
KitChip(
|
KitChip(
|
||||||
text = "Linked",
|
text = stringResource(R.string.main_files_linked),
|
||||||
selected = files.section == LocalVault.Storage.REFERENCE,
|
selected = files.section == LocalVault.Storage.REFERENCE,
|
||||||
onClick = { model.filesSetSection(LocalVault.Storage.REFERENCE) },
|
onClick = { model.filesSetSection(LocalVault.Storage.REFERENCE) },
|
||||||
tag = "files-section-linked",
|
tag = "files-section-linked",
|
||||||
@@ -321,7 +321,7 @@ private fun ColumnScope.FilesBreadcrumb(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"Files",
|
stringResource(R.string.main_files),
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
color = Palette.Accent,
|
color = Palette.Accent,
|
||||||
modifier = Modifier.clickable { model.filesGoTo(0) }.testTag("files-crumb-root"),
|
modifier = Modifier.clickable { model.filesGoTo(0) }.testTag("files-crumb-root"),
|
||||||
@@ -370,7 +370,7 @@ private fun ColumnScope.FileRow(
|
|||||||
Text(
|
Text(
|
||||||
// A row this phone cannot read is still SHOWN, with its
|
// A row this phone cannot read is still SHOWN, with its
|
||||||
// classification.
|
// classification.
|
||||||
text = entry.name ?: "Encrypted for someone else",
|
text = entry.name ?: stringResource(R.string.main_files_encrypted_for_other),
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = if (entry.isFolder) FontWeight.Medium else FontWeight.Normal,
|
fontWeight = if (entry.isFolder) FontWeight.Medium else FontWeight.Normal,
|
||||||
color = if (entry.name == null) Palette.SecondaryText else MaterialTheme.colorScheme.onSurface,
|
color = if (entry.name == null) Palette.SecondaryText else MaterialTheme.colorScheme.onSurface,
|
||||||
@@ -384,7 +384,7 @@ private fun ColumnScope.FileRow(
|
|||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
if (entry.isMissing) {
|
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) {
|
} else if (!entry.isFolder && entry.byteCount > 0) {
|
||||||
Text(
|
Text(
|
||||||
" · ${bytes(entry.byteCount)}",
|
" · ${bytes(entry.byteCount)}",
|
||||||
@@ -400,7 +400,7 @@ private fun ColumnScope.FileRow(
|
|||||||
!entry.isFolder && !entry.isMissing
|
!entry.isFolder && !entry.isMissing
|
||||||
) {
|
) {
|
||||||
KitSecondaryButton(
|
KitSecondaryButton(
|
||||||
text = "Encrypt",
|
text = stringResource(R.string.main_files_encrypt),
|
||||||
onClick = { model.filesEncrypt(entry.id) },
|
onClick = { model.filesEncrypt(entry.id) },
|
||||||
tag = "file-encrypt",
|
tag = "file-encrypt",
|
||||||
)
|
)
|
||||||
@@ -408,11 +408,12 @@ private fun ColumnScope.FileRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
private fun bytes(count: Int): String =
|
private fun bytes(count: Int): String =
|
||||||
when {
|
when {
|
||||||
count >= 1_000_000 -> "%.1f MB".format(count / 1_000_000.0)
|
count >= 1_000_000 -> stringResource(R.string.main_files_size_mb, count / 1_000_000.0)
|
||||||
count >= 1_000 -> "%.0f kB".format(count / 1_000.0)
|
count >= 1_000 -> stringResource(R.string.main_files_size_kb, count / 1_000.0)
|
||||||
else -> "$count bytes"
|
else -> pluralStringResource(R.plurals.main_files_size_bytes, count, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Sheets --------------------------------------------------------------.
|
// ---- Sheets --------------------------------------------------------------.
|
||||||
@@ -442,12 +443,12 @@ private fun ColumnScope.AddFileSheet(
|
|||||||
.padding(Space.md)
|
.padding(Space.md)
|
||||||
.testTag("files-add-sheet"),
|
.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))
|
Spacer(Modifier.height(Space.sm))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
label = { Text("Name") },
|
label = { Text(stringResource(R.string.common_name)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth().testTag("files-add-name"),
|
modifier = Modifier.fillMaxWidth().testTag("files-add-name"),
|
||||||
)
|
)
|
||||||
@@ -458,36 +459,33 @@ private fun ColumnScope.AddFileSheet(
|
|||||||
Spacer(Modifier.height(Space.md))
|
Spacer(Modifier.height(Space.md))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Copy it in and encrypt it. A stolen phone or backup gives up " +
|
stringResource(R.string.main_files_copy_in_note),
|
||||||
"nothing but ciphertext.",
|
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.xs))
|
Spacer(Modifier.height(Space.xs))
|
||||||
KitPrimaryButton(
|
KitPrimaryButton(
|
||||||
text = "Copy in and encrypt",
|
text = stringResource(R.string.main_files_copy_in),
|
||||||
onClick = { onVault(name.trim().ifEmpty { file.name }, level, areas) },
|
onClick = { onVault(name.trim().ifEmpty { file.name }, level, areas) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
tag = "files-add-vault",
|
tag = "files-add-vault",
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.md))
|
Spacer(Modifier.height(Space.md))
|
||||||
Text(
|
Text(
|
||||||
"Or link to it where it is. Nothing is copied and nothing is " +
|
stringResource(R.string.main_files_link_note),
|
||||||
"encrypted until you send it, until then the classification " +
|
|
||||||
"is a note, not a lock.",
|
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.xs))
|
Spacer(Modifier.height(Space.xs))
|
||||||
KitSecondaryButton(
|
KitSecondaryButton(
|
||||||
text = "Link to it instead",
|
text = stringResource(R.string.main_files_link_instead),
|
||||||
onClick = { onLink(name.trim().ifEmpty { file.name }, level, areas) },
|
onClick = { onLink(name.trim().ifEmpty { file.name }, level, areas) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
tag = "files-add-link",
|
tag = "files-add-link",
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(Space.sm))
|
||||||
KitSecondaryButton(
|
KitSecondaryButton(
|
||||||
text = "Cancel",
|
text = stringResource(R.string.common_cancel),
|
||||||
onClick = onCancel,
|
onClick = onCancel,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
tag = "files-add-cancel",
|
tag = "files-add-cancel",
|
||||||
@@ -514,12 +512,12 @@ private fun ColumnScope.FolderNameSheet(
|
|||||||
.padding(Space.md)
|
.padding(Space.md)
|
||||||
.testTag("files-folder-sheet"),
|
.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))
|
Spacer(Modifier.height(Space.sm))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
label = { Text("Name") },
|
label = { Text(stringResource(R.string.common_name)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth().testTag("files-folder-name"),
|
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
|
// Said here because the tree deliberately allows the opposite of what
|
||||||
// people assume: a folder's level does not constrain what goes in it.
|
// people assume: a folder's level does not constrain what goes in it.
|
||||||
Text(
|
Text(
|
||||||
"A folder's level doesn't limit what you put inside it, each file " +
|
stringResource(R.string.main_files_folder_level_note),
|
||||||
"keeps its own.",
|
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.md))
|
Spacer(Modifier.height(Space.md))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||||
KitSecondaryButton(
|
KitSecondaryButton(
|
||||||
text = "Cancel",
|
text = stringResource(R.string.common_cancel),
|
||||||
onClick = onCancel,
|
onClick = onCancel,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
tag = "files-folder-cancel",
|
tag = "files-folder-cancel",
|
||||||
)
|
)
|
||||||
KitPrimaryButton(
|
KitPrimaryButton(
|
||||||
text = "Create",
|
text = stringResource(R.string.main_files_create),
|
||||||
onClick = { onCreate(name, level, areas) },
|
onClick = { onCreate(name, level, areas) },
|
||||||
enabled = name.isNotBlank(),
|
enabled = name.isNotBlank(),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
@@ -572,12 +569,11 @@ private fun ColumnScope.AreaPicker(
|
|||||||
var adding by remember { mutableStateOf("") }
|
var adding by remember { mutableStateOf("") }
|
||||||
var renaming by remember { mutableStateOf<Pair<Int, String>?>(null) }
|
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))
|
Spacer(Modifier.height(Space.xs))
|
||||||
if (areas.isEmpty()) {
|
if (areas.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
"Nothing here is named for you. Add the words your organisation " +
|
stringResource(R.string.main_files_areas_empty),
|
||||||
"already uses.",
|
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
modifier = Modifier.testTag("files-areas-empty"),
|
modifier = Modifier.testTag("files-areas-empty"),
|
||||||
@@ -607,7 +603,7 @@ private fun ColumnScope.AreaPicker(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = adding,
|
value = adding,
|
||||||
onValueChange = { adding = it },
|
onValueChange = { adding = it },
|
||||||
label = { Text("Add an area") },
|
label = { Text(stringResource(R.string.main_files_add_area)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.weight(1f).testTag("files-area-new"),
|
modifier = Modifier.weight(1f).testTag("files-area-new"),
|
||||||
)
|
)
|
||||||
@@ -619,13 +615,12 @@ private fun ColumnScope.AreaPicker(
|
|||||||
areas = LocalAreas.all(context)
|
areas = LocalAreas.all(context)
|
||||||
},
|
},
|
||||||
modifier = Modifier.testTag("files-area-add"),
|
modifier = Modifier.testTag("files-area-add"),
|
||||||
) { Text("Add") }
|
) { Text(stringResource(R.string.common_add)) }
|
||||||
}
|
}
|
||||||
if (areas.isNotEmpty()) {
|
if (areas.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(Space.xs))
|
Spacer(Modifier.height(Space.xs))
|
||||||
Text(
|
Text(
|
||||||
"Long-press an area to rename or forget it. A file carries the area " +
|
stringResource(R.string.main_files_areas_hint),
|
||||||
"itself; the name is this phone's.",
|
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
@@ -634,7 +629,7 @@ private fun ColumnScope.AreaPicker(
|
|||||||
var text by remember(bit) { mutableStateOf(current) }
|
var text by remember(bit) { mutableStateOf(current) }
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { renaming = null },
|
onDismissRequest = { renaming = null },
|
||||||
title = { Text("Rename area") },
|
title = { Text(stringResource(R.string.main_files_rename_area)) },
|
||||||
text = {
|
text = {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = text,
|
value = text,
|
||||||
@@ -648,14 +643,14 @@ private fun ColumnScope.AreaPicker(
|
|||||||
LocalAreas.rename(context, bit, text)
|
LocalAreas.rename(context, bit, text)
|
||||||
renaming = null
|
renaming = null
|
||||||
areas = LocalAreas.all(context)
|
areas = LocalAreas.all(context)
|
||||||
}) { Text("Rename") }
|
}) { Text(stringResource(R.string.common_rename)) }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
LocalAreas.forget(context, bit)
|
LocalAreas.forget(context, bit)
|
||||||
renaming = null
|
renaming = null
|
||||||
areas = LocalAreas.all(context)
|
areas = LocalAreas.all(context)
|
||||||
}) { Text("Forget") }
|
}) { Text(stringResource(R.string.main_files_forget)) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -666,7 +661,7 @@ private fun ColumnScope.LevelPicker(
|
|||||||
selected: Short,
|
selected: Short,
|
||||||
onPick: (Short) -> Unit,
|
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))
|
Spacer(Modifier.height(Space.xs))
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
@@ -687,10 +682,7 @@ private fun ColumnScope.LevelPicker(
|
|||||||
// The last sentence is the honest boundary: neither app stops somebody
|
// The last sentence is the honest boundary: neither app stops somebody
|
||||||
// filing sensitive material somewhere low. See android#15.
|
// filing sensitive material somewhere low. See android#15.
|
||||||
Text(
|
Text(
|
||||||
"Who can open this. A folder's level doesn't limit what you put inside " +
|
stringResource(R.string.main_files_level_note),
|
||||||
"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.",
|
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
modifier = Modifier.testTag("files-level-note"),
|
modifier = Modifier.testTag("files-level-note"),
|
||||||
@@ -707,29 +699,35 @@ fun ColumnScope.AttachPanel(
|
|||||||
model: MessengerViewModel,
|
model: MessengerViewModel,
|
||||||
onPhoto: () -> Unit,
|
onPhoto: () -> Unit,
|
||||||
onFiles: () -> 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 =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(Radius.small))
|
.clip(RoundedCornerShape(Radius.small))
|
||||||
.background(Palette.SurfaceAlt)
|
.background(Palette.SurfaceAlt)
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
.padding(Space.md)
|
.padding(Space.md)
|
||||||
.testTag("attach-panel"),
|
.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(
|
KitSecondaryButton(
|
||||||
text = "Photo",
|
text = if (model.filesUnlocked()) stringResource(R.string.main_files_from_files) else stringResource(R.string.main_files_from_files_locked),
|
||||||
onClick = onPhoto,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
tag = "attach-photo",
|
|
||||||
)
|
|
||||||
KitSecondaryButton(
|
|
||||||
text = if (model.filesUnlocked()) "From Files" else "From Files (locked)",
|
|
||||||
onClick = onFiles,
|
onClick = onFiles,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
tag = "attach-files",
|
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)
|
.padding(Space.md)
|
||||||
.testTag("pick-file-sheet"),
|
.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))
|
Spacer(Modifier.height(Space.sm))
|
||||||
|
|
||||||
if (!unlocked) {
|
if (!unlocked) {
|
||||||
Text(
|
Text(
|
||||||
"Files is locked. Open Files from your account and unlock it, " +
|
stringResource(R.string.main_files_locked_note),
|
||||||
"then come back, your passphrase isn't asked for here.",
|
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
} else if (entries.isEmpty()) {
|
} 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 {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
"Whoever you send this to will be able to open it. The " +
|
stringResource(R.string.main_files_send_note),
|
||||||
"classification travels with the file so it's filed the " +
|
|
||||||
"same way on their phone, it doesn't stop them reading it.",
|
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.xs))
|
Spacer(Modifier.height(Space.xs))
|
||||||
// The photo button strips location; this one does not (android#17).
|
// The photo button strips location; this one does not (android#17).
|
||||||
Text(
|
Text(
|
||||||
"A file is sent exactly as it is, including anything inside " +
|
stringResource(R.string.main_files_send_metadata_note),
|
||||||
"it, like where a photo was taken. The camera button " +
|
|
||||||
"leaves that out; this doesn't.",
|
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
@@ -799,7 +792,7 @@ fun ColumnScope.PickFileToSendSheet(
|
|||||||
) {
|
) {
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
entry.name ?: "Unnamed",
|
entry.name ?: stringResource(R.string.main_files_unnamed),
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
@@ -815,7 +808,7 @@ fun ColumnScope.PickFileToSendSheet(
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(Space.sm))
|
||||||
KitSecondaryButton(
|
KitSecondaryButton(
|
||||||
text = "Cancel",
|
text = stringResource(R.string.common_cancel),
|
||||||
onClick = onCancel,
|
onClick = onCancel,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
tag = "pick-file-cancel",
|
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.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import io.flamenet.e2e.BackupPayload
|
import io.flamenet.e2e.BackupPayload
|
||||||
|
import io.flamenet.messenger.R
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@@ -57,8 +59,8 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
|||||||
context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) }
|
context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) }
|
||||||
?: error("nothing to write to")
|
?: error("nothing to write to")
|
||||||
}.fold(
|
}.fold(
|
||||||
onSuccess = { "Backup saved. Keep the passphrase, it is the only key." },
|
onSuccess = { context.getString(R.string.set_backup_saved) },
|
||||||
onFailure = { "Couldn't write the file: ${it.message}" },
|
onFailure = { context.getString(R.string.set_backup_write_failed, it.message) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,13 +69,9 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
|||||||
ActivityResultContracts.OpenDocument(),
|
ActivityResultContracts.OpenDocument(),
|
||||||
) { uri -> if (uri != null) pendingImport = uri }
|
) { 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(
|
Text(
|
||||||
"Your history lives only on this phone, the server keeps none, so a " +
|
stringResource(R.string.set_backup_intro),
|
||||||
"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.",
|
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
@@ -83,7 +81,7 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
|||||||
onClick = { showExportDialog = true },
|
onClick = { showExportDialog = true },
|
||||||
enabled = !working,
|
enabled = !working,
|
||||||
modifier = Modifier.testTag("export-history"),
|
modifier = Modifier.testTag("export-history"),
|
||||||
) { Text("Export…") }
|
) { Text(stringResource(R.string.set_backup_export)) }
|
||||||
Spacer(Modifier.width(Space.sm))
|
Spacer(Modifier.width(Space.sm))
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
// SAF filters on MIME, and a custom extension has none, offer
|
// SAF filters on MIME, and a custom extension has none, offer
|
||||||
@@ -91,7 +89,7 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
|||||||
onClick = { openLauncher.launch(arrayOf("*/*")) },
|
onClick = { openLauncher.launch(arrayOf("*/*")) },
|
||||||
enabled = !working,
|
enabled = !working,
|
||||||
modifier = Modifier.testTag("import-history"),
|
modifier = Modifier.testTag("import-history"),
|
||||||
) { Text("Import…") }
|
) { Text(stringResource(R.string.set_backup_import)) }
|
||||||
}
|
}
|
||||||
status?.let {
|
status?.let {
|
||||||
Spacer(Modifier.height(Space.xs))
|
Spacer(Modifier.height(Space.xs))
|
||||||
@@ -105,11 +103,8 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
|||||||
|
|
||||||
if (showExportDialog) {
|
if (showExportDialog) {
|
||||||
PassphraseDialog(
|
PassphraseDialog(
|
||||||
title = "Export history",
|
title = stringResource(R.string.set_backup_export_title),
|
||||||
explainer =
|
explainer = stringResource(R.string.set_backup_export_explainer),
|
||||||
"This passphrase is the only key to the backup. It isn't " +
|
|
||||||
"stored anywhere, a forgotten passphrase means the backup can " +
|
|
||||||
"never be opened.",
|
|
||||||
requireRepeat = true,
|
requireRepeat = true,
|
||||||
busy = working,
|
busy = working,
|
||||||
onDismiss = { if (!working) showExportDialog = false },
|
onDismiss = { if (!working) showExportDialog = false },
|
||||||
@@ -120,7 +115,7 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
|||||||
working = false
|
working = false
|
||||||
showExportDialog = false
|
showExportDialog = false
|
||||||
if (bytes == null) {
|
if (bytes == null) {
|
||||||
status = "Sign in first."
|
status = context.getString(R.string.set_backup_sign_in_first)
|
||||||
} else {
|
} else {
|
||||||
pendingExport = bytes
|
pendingExport = bytes
|
||||||
val stamp = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
|
val stamp = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
|
||||||
@@ -134,10 +129,8 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
|||||||
|
|
||||||
pendingImport?.let { uri ->
|
pendingImport?.let { uri ->
|
||||||
PassphraseDialog(
|
PassphraseDialog(
|
||||||
title = "Import history",
|
title = stringResource(R.string.set_backup_import_title),
|
||||||
explainer =
|
explainer = stringResource(R.string.set_backup_import_explainer),
|
||||||
"Anything already on this phone stays, the backup is " +
|
|
||||||
"merged in, never a replacement.",
|
|
||||||
requireRepeat = false,
|
requireRepeat = false,
|
||||||
busy = working,
|
busy = working,
|
||||||
onDismiss = { if (!working) pendingImport = null },
|
onDismiss = { if (!working) pendingImport = null },
|
||||||
@@ -151,7 +144,7 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
|||||||
if (bytes == null) {
|
if (bytes == null) {
|
||||||
working = false
|
working = false
|
||||||
pendingImport = null
|
pendingImport = null
|
||||||
status = "Couldn't read that file."
|
status = context.getString(R.string.set_backup_read_failed)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val outcome = model.importHistory(bytes, phrase.toCharArray())
|
val outcome = model.importHistory(bytes, phrase.toCharArray())
|
||||||
@@ -161,14 +154,24 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
|||||||
outcome.fold(
|
outcome.fold(
|
||||||
onSuccess = { counts ->
|
onSuccess = { counts ->
|
||||||
if (counts.messages + counts.stories == 0) {
|
if (counts.messages + counts.stories == 0) {
|
||||||
"Backup opened, everything in it was already here."
|
context.getString(R.string.set_backup_nothing_new)
|
||||||
} else {
|
} else {
|
||||||
"Imported ${counts.messages} message${if (counts.messages == 1) "" else "s"}" +
|
context.getString(
|
||||||
" and ${counts.stories} " +
|
R.string.set_backup_imported,
|
||||||
if (counts.stories == 1) "story." else "stories."
|
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(
|
OutlinedTextField(
|
||||||
value = phrase,
|
value = phrase,
|
||||||
onValueChange = { phrase = it },
|
onValueChange = { phrase = it },
|
||||||
placeholder = { Text("Passphrase", fontSize = 14.sp) },
|
placeholder = { Text(stringResource(R.string.set_backup_passphrase), fontSize = 14.sp) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
modifier = Modifier.fillMaxWidth().testTag("backup-passphrase"),
|
modifier = Modifier.fillMaxWidth().testTag("backup-passphrase"),
|
||||||
@@ -209,7 +212,7 @@ private fun PassphraseDialog(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = repeat,
|
value = repeat,
|
||||||
onValueChange = { repeat = it },
|
onValueChange = { repeat = it },
|
||||||
placeholder = { Text("Repeat passphrase", fontSize = 14.sp) },
|
placeholder = { Text(stringResource(R.string.set_backup_repeat_passphrase), fontSize = 14.sp) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
isError = mismatch,
|
isError = mismatch,
|
||||||
@@ -217,7 +220,7 @@ private fun PassphraseDialog(
|
|||||||
)
|
)
|
||||||
if (mismatch) {
|
if (mismatch) {
|
||||||
Text(
|
Text(
|
||||||
"The two passphrases don't match yet.",
|
stringResource(R.string.set_backup_mismatch),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
@@ -225,7 +228,7 @@ private fun PassphraseDialog(
|
|||||||
}
|
}
|
||||||
if (phrase.isNotEmpty() && phrase.length < 6) {
|
if (phrase.isNotEmpty() && phrase.length < 6) {
|
||||||
Text(
|
Text(
|
||||||
"Use at least 6 characters.",
|
stringResource(R.string.set_backup_min_length),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
@@ -237,10 +240,10 @@ private fun PassphraseDialog(
|
|||||||
onClick = { onConfirm(phrase) },
|
onClick = { onConfirm(phrase) },
|
||||||
enabled = ready && !busy,
|
enabled = ready && !busy,
|
||||||
modifier = Modifier.testTag("backup-confirm"),
|
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 = {
|
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.
|
* Records as files under `filesDir`, which is app-private storage.
|
||||||
*/
|
*/
|
||||||
private class FileStorage(
|
internal class FileStorage(
|
||||||
private val root: File,
|
private val root: File,
|
||||||
) : Vault.Storage {
|
) : Vault.Storage {
|
||||||
init {
|
init {
|
||||||
@@ -87,9 +87,13 @@ object KeystoreVault {
|
|||||||
bytes: ByteArray,
|
bytes: ByteArray,
|
||||||
) {
|
) {
|
||||||
// Write beside and rename, so a kill mid-write leaves the previous
|
// 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 target = fileFor(name)
|
||||||
val staging = File(root, target.name + ".tmp")
|
val staging = File(root, target.name + "." + java.util.UUID.randomUUID() + ".tmp")
|
||||||
staging.writeBytes(bytes)
|
staging.writeBytes(bytes)
|
||||||
if (!staging.renameTo(target)) {
|
if (!staging.renameTo(target)) {
|
||||||
staging.delete()
|
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.Mic
|
||||||
import androidx.compose.material.icons.outlined.MicOff
|
import androidx.compose.material.icons.outlined.MicOff
|
||||||
import androidx.compose.material.icons.outlined.MoreHoriz
|
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.NotificationsNone
|
||||||
import androidx.compose.material.icons.outlined.PersonAdd
|
import androidx.compose.material.icons.outlined.PersonAdd
|
||||||
import androidx.compose.material.icons.outlined.PersonOutline
|
import androidx.compose.material.icons.outlined.PersonOutline
|
||||||
@@ -76,6 +77,9 @@ object KitIcon {
|
|||||||
val Files: ImageVector = Icons.Outlined.FolderOpen
|
val Files: ImageVector = Icons.Outlined.FolderOpen
|
||||||
val Wallet: ImageVector = Icons.Outlined.AccountBalanceWallet
|
val Wallet: ImageVector = Icons.Outlined.AccountBalanceWallet
|
||||||
|
|
||||||
|
/** The Today tab: headlines first, so a newspaper. */
|
||||||
|
val Today: ImageVector = Icons.Outlined.Newspaper
|
||||||
|
|
||||||
// Composer.
|
// Composer.
|
||||||
val Attach: ImageVector = Icons.Outlined.AddCircleOutline
|
val Attach: ImageVector = Icons.Outlined.AddCircleOutline
|
||||||
val Camera: ImageVector = Icons.Outlined.PhotoCamera
|
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.platform.testTag
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -106,6 +107,10 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
|
|||||||
|
|
||||||
val lock = AppLock(this)
|
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 {
|
setContent {
|
||||||
MessengerTheme {
|
MessengerTheme {
|
||||||
// `android:windowSoftInputMode="adjustResize"` stopped doing
|
// `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) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
answerIfAsked(intent)
|
answerIfAsked(intent)
|
||||||
|
openHelloLinkIfAsked(intent)
|
||||||
|
openLinkIfAsked(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The handover: exactly one poller at a time.
|
// 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
|
// Also on the way in: a cold start from the notification arrives here
|
||||||
// rather than through onNewIntent.
|
// rather than through onNewIntent.
|
||||||
answerIfAsked(intent)
|
answerIfAsked(intent)
|
||||||
|
openLinkIfAsked(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStop() {
|
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
|
@Composable
|
||||||
private fun Root(model: MessengerViewModel = viewModel()) {
|
private fun Root(model: MessengerViewModel = viewModel()) {
|
||||||
val phase by model.phase.collectAsStateWithLifecycle()
|
val phase by model.phase.collectAsStateWithLifecycle()
|
||||||
@@ -262,7 +301,7 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
|||||||
) {
|
) {
|
||||||
when (current) {
|
when (current) {
|
||||||
is MessengerViewModel.Phase.Starting -> CircularProgressIndicator()
|
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)
|
is MessengerViewModel.Phase.VaultUnreadable -> VaultUnreadable(model, current)
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
@@ -308,6 +347,8 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
|||||||
myUserId = current.userId,
|
myUserId = current.userId,
|
||||||
groups = current.groups,
|
groups = current.groups,
|
||||||
heldMessages = current.heldMessages,
|
heldMessages = current.heldMessages,
|
||||||
|
unsealedSendAt = current.unsealedSendAt,
|
||||||
|
myName = current.handle,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// The content area is weighted, and the tab bar is not.
|
// The content area is weighted, and the tab bar is not.
|
||||||
@@ -330,6 +371,16 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
|||||||
MessagesScreen(model, current)
|
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 -> {
|
MessengerViewModel.Phase.Screen.CALLS -> {
|
||||||
CallsScreen(model, current)
|
CallsScreen(model, current)
|
||||||
}
|
}
|
||||||
@@ -357,6 +408,10 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
|||||||
MessengerViewModel.Phase.Screen.ACCOUNT -> {
|
MessengerViewModel.Phase.Screen.ACCOUNT -> {
|
||||||
AccountScreen(model, current)
|
AccountScreen(model, current)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MessengerViewModel.Phase.Screen.SEARCH -> {
|
||||||
|
WebSearchScreen(model, current)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BottomNav(model, current)
|
BottomNav(model, current)
|
||||||
@@ -368,7 +423,7 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ColumnScope.SignIn(
|
internal fun ColumnScope.SignInScreen(
|
||||||
model: MessengerViewModel,
|
model: MessengerViewModel,
|
||||||
phase: MessengerViewModel.Phase.SignedOut,
|
phase: MessengerViewModel.Phase.SignedOut,
|
||||||
) {
|
) {
|
||||||
@@ -380,6 +435,9 @@ private fun ColumnScope.SignIn(
|
|||||||
var passphrase by remember { mutableStateOf("") }
|
var passphrase by remember { mutableStateOf("") }
|
||||||
var email by remember { mutableStateOf("") }
|
var email by remember { mutableStateOf("") }
|
||||||
var registering by remember { mutableStateOf(false) }
|
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 birthYear by remember { mutableStateOf("") }
|
||||||
var ageError by remember { mutableStateOf<String?>(null) }
|
var ageError by remember { mutableStateOf<String?>(null) }
|
||||||
// Only a relay keeping its own accounts needs an address for reset mail.
|
// 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))
|
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))
|
Spacer(Modifier.height(Space.sm))
|
||||||
Text(
|
Text(
|
||||||
"Flamenet Messenger. End-to-end encrypted, no phone number, and it " +
|
stringResource(R.string.main_welcome_body),
|
||||||
"runs on whichever server you point it at.",
|
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
@@ -424,7 +481,7 @@ private fun ColumnScope.SignIn(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = relay,
|
value = relay,
|
||||||
onValueChange = model::setRelay,
|
onValueChange = model::setRelay,
|
||||||
label = { Text("Server") },
|
label = { Text(stringResource(R.string.main_server)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
||||||
modifier = Modifier.fillMaxWidth().testTag("relay"),
|
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.
|
// Say which kind of server this is BEFORE the passphrase is typed.
|
||||||
site?.let {
|
site?.let {
|
||||||
Text(
|
Text(
|
||||||
it.identityDescription,
|
stringResource(it.identityDescriptionRes),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
modifier = Modifier.testTag("identity-mode"),
|
modifier = Modifier.testTag("identity-mode"),
|
||||||
)
|
)
|
||||||
@@ -442,7 +499,7 @@ private fun ColumnScope.SignIn(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = handle,
|
value = handle,
|
||||||
onValueChange = { handle = it },
|
onValueChange = { handle = it },
|
||||||
label = { Text("Handle") },
|
label = { Text(stringResource(R.string.main_handle)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
modifier = Modifier.fillMaxWidth().testTag("handle"),
|
modifier = Modifier.fillMaxWidth().testTag("handle"),
|
||||||
@@ -454,14 +511,14 @@ private fun ColumnScope.SignIn(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = email,
|
value = email,
|
||||||
onValueChange = { email = it },
|
onValueChange = { email = it },
|
||||||
label = { Text("Email") },
|
label = { Text(stringResource(R.string.main_email)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions =
|
keyboardOptions =
|
||||||
KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
|
KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
|
||||||
modifier = Modifier.fillMaxWidth().testTag("email"),
|
modifier = Modifier.fillMaxWidth().testTag("email"),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"Used only by this server, to send a password reset.",
|
stringResource(R.string.main_email_note),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -469,7 +526,7 @@ private fun ColumnScope.SignIn(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = passphrase,
|
value = passphrase,
|
||||||
onValueChange = { passphrase = it },
|
onValueChange = { passphrase = it },
|
||||||
label = { Text("Passphrase") },
|
label = { Text(stringResource(R.string.main_passphrase)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
keyboardOptions =
|
keyboardOptions =
|
||||||
@@ -482,14 +539,14 @@ private fun ColumnScope.SignIn(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = birthYear,
|
value = birthYear,
|
||||||
onValueChange = { birthYear = it.filter(Char::isDigit).take(4) },
|
onValueChange = { birthYear = it.filter(Char::isDigit).take(4) },
|
||||||
label = { Text("Year of birth") },
|
label = { Text(stringResource(R.string.main_birth_year)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions =
|
keyboardOptions =
|
||||||
KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
|
KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
|
||||||
modifier = Modifier.fillMaxWidth().testTag("birth-year"),
|
modifier = Modifier.fillMaxWidth().testTag("birth-year"),
|
||||||
)
|
)
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
ageError?.let {
|
ageError?.let {
|
||||||
@@ -499,12 +556,23 @@ private fun ColumnScope.SignIn(
|
|||||||
modifier = Modifier.testTag("age-error"),
|
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 {
|
phase.error?.let {
|
||||||
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("error"))
|
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,
|
// 240 wide and centred, which is the kit's primary button on this screen,
|
||||||
// not a full-width slab.
|
// not a full-width slab.
|
||||||
Spacer(Modifier.height(Space.md))
|
Spacer(Modifier.height(Space.md))
|
||||||
@@ -522,15 +590,15 @@ private fun ColumnScope.SignIn(
|
|||||||
if (year < 1900 || age < 14) {
|
if (year < 1900 || age < 14) {
|
||||||
ageError =
|
ageError =
|
||||||
if (year < 1900) {
|
if (year < 1900) {
|
||||||
"Enter your year of birth."
|
enterYearText
|
||||||
} else {
|
} else {
|
||||||
"You must be at least 13 to use Flamenet Messenger."
|
tooYoungText
|
||||||
}
|
}
|
||||||
return@Button
|
return@Button
|
||||||
}
|
}
|
||||||
ageError = null
|
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 =
|
enabled =
|
||||||
!phase.busy && handle.isNotBlank() && passphrase.isNotBlank() &&
|
!phase.busy && handle.isNotBlank() && passphrase.isNotBlank() &&
|
||||||
@@ -543,7 +611,7 @@ private fun ColumnScope.SignIn(
|
|||||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
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,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
@@ -561,10 +629,27 @@ private fun ColumnScope.SignIn(
|
|||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth().testTag("toggle-register"),
|
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)
|
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(
|
TextButton(
|
||||||
onClick = { open = !open },
|
onClick = { open = !open },
|
||||||
modifier = Modifier.fillMaxWidth().testTag("toggle-reset"),
|
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
|
if (!open) return
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Enter your handle above, then send yourself a code. " +
|
stringResource(R.string.main_reset_explainer),
|
||||||
"This server can only do that if it has an address on file for you.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
Button(
|
Button(
|
||||||
onClick = { model.requestPasswordReset(handle.trim()) },
|
onClick = { model.requestPasswordReset(handle.trim()) },
|
||||||
enabled = handle.isNotBlank(),
|
enabled = handle.isNotBlank(),
|
||||||
modifier = Modifier.fillMaxWidth().testTag("send-reset-code"),
|
modifier = Modifier.fillMaxWidth().testTag("send-reset-code"),
|
||||||
) { Text("Send a code") }
|
) { Text(stringResource(R.string.main_send_code)) }
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = code,
|
value = code,
|
||||||
onValueChange = { code = it },
|
onValueChange = { code = it },
|
||||||
label = { Text("Code from the email") },
|
label = { Text(stringResource(R.string.main_reset_code)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth().testTag("reset-code"),
|
modifier = Modifier.fillMaxWidth().testTag("reset-code"),
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = fresh,
|
value = fresh,
|
||||||
onValueChange = { fresh = it },
|
onValueChange = { fresh = it },
|
||||||
label = { Text("New password") },
|
label = { Text(stringResource(R.string.main_new_password)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
modifier = Modifier.fillMaxWidth().testTag("reset-password"),
|
modifier = Modifier.fillMaxWidth().testTag("reset-password"),
|
||||||
@@ -616,7 +700,7 @@ private fun ColumnScope.ResetPassword(
|
|||||||
onClick = { model.confirmPasswordReset(handle.trim(), code.trim(), fresh) },
|
onClick = { model.confirmPasswordReset(handle.trim(), code.trim(), fresh) },
|
||||||
enabled = code.isNotBlank() && fresh.length >= 8,
|
enabled = code.isNotBlank() && fresh.length >= 8,
|
||||||
modifier = Modifier.fillMaxWidth().testTag("confirm-reset"),
|
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 screen: MessengerViewModel.Phase.Screen,
|
||||||
val label: String,
|
val label: String,
|
||||||
val tag: String,
|
val tag: String,
|
||||||
|
@androidx.annotation.StringRes val labelRes: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The bar, in order.
|
* The bar, in order.
|
||||||
*/
|
*/
|
||||||
fun tabs(storiesHidden: Boolean): List<Tab> =
|
fun tabs(
|
||||||
|
storiesHidden: Boolean,
|
||||||
|
todayHidden: Boolean = false,
|
||||||
|
): List<Tab> =
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
Tab(Screen.CONTACTS, "Home", "nav-contacts"),
|
Tab(Screen.CONTACTS, "Home", "nav-contacts", R.string.main_home),
|
||||||
Tab(Screen.MESSAGES, "Messages", "nav-messages"),
|
Tab(Screen.MESSAGES, "Messages", "nav-messages", R.string.main_messages),
|
||||||
Tab(Screen.CALLS, "Calls", "nav-calls"),
|
// Beside Messages: the public Today file and the directory (§8.12).
|
||||||
Tab(Screen.STORIES, "Stories", "nav-stories").takeUnless { storiesHidden },
|
Tab(Screen.TODAY, "Today", "nav-today", R.string.main_today).takeUnless { todayHidden },
|
||||||
Tab(Screen.FILES, "Files", "nav-files"),
|
Tab(Screen.CALLS, "Calls", "nav-calls", R.string.main_calls),
|
||||||
Tab(Screen.WALLET, "Wallet", "nav-wallet"),
|
Tab(Screen.STORIES, "Stories", "nav-stories", R.string.main_stories).takeUnless { storiesHidden },
|
||||||
Tab(Screen.ACCOUNT, "Account", "nav-account"),
|
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,
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
) {
|
) {
|
||||||
// Supervision removes the Stories surface rather than emptying it.
|
// Supervision removes the Stories surface rather than emptying it.
|
||||||
val storiesHidden =
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current)
|
val tabs =
|
||||||
for (tab in NavDestinations.tabs(storiesHidden)) {
|
NavDestinations.tabs(
|
||||||
|
storiesHidden = ParentalControls.storiesHidden(context),
|
||||||
|
todayHidden = ParentalControls.todayHidden(context),
|
||||||
|
)
|
||||||
|
for (tab in tabs) {
|
||||||
val badge =
|
val badge =
|
||||||
when (tab.screen) {
|
when (tab.screen) {
|
||||||
// The inbox badge lives on Messages.
|
// The inbox badge lives on Messages.
|
||||||
@@ -700,13 +794,24 @@ internal fun BottomNav(
|
|||||||
when (tab.screen) {
|
when (tab.screen) {
|
||||||
Screen.CONTACTS -> KitIcon.Home to KitIcon.Home
|
Screen.CONTACTS -> KitIcon.Home to KitIcon.Home
|
||||||
Screen.MESSAGES -> KitIcon.Chats to KitIcon.ChatsActive
|
Screen.MESSAGES -> KitIcon.Chats to KitIcon.ChatsActive
|
||||||
|
Screen.TODAY -> KitIcon.Today to KitIcon.Today
|
||||||
Screen.CALLS -> KitIcon.Call to KitIcon.Call
|
Screen.CALLS -> KitIcon.Call to KitIcon.Call
|
||||||
Screen.STORIES -> KitIcon.Stories to KitIcon.Stories
|
Screen.STORIES -> KitIcon.Stories to KitIcon.Stories
|
||||||
Screen.FILES -> KitIcon.Files to KitIcon.Files
|
Screen.FILES -> KitIcon.Files to KitIcon.Files
|
||||||
Screen.WALLET -> KitIcon.Wallet to KitIcon.Wallet
|
Screen.WALLET -> KitIcon.Wallet to KitIcon.Wallet
|
||||||
else -> KitIcon.Account to KitIcon.Account
|
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,
|
label: String,
|
||||||
badge: Int,
|
badge: Int,
|
||||||
tag: String,
|
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 selected = phase.screen == screen
|
||||||
val face = if (selected) Color.White else Color(0xFFB4D2EE)
|
val face = if (selected) Color.White else Color(0xFFB4D2EE)
|
||||||
|
val unreadLabel = stringResource(R.string.main_nav_unread, label, badge)
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -739,7 +850,7 @@ private fun RowScope.NavButton(
|
|||||||
.clickable { model.navigateTo(screen) }
|
.clickable { model.navigateTo(screen) }
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription =
|
contentDescription =
|
||||||
if (badge > 0) "$label, $badge unread" else label
|
if (badge > 0) unreadLabel else label
|
||||||
}.testTag(tag),
|
}.testTag(tag),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
@@ -768,6 +879,9 @@ private fun RowScope.NavButton(
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = face,
|
color = face,
|
||||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
|
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) {
|
if (badge > 0) {
|
||||||
@@ -815,6 +929,17 @@ internal fun ColumnScope.SignedIn(
|
|||||||
scanning = true
|
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
|
// The contact screen REPLACES the list rather than sitting under it, the
|
||||||
// same way an open chat does.
|
// same way an open chat does.
|
||||||
val profile = openProfile?.let { id -> phase.buddies.firstOrNull { it.userId == id } }
|
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.
|
// Home is two things now, on purpose: your people, and their stories.
|
||||||
PortalHeader(
|
PortalHeader(
|
||||||
title = "Home",
|
title = stringResource(R.string.main_home),
|
||||||
subtitle = phase.handle.ifBlank { null }?.let { "$it · ${phase.relay}" },
|
subtitle = phase.handle.ifBlank { null }?.let { "$it · ${phase.relay}" },
|
||||||
) {
|
) {
|
||||||
// Scan their Address screen, iOS's home has the same button.
|
// Scan their Address screen, iOS's home has the same button.
|
||||||
PortalHeaderIcon(
|
PortalHeaderIcon(
|
||||||
icon = KitIcon.Scan,
|
icon = KitIcon.Scan,
|
||||||
contentDescription = "Scan an address",
|
contentDescription = stringResource(R.string.main_scan_address),
|
||||||
modifier = Modifier.testTag("scan-address"),
|
modifier = Modifier.testTag("scan-address"),
|
||||||
onClick = {
|
onClick = {
|
||||||
val has =
|
val has =
|
||||||
@@ -848,7 +973,7 @@ internal fun ColumnScope.SignedIn(
|
|||||||
)
|
)
|
||||||
PortalHeaderIcon(
|
PortalHeaderIcon(
|
||||||
icon = if (addingContact) KitIcon.Close else KitIcon.Add,
|
icon = if (addingContact) KitIcon.Close else KitIcon.Add,
|
||||||
contentDescription = "Add contact",
|
contentDescription = stringResource(R.string.main_add_contact),
|
||||||
modifier = Modifier.testTag("add-contact"),
|
modifier = Modifier.testTag("add-contact"),
|
||||||
onClick = { addingContact = !addingContact },
|
onClick = { addingContact = !addingContact },
|
||||||
)
|
)
|
||||||
@@ -858,9 +983,12 @@ internal fun ColumnScope.SignedIn(
|
|||||||
ScanAddressDialog(
|
ScanAddressDialog(
|
||||||
onFound = { raw ->
|
onFound = { raw ->
|
||||||
scanning = false
|
scanning = false
|
||||||
parseScannedAddress(raw)?.let { card ->
|
// A hello link's code, an older card, or a bare address.
|
||||||
// Bind by the fnid, never the handle (audit LOW).
|
val prefill = prefillFor(raw)
|
||||||
scannedPrefill = "${'$'}{card.fnid}@${'$'}{card.relay}"
|
if (prefill == null) {
|
||||||
|
model.unreadableCode()
|
||||||
|
} else {
|
||||||
|
scannedPrefill = prefill
|
||||||
addingContact = true
|
addingContact = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -887,14 +1015,14 @@ internal fun ColumnScope.SignedIn(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PortalBand("Your people")
|
PortalBand(stringResource(R.string.main_your_people))
|
||||||
if (phase.buddies.isEmpty()) {
|
if (phase.buddies.isEmpty()) {
|
||||||
PortalBody {
|
PortalBody {
|
||||||
Text(
|
Text(
|
||||||
if (phase.buddiesLoaded) {
|
if (phase.buddiesLoaded) {
|
||||||
"Nobody here yet. Add someone by the username they signed up with."
|
stringResource(R.string.main_no_contacts)
|
||||||
} else {
|
} else {
|
||||||
"Couldn't load your contacts. Check the connection and sign in again."
|
stringResource(R.string.main_contacts_load_failed)
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
@@ -904,7 +1032,7 @@ internal fun ColumnScope.SignedIn(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current)) {
|
if (!ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current)) {
|
||||||
PortalBand("Stories")
|
PortalBand(stringResource(R.string.main_stories))
|
||||||
StoriesCards(model, phase)
|
StoriesCards(model, phase)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -940,16 +1068,13 @@ private fun ColumnScope.VaultUnreadable(
|
|||||||
model: MessengerViewModel,
|
model: MessengerViewModel,
|
||||||
phase: MessengerViewModel.Phase.VaultUnreadable,
|
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(
|
Text(
|
||||||
"This phone has keys saved for Flamenet Messenger, but they cannot be opened. " +
|
stringResource(R.string.main_vault_unreadable_body),
|
||||||
"That usually means the app was restored onto a different device, where the " +
|
|
||||||
"key that protects them could not follow.",
|
|
||||||
)
|
)
|
||||||
Text(phase.detail, style = MaterialTheme.typography.bodySmall)
|
Text(phase.detail, style = MaterialTheme.typography.bodySmall)
|
||||||
Text(
|
Text(
|
||||||
"Starting fresh publishes a new device. Everyone you talk to will see " +
|
stringResource(R.string.main_vault_fresh_note),
|
||||||
"\"security keys changed\", so tell them to expect it.",
|
|
||||||
)
|
)
|
||||||
// Behind a confirmation, because the button is one tap from a first
|
// 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
|
// 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 },
|
onClick = { confirming = true },
|
||||||
modifier = Modifier.testTag("start-fresh"),
|
modifier = Modifier.testTag("start-fresh"),
|
||||||
) {
|
) {
|
||||||
Text("Erase and start fresh")
|
Text(stringResource(R.string.main_vault_erase_start_fresh))
|
||||||
}
|
}
|
||||||
if (confirming) {
|
if (confirming) {
|
||||||
androidx.compose.material3.AlertDialog(
|
androidx.compose.material3.AlertDialog(
|
||||||
onDismissRequest = { confirming = false },
|
onDismissRequest = { confirming = false },
|
||||||
title = { Text("Erase the stored keys?") },
|
title = { Text(stringResource(R.string.main_vault_erase_title)) },
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
"This deletes the keys on this phone and every message they " +
|
stringResource(R.string.main_vault_erase_body),
|
||||||
"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.",
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
KitSecondaryButton(
|
KitSecondaryButton(
|
||||||
text = "Erase everything",
|
text = stringResource(R.string.main_vault_erase_everything),
|
||||||
onClick = {
|
onClick = {
|
||||||
confirming = false
|
confirming = false
|
||||||
model.discardUnreadableVault()
|
model.discardUnreadableVault()
|
||||||
@@ -990,7 +1112,7 @@ private fun ColumnScope.VaultUnreadable(
|
|||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
KitChip(
|
KitChip(
|
||||||
text = "Keep them",
|
text = stringResource(R.string.main_vault_keep_them),
|
||||||
selected = false,
|
selected = false,
|
||||||
onClick = { confirming = false },
|
onClick = { confirming = false },
|
||||||
tag = "start-fresh-cancel",
|
tag = "start-fresh-cancel",
|
||||||
@@ -1017,21 +1139,19 @@ private fun CallKeyReviewDialog(
|
|||||||
) {
|
) {
|
||||||
androidx.compose.material3.AlertDialog(
|
androidx.compose.material3.AlertDialog(
|
||||||
onDismissRequest = { model.dismissBlockedCallKeys() },
|
onDismissRequest = { model.dismissBlockedCallKeys() },
|
||||||
title = { Text("Security keys changed") },
|
title = { Text(stringResource(R.string.main_call_keys_title)) },
|
||||||
text = {
|
text = {
|
||||||
androidx.compose.foundation.layout.Column(
|
androidx.compose.foundation.layout.Column(
|
||||||
verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(Space.sm),
|
verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(Space.sm),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"${review.peerName}'s keys are not the ones you accepted before, so the " +
|
stringResource(R.string.main_call_keys_body, review.peerName),
|
||||||
"call was not placed.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
// Both readings, in the order that matters, as the chat banner
|
// Both readings, in the order that matters, as the chat banner
|
||||||
// has them.
|
// has them.
|
||||||
Text(
|
Text(
|
||||||
"That happens when someone reinstalls or adds a device, and it is also " +
|
stringResource(R.string.main_call_keys_why),
|
||||||
"what an intercepted conversation looks like.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
review.safetyNumber?.let { number ->
|
review.safetyNumber?.let { number ->
|
||||||
@@ -1044,14 +1164,12 @@ private fun CallKeyReviewDialog(
|
|||||||
}
|
}
|
||||||
if (review.accepted) {
|
if (review.accepted) {
|
||||||
Text(
|
Text(
|
||||||
"New keys accepted. You can call ${review.peerName} again.",
|
stringResource(R.string.main_call_keys_accepted, review.peerName),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
"Accepting means this call, and everything you send after it, goes to " +
|
stringResource(R.string.main_call_keys_warning),
|
||||||
"whoever holds the new keys. Read the number to them on a channel " +
|
|
||||||
"this app does not control, and accept only if it matches.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1060,13 +1178,13 @@ private fun CallKeyReviewDialog(
|
|||||||
confirmButton = {
|
confirmButton = {
|
||||||
if (review.accepted) {
|
if (review.accepted) {
|
||||||
KitSecondaryButton(
|
KitSecondaryButton(
|
||||||
text = "Done",
|
text = stringResource(R.string.common_done),
|
||||||
onClick = { model.dismissBlockedCallKeys() },
|
onClick = { model.dismissBlockedCallKeys() },
|
||||||
tag = "call-keys-done",
|
tag = "call-keys-done",
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
KitSecondaryButton(
|
KitSecondaryButton(
|
||||||
text = "Accept new keys",
|
text = stringResource(R.string.main_call_keys_accept),
|
||||||
onClick = { model.acceptBlockedCallKeys() },
|
onClick = { model.acceptBlockedCallKeys() },
|
||||||
tag = "call-accept-new-keys",
|
tag = "call-accept-new-keys",
|
||||||
danger = true,
|
danger = true,
|
||||||
@@ -1076,7 +1194,7 @@ private fun CallKeyReviewDialog(
|
|||||||
dismissButton = {
|
dismissButton = {
|
||||||
if (!review.accepted) {
|
if (!review.accepted) {
|
||||||
KitChip(
|
KitChip(
|
||||||
text = "Not now",
|
text = stringResource(R.string.common_not_now),
|
||||||
selected = false,
|
selected = false,
|
||||||
onClick = { model.dismissBlockedCallKeys() },
|
onClick = { model.dismissBlockedCallKeys() },
|
||||||
tag = "call-keys-not-now",
|
tag = "call-keys-not-now",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package io.flamenet.messenger.app
|
package io.flamenet.messenger.app
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -28,16 +30,19 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import io.flamenet.e2e.Vault
|
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
|
* The kit's `07 Messages-Listing` as its own tab: large title, search box, the
|
||||||
* Recent circles row, then a flat list of conversations.
|
* Recent circles row, then a flat list of conversations.
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ColumnScope.MessagesScreen(
|
fun ColumnScope.MessagesScreen(
|
||||||
model: MessengerViewModel,
|
model: MessengerViewModel,
|
||||||
@@ -62,10 +67,10 @@ fun ColumnScope.MessagesScreen(
|
|||||||
// Starting a group sits on the Groups heading and a chat on the Chats
|
// 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
|
// 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.
|
// 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(
|
PortalHeaderIcon(
|
||||||
icon = KitIcon.Scan,
|
icon = KitIcon.Scan,
|
||||||
contentDescription = "Scan an address",
|
contentDescription = stringResource(R.string.set_messages_scan),
|
||||||
modifier = Modifier.testTag("messages-scan"),
|
modifier = Modifier.testTag("messages-scan"),
|
||||||
onClick = {
|
onClick = {
|
||||||
val has =
|
val has =
|
||||||
@@ -82,7 +87,7 @@ fun ColumnScope.MessagesScreen(
|
|||||||
)
|
)
|
||||||
PortalHeaderIcon(
|
PortalHeaderIcon(
|
||||||
icon = if (addingContact) KitIcon.Close else KitIcon.Add,
|
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"),
|
modifier = Modifier.testTag("messages-add-contact"),
|
||||||
onClick = { addingContact = !addingContact },
|
onClick = { addingContact = !addingContact },
|
||||||
)
|
)
|
||||||
@@ -92,15 +97,19 @@ fun ColumnScope.MessagesScreen(
|
|||||||
ScanAddressDialog(
|
ScanAddressDialog(
|
||||||
onFound = { raw ->
|
onFound = { raw ->
|
||||||
scanning = false
|
scanning = false
|
||||||
parseScannedAddress(raw)?.let { card ->
|
// A contact link first (8.12): its app form looks like an
|
||||||
scannedPrefill =
|
// address too, and reading it as one would drop the token.
|
||||||
if (card.handle != null &&
|
if (io.flamenet.e2e.LinkCodes.decodeContactLink(raw) != null) {
|
||||||
card.relay.equals(phase.relay, ignoreCase = true)
|
model.openContactLink(raw)
|
||||||
) {
|
return@ScanAddressDialog
|
||||||
card.handle
|
}
|
||||||
} else {
|
// A hello link's code, an older card, or a bare address,
|
||||||
"${card.fnid}@${card.relay}"
|
// bound by the fnid as Home's scanner is.
|
||||||
}
|
val prefill = prefillFor(raw)
|
||||||
|
if (prefill == null) {
|
||||||
|
model.unreadableCode()
|
||||||
|
} else {
|
||||||
|
scannedPrefill = prefill
|
||||||
addingContact = true
|
addingContact = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -117,11 +126,15 @@ fun ColumnScope.MessagesScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HeldLinkBanner(model, phase)
|
||||||
|
ExportNudge(model, phase)
|
||||||
|
RequestsSection(model, phase)
|
||||||
|
|
||||||
PortalBody {
|
PortalBody {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = query.value,
|
value = query.value,
|
||||||
onValueChange = { query.value = it },
|
onValueChange = { query.value = it },
|
||||||
placeholder = { Text("Search conversations") },
|
placeholder = { Text(stringResource(R.string.set_messages_search)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth().testTag("messages-search"),
|
modifier = Modifier.fillMaxWidth().testTag("messages-search"),
|
||||||
)
|
)
|
||||||
@@ -130,7 +143,7 @@ fun ColumnScope.MessagesScreen(
|
|||||||
MessageHits(model, query.value)
|
MessageHits(model, query.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
PortalBand("Recent")
|
PortalBand(stringResource(R.string.set_messages_recent))
|
||||||
BuddyCirclesRow(model, phase)
|
BuddyCirclesRow(model, phase)
|
||||||
|
|
||||||
// The kit's listing holds every conversation, groups included.
|
// The kit's listing holds every conversation, groups included.
|
||||||
@@ -138,12 +151,12 @@ fun ColumnScope.MessagesScreen(
|
|||||||
|
|
||||||
val showNewChat = remember { mutableStateOf(false) }
|
val showNewChat = remember { mutableStateOf(false) }
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
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
|
// The way in, on the band itself, exactly as New group sits beside its
|
||||||
// heading in the bar above.
|
// heading in the bar above.
|
||||||
androidx.compose.material3.Icon(
|
androidx.compose.material3.Icon(
|
||||||
KitIcon.NewChat,
|
KitIcon.NewChat,
|
||||||
contentDescription = "New chat",
|
contentDescription = stringResource(R.string.set_messages_new_chat),
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -157,17 +170,18 @@ fun ColumnScope.MessagesScreen(
|
|||||||
if (showNewChat.value) {
|
if (showNewChat.value) {
|
||||||
androidx.compose.material3.AlertDialog(
|
androidx.compose.material3.AlertDialog(
|
||||||
onDismissRequest = { showNewChat.value = false },
|
onDismissRequest = { showNewChat.value = false },
|
||||||
title = { Text("New chat") },
|
title = { Text(stringResource(R.string.set_messages_new_chat)) },
|
||||||
text = {
|
text = {
|
||||||
if (phase.buddies.isEmpty()) {
|
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 {
|
} else {
|
||||||
Column {
|
Column {
|
||||||
phase.buddies.forEach { buddy ->
|
phase.buddies.forEach { buddy ->
|
||||||
|
val fallbackName = stringResource(R.string.set_messages_user_fallback, buddy.userId)
|
||||||
val rowName =
|
val rowName =
|
||||||
buddy.displayName
|
buddy.displayName
|
||||||
.ifBlank { buddy.handle }
|
.ifBlank { buddy.handle }
|
||||||
.ifBlank { "user ${buddy.userId}" }
|
.ifBlank { fallbackName }
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -199,30 +213,40 @@ fun ColumnScope.MessagesScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
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
|
phase.buddies
|
||||||
|
.filter { it.userId !in phase.requests }
|
||||||
.filter {
|
.filter {
|
||||||
query.value.isBlank() ||
|
query.value.isBlank() ||
|
||||||
it.displayName.contains(query.value, true) ||
|
it.displayName.contains(query.value, true) ||
|
||||||
it.handle.contains(query.value, true)
|
it.handle.contains(query.value, true)
|
||||||
}.sortedWith(
|
}
|
||||||
compareByDescending<io.flamenet.client.MessengerApi.Buddy> {
|
val archived = listed.filter { isArchived(phase, it.userId) }
|
||||||
Vault.threadForUser(it.userId) in phase.unread
|
val shown =
|
||||||
}.thenBy { it.displayName.ifBlank { it.handle } },
|
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 {
|
PortalBody {
|
||||||
Text(
|
Text(
|
||||||
if (phase.buddies.isEmpty()) {
|
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 {
|
} else {
|
||||||
"Nothing matches that search."
|
stringResource(R.string.set_messages_empty_no_match)
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
@@ -231,18 +255,29 @@ fun ColumnScope.MessagesScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxWidth().testTag("conversations")) {
|
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 =
|
val name =
|
||||||
buddy.displayName
|
buddy.displayName
|
||||||
.ifBlank { buddy.handle }
|
.ifBlank { buddy.handle }
|
||||||
.ifBlank { "user ${buddy.userId}" }
|
.ifBlank { fallbackName }
|
||||||
val unread = Vault.threadForUser(buddy.userId) in phase.unread
|
val unread = Vault.threadForUser(buddy.userId) in phase.unread
|
||||||
|
val thread = Vault.threadForUser(buddy.userId)
|
||||||
PortalRow(
|
PortalRow(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.clickable { model.openChat(buddy.userId, name) }
|
.combinedClickable(
|
||||||
.testTag("conversation-${buddy.userId}"),
|
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(
|
Avatar(
|
||||||
name = name,
|
name = name,
|
||||||
online = buddy.presence == "online",
|
online = buddy.presence == "online",
|
||||||
@@ -258,7 +293,12 @@ fun ColumnScope.MessagesScreen(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
Text(
|
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,
|
fontSize = 13.sp,
|
||||||
color =
|
color =
|
||||||
if (unread) {
|
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
|
if (hits.isEmpty()) return
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"IN MESSAGES",
|
stringResource(R.string.set_messages_in_messages),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import androidx.core.app.NotificationCompat
|
|||||||
import io.flamenet.client.MessengerApi
|
import io.flamenet.client.MessengerApi
|
||||||
import io.flamenet.client.RelayClient
|
import io.flamenet.client.RelayClient
|
||||||
import io.flamenet.e2e.Vault
|
import io.flamenet.e2e.Vault
|
||||||
|
import io.flamenet.messenger.R
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -165,8 +166,8 @@ class MessengerService : Service() {
|
|||||||
val notice =
|
val notice =
|
||||||
NotificationCompat
|
NotificationCompat
|
||||||
.Builder(this, CHANNEL_ONGOING)
|
.Builder(this, CHANNEL_ONGOING)
|
||||||
.setContentTitle("Flamenet is receiving")
|
.setContentTitle(getString(R.string.core_notif_receiving_title))
|
||||||
.setContentText("Messages arrive without Google or Apple in the middle.")
|
.setContentText(getString(R.string.core_notif_receiving_text))
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
@@ -212,7 +213,10 @@ class MessengerService : Service() {
|
|||||||
relay = relay,
|
relay = relay,
|
||||||
api =
|
api =
|
||||||
MessengerApi(credential.relayBaseUrl, credential.username, credential.appPassword),
|
MessengerApi(credential.relayBaseUrl, credential.username, credential.appPassword),
|
||||||
)
|
).also { s ->
|
||||||
|
s.onWiped = { TodayData.forget(applicationContext) }
|
||||||
|
s.attachmentCacheDir = cacheDir
|
||||||
|
}
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
private suspend fun run() {
|
private suspend fun run() {
|
||||||
@@ -315,7 +319,24 @@ class MessengerService : Service() {
|
|||||||
"call=$onACall touched=${touched.size} " +
|
"call=$onACall touched=${touched.size} " +
|
||||||
(outcome.exceptionOrNull()?.let { "failed=${it::class.simpleName}" } ?: "ok"),
|
(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
|
// Reply reminders fire from THIS loop, after `receive()`, and the
|
||||||
// order matters: an inbound message clears the reminder inside
|
// order matters: an inbound message clears the reminder inside
|
||||||
// `Session.record`, so a reply that has just landed cancels its own
|
// `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 } }
|
s.roster().associate { it.userId to it.displayName.ifBlank { it.handle } }
|
||||||
}.getOrDefault(names)
|
}.getOrDefault(names)
|
||||||
// Falls back to something answerable rather than a bare number.
|
// 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(
|
private fun notifyMessages(
|
||||||
threads: Int,
|
threads: Int,
|
||||||
from: String? = null,
|
from: String? = null,
|
||||||
|
notice: Session.Notice? = null,
|
||||||
|
s: Session? = null,
|
||||||
) {
|
) {
|
||||||
val settings = NotificationSettings(this)
|
val settings = NotificationSettings(this)
|
||||||
// The master switch and the per-kind one, both checked before a
|
// The master switch and the per-kind one, both checked before a
|
||||||
@@ -372,14 +395,27 @@ class MessengerService : Service() {
|
|||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
)
|
)
|
||||||
// Still no sender and no preview unless it was asked for.
|
// 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(
|
notify(
|
||||||
CHANNEL_MESSAGES,
|
CHANNEL_MESSAGES,
|
||||||
2,
|
2,
|
||||||
text,
|
text,
|
||||||
NotificationCompat.PRIORITY_HIGH,
|
if (quiet) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_HIGH,
|
||||||
open,
|
open,
|
||||||
number = threads,
|
number = threads,
|
||||||
|
silent = quiet,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +531,8 @@ class MessengerService : Service() {
|
|||||||
open: PendingIntent,
|
open: PendingIntent,
|
||||||
category: String? = null,
|
category: String? = null,
|
||||||
number: Int = 0,
|
number: Int = 0,
|
||||||
|
/** Shown without a sound or a buzz, as a game's next move is (§6.33). */
|
||||||
|
silent: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val n =
|
val n =
|
||||||
NotificationCompat
|
NotificationCompat
|
||||||
@@ -503,6 +541,7 @@ class MessengerService : Service() {
|
|||||||
.setContentText(text)
|
.setContentText(text)
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||||
.setPriority(priority)
|
.setPriority(priority)
|
||||||
|
.setSilent(silent)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setContentIntent(open)
|
.setContentIntent(open)
|
||||||
.also { b -> category?.let { b.setCategory(it) } }
|
.also { b -> category?.let { b.setCategory(it) } }
|
||||||
@@ -521,10 +560,10 @@ class MessengerService : Service() {
|
|||||||
manager.createNotificationChannel(
|
manager.createNotificationChannel(
|
||||||
NotificationChannel(
|
NotificationChannel(
|
||||||
CHANNEL_ONGOING,
|
CHANNEL_ONGOING,
|
||||||
"Receiving",
|
getString(R.string.core_channel_receiving),
|
||||||
NotificationManager.IMPORTANCE_MIN,
|
NotificationManager.IMPORTANCE_MIN,
|
||||||
).apply {
|
).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.
|
// The always-on service notice must not put a dot on the icon.
|
||||||
// a permanent badge reads as "you have unread mail", for ever.
|
// a permanent badge reads as "you have unread mail", for ever.
|
||||||
setShowBadge(false)
|
setShowBadge(false)
|
||||||
@@ -533,14 +572,14 @@ class MessengerService : Service() {
|
|||||||
manager.createNotificationChannel(
|
manager.createNotificationChannel(
|
||||||
NotificationChannel(
|
NotificationChannel(
|
||||||
CHANNEL_MESSAGES,
|
CHANNEL_MESSAGES,
|
||||||
"Messages",
|
getString(R.string.core_channel_messages),
|
||||||
NotificationManager.IMPORTANCE_HIGH,
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
).apply { setShowBadge(true) },
|
).apply { setShowBadge(true) },
|
||||||
)
|
)
|
||||||
manager.createNotificationChannel(
|
manager.createNotificationChannel(
|
||||||
NotificationChannel(
|
NotificationChannel(
|
||||||
CHANNEL_CALLS,
|
CHANNEL_CALLS,
|
||||||
"Calls",
|
getString(R.string.core_channel_calls),
|
||||||
NotificationManager.IMPORTANCE_HIGH,
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
).apply {
|
).apply {
|
||||||
setBypassDnd(false)
|
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.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
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.platform.testTag
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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 androidx.compose.ui.unit.sp
|
||||||
|
import io.flamenet.messenger.R
|
||||||
import io.flamenet.client.Obfs4Routing
|
import io.flamenet.client.Obfs4Routing
|
||||||
import io.flamenet.obfs4.BridgeLine
|
import io.flamenet.obfs4.BridgeLine
|
||||||
import io.flamenet.obfs4.BridgeLineResult
|
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
|
@Composable
|
||||||
fun ColumnScope.NetworkSection(
|
fun ColumnScope.NetworkSection(
|
||||||
@@ -38,25 +50,30 @@ fun ColumnScope.NetworkSection(
|
|||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val settings = remember { Obfs4Settings(context) }
|
val settings = remember { Obfs4Settings(context) }
|
||||||
var draft by remember { mutableStateOf(settings.bridgeLine ?: "") }
|
var draft by remember { mutableStateOf("") }
|
||||||
var stored by remember { mutableStateOf(settings.bridgeLine) }
|
var refusal by remember { mutableStateOf<String?>(null) }
|
||||||
// Bumped on save/remove so the live status below re-reads.
|
// Bumped on every change so the lists and the live status re-read.
|
||||||
var tick by remember { mutableIntStateOf(0) }
|
var tick by remember { mutableIntStateOf(0) }
|
||||||
// Who has been handed the line this sitting.
|
// Who has been handed the line this sitting.
|
||||||
var sharedWith by remember { mutableStateOf(setOf<Long>()) }
|
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")
|
@Suppress("UNUSED_EXPRESSION")
|
||||||
tick
|
tick
|
||||||
|
val lines = remember(tick) { settings.lines }
|
||||||
val parsed = if (draft.isBlank()) null else BridgeLine.parse(draft)
|
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
|
// Reads what the transport did, never the stored list.
|
||||||
// line.
|
|
||||||
Text(
|
Text(
|
||||||
statusLine(),
|
statusLineText(),
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
modifier = Modifier.testTag("obfs4-status"),
|
modifier = Modifier.testTag("obfs4-status"),
|
||||||
@@ -66,11 +83,11 @@ fun ColumnScope.NetworkSection(
|
|||||||
val offers = remember(tick) { model?.bridgeOffers().orEmpty() }
|
val offers = remember(tick) { model?.bridgeOffers().orEmpty() }
|
||||||
if (offers.isNotEmpty()) {
|
if (offers.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(Space.md))
|
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) {
|
for (offer in offers) {
|
||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(Space.sm))
|
||||||
Text(
|
Text(
|
||||||
"${offer.fromName} shared a bridge",
|
stringResource(R.string.set_network_shared_a_bridge, offer.fromName),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
modifier = Modifier.testTag("obfs4-offer-${offer.fromUser}"),
|
modifier = Modifier.testTag("obfs4-offer-${offer.fromUser}"),
|
||||||
)
|
)
|
||||||
@@ -81,36 +98,23 @@ fun ColumnScope.NetworkSection(
|
|||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
when (val parsed = BridgeLine.parse(offer.line)) {
|
Reading(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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(Space.xs))
|
Spacer(Modifier.height(Space.xs))
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
KitPrimaryButton(
|
KitPrimaryButton(
|
||||||
text = "Use this bridge",
|
text = stringResource(R.string.set_network_use_bridge),
|
||||||
onClick = {
|
onClick = {
|
||||||
if (settings.accept(offer.line) is BridgeLineResult.Ok) {
|
when (val result = settings.accept(offer.line, from = offer.fromName)) {
|
||||||
draft = offer.line
|
is Obfs4Settings.AddResult.Added, Obfs4Settings.AddResult.AlreadySaved -> {
|
||||||
stored = settings.bridgeLine
|
model?.dismissBridgeOffer(offer.fromUser)
|
||||||
model?.dismissBridgeOffer(offer.fromUser)
|
refusal = null
|
||||||
tick++
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
refusal = addResultWords(context, result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
tick++
|
||||||
},
|
},
|
||||||
enabled = parsedOk(offer.line),
|
enabled = parsedOk(offer.line),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
@@ -118,7 +122,7 @@ fun ColumnScope.NetworkSection(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.width(Space.sm))
|
Spacer(Modifier.width(Space.sm))
|
||||||
KitSecondaryButton(
|
KitSecondaryButton(
|
||||||
text = "Dismiss",
|
text = stringResource(R.string.set_network_dismiss),
|
||||||
onClick = {
|
onClick = {
|
||||||
model?.dismissBridgeOffer(offer.fromUser)
|
model?.dismissBridgeOffer(offer.fromUser)
|
||||||
tick++
|
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))
|
Spacer(Modifier.height(Space.md))
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = draft,
|
value = draft,
|
||||||
onValueChange = { draft = it },
|
onValueChange = {
|
||||||
|
draft = it
|
||||||
|
refusal = null
|
||||||
|
},
|
||||||
placeholder = { Text("obfs4 198.51.100.7:4444 …", fontSize = 13.sp) },
|
placeholder = { Text("obfs4 198.51.100.7:4444 …", fontSize = 13.sp) },
|
||||||
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
minLines = 2,
|
minLines = 2,
|
||||||
@@ -156,7 +204,7 @@ fun ColumnScope.NetworkSection(
|
|||||||
|
|
||||||
is BridgeLineResult.Ok -> {
|
is BridgeLineResult.Ok -> {
|
||||||
Text(
|
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,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
modifier = Modifier.testTag("obfs4-parsed"),
|
modifier = Modifier.testTag("obfs4-parsed"),
|
||||||
@@ -175,47 +223,56 @@ fun ColumnScope.NetworkSection(
|
|||||||
Unit
|
Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
refusal?.let {
|
||||||
|
Text(it, fontSize = 12.sp, color = Palette.Danger, modifier = Modifier.testTag("obfs4-refusal"))
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(Space.sm))
|
||||||
|
|
||||||
KitPrimaryButton(
|
KitPrimaryButton(
|
||||||
text = "Save this bridge",
|
text = stringResource(R.string.set_network_save),
|
||||||
onClick = {
|
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.
|
// could use.
|
||||||
if (settings.save(draft) is BridgeLineResult.Ok) {
|
when (val result = settings.add(draft)) {
|
||||||
stored = settings.bridgeLine
|
is Obfs4Settings.AddResult.Added -> {
|
||||||
tick++
|
draft = ""
|
||||||
|
refusal = null
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
refusal = addResultWords(context, result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
tick++
|
||||||
},
|
},
|
||||||
enabled = parsed is BridgeLineResult.Ok && !isUnchanged,
|
enabled = parsed is BridgeLineResult.Ok,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
tag = "obfs4-save",
|
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) {
|
// The other half of receiving one (§24.3).
|
||||||
Spacer(Modifier.height(Space.sm))
|
val share = remember(tick) { settings.bridgeLine }
|
||||||
KitSecondaryButton(
|
if (share != null && model != null && buddies.isNotEmpty()) {
|
||||||
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()) {
|
|
||||||
Spacer(Modifier.height(Space.md))
|
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(
|
Text(
|
||||||
// Says who learns what.
|
// Says who learns what.
|
||||||
"They get the line and decide for themselves. Whoever runs the bridge will " +
|
stringResource(R.string.set_network_share_note),
|
||||||
"see one more person connecting, and your contact will know you use it.",
|
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
@@ -230,11 +287,13 @@ fun ColumnScope.NetworkSection(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
KitSecondaryButton(
|
KitSecondaryButton(
|
||||||
text = if (sharedWith.contains(buddy.userId)) "Sent" else "Send",
|
text =
|
||||||
onClick = {
|
if (sharedWith.contains(buddy.userId)) {
|
||||||
model?.shareBridge(buddy.userId)
|
stringResource(R.string.set_network_sent)
|
||||||
sharedWith = sharedWith + buddy.userId
|
} else {
|
||||||
},
|
stringResource(R.string.common_send)
|
||||||
|
},
|
||||||
|
onClick = { confirmShare = buddy },
|
||||||
// Not disabled after sending.
|
// Not disabled after sending.
|
||||||
tag = "obfs4-share-${buddy.userId}",
|
tag = "obfs4-share-${buddy.userId}",
|
||||||
)
|
)
|
||||||
@@ -245,25 +304,84 @@ fun ColumnScope.NetworkSection(
|
|||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(Space.sm))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Paste the line a bridge provider gave you. It's one long line that starts with " +
|
stringResource(R.string.set_network_paste_hint),
|
||||||
"obfs4, copy all of it, including the part after cert=.",
|
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(Space.sm))
|
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
|
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(
|
internal fun statusLine(
|
||||||
bridge: io.flamenet.obfs4.Obfs4Bridge? = Obfs4Routing.bridge,
|
bridge: Obfs4Bridge? = Obfs4Routing.lastCarried ?: Obfs4Routing.bridge,
|
||||||
handshakes: Int = Obfs4Stats.completedHandshakes(),
|
handshakes: Int = if (Obfs4Routing.lastCarried != null) 1 else 0,
|
||||||
|
count: Int = Obfs4Routing.bridges.size,
|
||||||
|
everyLineFailed: Boolean = Obfs4Routing.everyLineFailed,
|
||||||
): String =
|
): String =
|
||||||
when {
|
when {
|
||||||
bridge == null -> {
|
bridge == null -> {
|
||||||
@@ -277,7 +395,19 @@ internal fun statusLine(
|
|||||||
"always did."
|
"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".
|
// 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 -> {
|
else -> {
|
||||||
"Bridge saved: ${bridge.host}:${bridge.port}. Nothing has gone through it yet, the " +
|
"Bridge saved: ${bridge.host}:${bridge.port}. Nothing has gone through it yet, the " +
|
||||||
"app will try the next time it goes online."
|
"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 =
|
private fun addResultWords(
|
||||||
"Some networks block Flamenet outright, or recognise it and slow it down. A bridge is a " +
|
context: android.content.Context,
|
||||||
"machine somewhere else that your app talks to instead, in a way that doesn't look " +
|
result: Obfs4Settings.AddResult,
|
||||||
"like anything in particular, not like Flamenet, and not like a video call or a web " +
|
): String =
|
||||||
"page either. The bridge passes your traffic on to your server.\n\n" +
|
when (result) {
|
||||||
"Two things worth being clear about. Your server sees exactly what it saw before, so " +
|
Obfs4Settings.AddResult.TooMany -> context.getString(R.string.set_network_too_many)
|
||||||
"this doesn't hide anything from whoever runs it. And whoever runs the bridge can see " +
|
Obfs4Settings.AddResult.AlreadySaved -> context.getString(R.string.set_network_already_saved)
|
||||||
"that you're connecting to them, though not what you're saying, your messages stay " +
|
else -> result.message
|
||||||
"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."
|
|
||||||
|
/**
|
||||||
|
* [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.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.flamenet.messenger.R
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What this phone's notifications are allowed to say.
|
* What this phone's notifications are allowed to say.
|
||||||
@@ -37,7 +39,7 @@ fun ColumnScope.NotificationSection() {
|
|||||||
var previews by remember { mutableStateOf(settings.showPreviews) }
|
var previews by remember { mutableStateOf(settings.showPreviews) }
|
||||||
var sounds by remember { mutableStateOf(settings.inAppSounds) }
|
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
|
@Composable
|
||||||
fun row(
|
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
|
settings.enabled = on
|
||||||
enabled = on
|
enabled = on
|
||||||
}
|
}
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
row("New messages", "notify-messages", newMessages) { on ->
|
row(stringResource(R.string.set_notify_new_messages), "notify-messages", newMessages) { on ->
|
||||||
settings.newMessages = on
|
settings.newMessages = on
|
||||||
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
|
settings.showPreviews = on
|
||||||
previews = 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
|
settings.inAppSounds = on
|
||||||
sounds = on
|
sounds = on
|
||||||
}
|
}
|
||||||
@@ -92,20 +94,18 @@ fun ColumnScope.NotificationSection() {
|
|||||||
if (manager?.canUseFullScreenIntent() == false) {
|
if (manager?.canUseFullScreenIntent() == false) {
|
||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(Space.sm))
|
||||||
Text(
|
Text(
|
||||||
"Calls will not ring full screen",
|
stringResource(R.string.set_notify_fsi_title),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
modifier = Modifier.testTag("notify-fsi-title"),
|
modifier = Modifier.testTag("notify-fsi-title"),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"Android is holding back the permission that lets an incoming call " +
|
stringResource(R.string.set_notify_fsi_body),
|
||||||
"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.",
|
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.xs))
|
Spacer(Modifier.height(Space.xs))
|
||||||
KitSecondaryButton(
|
KitSecondaryButton(
|
||||||
text = "Let calls ring",
|
text = stringResource(R.string.set_notify_fsi_grant),
|
||||||
onClick = {
|
onClick = {
|
||||||
runCatching {
|
runCatching {
|
||||||
context.startActivity(
|
context.startActivity(
|
||||||
@@ -127,11 +127,9 @@ fun ColumnScope.NotificationSection() {
|
|||||||
// cautious.
|
// cautious.
|
||||||
Text(
|
Text(
|
||||||
if (previews) {
|
if (previews) {
|
||||||
"Your lock screen will name whoever wrote to you. Anyone who can see your " +
|
stringResource(R.string.set_notify_previews_on_note)
|
||||||
"phone can see that, which is sometimes more revealing than the message."
|
|
||||||
} else {
|
} else {
|
||||||
"Notifications say how many messages arrived and nothing else, no name, no " +
|
stringResource(R.string.set_notify_previews_off_note)
|
||||||
"words. Calls still ring."
|
|
||||||
},
|
},
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package io.flamenet.messenger.app
|
package io.flamenet.messenger.app
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import io.flamenet.e2e.Vault
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a notification is allowed to say, and whether to post one at all.
|
* 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)
|
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. */
|
/** The master switch. Off means no notification of any kind is posted. */
|
||||||
var enabled: Boolean
|
var enabled: Boolean
|
||||||
get() = prefs.getBoolean(ENABLED, true)
|
get() = prefs.getBoolean(ENABLED, true)
|
||||||
@@ -38,13 +42,40 @@ class NotificationSettings(
|
|||||||
fun messageText(
|
fun messageText(
|
||||||
threads: Int,
|
threads: Int,
|
||||||
from: String?,
|
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. */
|
/** The same question for a reply reminder. */
|
||||||
fun replyReminderText(
|
fun replyReminderText(
|
||||||
count: Int,
|
count: Int,
|
||||||
from: String?,
|
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 {
|
companion object {
|
||||||
/**
|
/**
|
||||||
@@ -62,6 +93,82 @@ class NotificationSettings(
|
|||||||
else -> "New messages in $threads chats"
|
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(
|
fun replyReminderText(
|
||||||
showPreviews: Boolean,
|
showPreviews: Boolean,
|
||||||
count: Int,
|
count: Int,
|
||||||
|
|||||||
@@ -3,72 +3,190 @@ package io.flamenet.messenger.app
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import io.flamenet.client.Obfs4Routing
|
import io.flamenet.client.Obfs4Routing
|
||||||
import io.flamenet.obfs4.BridgeLine
|
import io.flamenet.obfs4.BridgeLine
|
||||||
|
import io.flamenet.obfs4.BridgeLineProblem
|
||||||
import io.flamenet.obfs4.BridgeLineResult
|
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(
|
class Obfs4Settings(
|
||||||
context: Context,
|
context: Context,
|
||||||
) {
|
) {
|
||||||
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
/**
|
/** One stored line. */
|
||||||
* The stored line, exactly as it was pasted.
|
data class StoredLine(
|
||||||
*/
|
val line: String,
|
||||||
var bridgeLine: String?
|
val addedAt: Long,
|
||||||
get() = prefs.getString(BRIDGE_LINE, null)
|
val offeredBy: String?,
|
||||||
private set(value) {
|
val lastWorked: Long?,
|
||||||
prefs
|
)
|
||||||
.edit()
|
|
||||||
.apply {
|
/** What adding a line came to. */
|
||||||
if (value.isNullOrBlank()) remove(BRIDGE_LINE) else putString(BRIDGE_LINE, value)
|
sealed interface AddResult {
|
||||||
}.apply()
|
val message: String
|
||||||
|
|
||||||
|
data class Added(
|
||||||
|
val bridge: Obfs4Bridge,
|
||||||
|
val caveat: String?,
|
||||||
|
) : AddResult {
|
||||||
|
override val message = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
data class Unreadable(
|
||||||
* Stores a line and starts using it, or reports why it cannot be used.
|
val problem: BridgeLineProblem,
|
||||||
*/
|
) : AddResult {
|
||||||
fun save(line: String): BridgeLineResult {
|
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 trimmed = line.trim()
|
||||||
val result = BridgeLine.parse(trimmed)
|
val bridge =
|
||||||
if (result is BridgeLineResult.Ok) {
|
when (val result = BridgeLine.parse(trimmed)) {
|
||||||
bridgeLine = trimmed
|
is BridgeLineResult.Bad -> return AddResult.Unreadable(result.problem)
|
||||||
Obfs4Routing.use(result.bridge)
|
is BridgeLineResult.Ok -> result
|
||||||
}
|
}
|
||||||
return 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() {
|
fun clear() {
|
||||||
bridgeLine = null
|
write(emptyList())
|
||||||
Obfs4Routing.use(null)
|
prefs.edit().remove(BRIDGE_LINE).apply()
|
||||||
|
Obfs4Routing.use(emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Records that a line carried a connection that reached the far end. */
|
||||||
* Puts the stored line into force.
|
fun markWorked(bridge: Obfs4Bridge) {
|
||||||
*/
|
val all = lines
|
||||||
fun applyAtStartup() {
|
val i =
|
||||||
val line = bridgeLine ?: return
|
all.indexOfFirst { (BridgeLine.parse(it.line) as? BridgeLineResult.Ok)?.bridge == bridge }
|
||||||
when (val result = BridgeLine.parse(line)) {
|
if (i < 0) return
|
||||||
is BridgeLineResult.Ok -> {
|
write(all.toMutableList().also { it[i] = it[i].copy(lastWorked = System.currentTimeMillis() / 1000) })
|
||||||
Obfs4Routing.use(result.bridge)
|
|
||||||
}
|
|
||||||
|
|
||||||
is BridgeLineResult.Bad -> {
|
|
||||||
bridgeLine = null
|
|
||||||
Obfs4Routing.use(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Puts the stored lines into force. */
|
||||||
* Accept a bridge a contact offered.
|
fun applyAtStartup() = route(lines)
|
||||||
*/
|
|
||||||
fun accept(offeredLine: String): BridgeLineResult = save(offeredLine)
|
/** Accept a bridge a contact offered: it joins the list. */
|
||||||
|
fun accept(
|
||||||
|
offeredLine: String,
|
||||||
|
from: String,
|
||||||
|
): AddResult = add(offeredLine, offeredBy = from)
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val PREFS = "flamenet.obfs4"
|
const val PREFS = "flamenet.obfs4"
|
||||||
const val BRIDGE_LINE = "bridge-line"
|
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)
|
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(
|
fun setGate(
|
||||||
context: Context,
|
context: Context,
|
||||||
key: String,
|
key: String,
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.flamenet.messenger.R
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parental controls on the Account screen: a parent's PIN guarding what this
|
* Parental controls on the Account screen: a parent's PIN guarding what this
|
||||||
@@ -41,16 +43,12 @@ fun ParentalControlsSection() {
|
|||||||
@Suppress("UNUSED_EXPRESSION")
|
@Suppress("UNUSED_EXPRESSION")
|
||||||
tick
|
tick
|
||||||
|
|
||||||
Text("Parental controls", style = MaterialTheme.typography.titleMedium)
|
Text(stringResource(R.string.set_parental_title), style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
when {
|
when {
|
||||||
!ParentalControls.isEnabled(context) -> {
|
!ParentalControls.isEnabled(context) -> {
|
||||||
Text(
|
Text(
|
||||||
"A parent sets a PIN on this phone. While supervision is on, " +
|
stringResource(R.string.set_parental_intro),
|
||||||
"adding contacts asks for the PIN, and stories and calls can " +
|
|
||||||
"be switched off, all enforced on this device. Nothing about " +
|
|
||||||
"supervision is sent to any server. Use it alongside Family " +
|
|
||||||
"Link, which governs the phone itself.",
|
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
@@ -58,7 +56,7 @@ fun ParentalControlsSection() {
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = newPin,
|
value = newPin,
|
||||||
onValueChange = { newPin = it },
|
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,
|
singleLine = true,
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
modifier = Modifier.fillMaxWidth().testTag("parental-pin"),
|
modifier = Modifier.fillMaxWidth().testTag("parental-pin"),
|
||||||
@@ -67,7 +65,7 @@ fun ParentalControlsSection() {
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = repeatPin,
|
value = repeatPin,
|
||||||
onValueChange = { repeatPin = it },
|
onValueChange = { repeatPin = it },
|
||||||
placeholder = { Text("Repeat PIN", fontSize = 14.sp) },
|
placeholder = { Text(stringResource(R.string.set_parental_repeat_pin), fontSize = 14.sp) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
modifier = Modifier.fillMaxWidth().testTag("parental-pin-repeat"),
|
modifier = Modifier.fillMaxWidth().testTag("parental-pin-repeat"),
|
||||||
@@ -83,12 +81,12 @@ fun ParentalControlsSection() {
|
|||||||
},
|
},
|
||||||
enabled = newPin.length >= 4 && newPin == repeatPin,
|
enabled = newPin.length >= 4 && newPin == repeatPin,
|
||||||
modifier = Modifier.testTag("parental-enable"),
|
modifier = Modifier.testTag("parental-enable"),
|
||||||
) { Text("Turn on supervision") }
|
) { Text(stringResource(R.string.set_parental_turn_on)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
!unlocked -> {
|
!unlocked -> {
|
||||||
Text(
|
Text(
|
||||||
"Supervision is on. The settings behind this are the parent's to change.",
|
stringResource(R.string.set_parental_on_note),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
@@ -99,7 +97,7 @@ fun ParentalControlsSection() {
|
|||||||
pinEntry = it
|
pinEntry = it
|
||||||
wrong = false
|
wrong = false
|
||||||
},
|
},
|
||||||
placeholder = { Text("Parent PIN", fontSize = 14.sp) },
|
placeholder = { Text(stringResource(R.string.set_parental_pin), fontSize = 14.sp) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
isError = wrong,
|
isError = wrong,
|
||||||
@@ -107,7 +105,7 @@ fun ParentalControlsSection() {
|
|||||||
)
|
)
|
||||||
if (wrong) {
|
if (wrong) {
|
||||||
Text(
|
Text(
|
||||||
"That's not the PIN.",
|
stringResource(R.string.set_parental_wrong_pin),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
@@ -120,17 +118,16 @@ fun ParentalControlsSection() {
|
|||||||
},
|
},
|
||||||
enabled = pinEntry.isNotEmpty(),
|
enabled = pinEntry.isNotEmpty(),
|
||||||
modifier = Modifier.testTag("parental-unlock"),
|
modifier = Modifier.testTag("parental-unlock"),
|
||||||
) { Text("Unlock") }
|
) { Text(stringResource(R.string.common_unlock)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Column {
|
Column {
|
||||||
GateRow("Ask the PIN to add a contact", ParentalControls.CONTACTS_GATED) { tick++ }
|
GateRow(stringResource(R.string.set_parental_gate_contacts), ParentalControls.CONTACTS_GATED) { tick++ }
|
||||||
GateRow("Hide stories", ParentalControls.STORIES_HIDDEN) { tick++ }
|
GateRow(stringResource(R.string.set_parental_gate_stories), ParentalControls.STORIES_HIDDEN) { tick++ }
|
||||||
GateRow("Turn off calls", ParentalControls.CALLS_DISABLED) { tick++ }
|
GateRow(stringResource(R.string.set_parental_gate_calls), ParentalControls.CALLS_DISABLED) { tick++ }
|
||||||
Text(
|
Text(
|
||||||
"With calls off, this phone neither places calls nor rings, a " +
|
stringResource(R.string.set_parental_calls_off_note),
|
||||||
"caller is answered busy.",
|
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
@@ -142,7 +139,7 @@ fun ParentalControlsSection() {
|
|||||||
tick++
|
tick++
|
||||||
},
|
},
|
||||||
modifier = Modifier.testTag("parental-disable"),
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
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.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
@@ -28,24 +30,26 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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 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
|
@Composable
|
||||||
fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) {
|
fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val stickers = StickerPack.all(context)
|
val packs = StickerPack.packs(context).filter { it.stickers.isNotEmpty() }
|
||||||
|
|
||||||
if (stickers.isEmpty()) {
|
if (packs.isEmpty()) {
|
||||||
// The manifest failed to load.
|
// No manifest would load.
|
||||||
Text(
|
Text(
|
||||||
"Stickers could not be loaded on this device.",
|
stringResource(R.string.main_stickers_failed),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
modifier = Modifier.padding(Kit.gutter),
|
modifier = Modifier.padding(Kit.gutter),
|
||||||
@@ -58,7 +62,7 @@ fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) {
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(max = 220.dp)
|
.heightIn(max = 260.dp)
|
||||||
.clip(RoundedCornerShape(Radius.large))
|
.clip(RoundedCornerShape(Radius.large))
|
||||||
.background(Palette.SurfaceAlt)
|
.background(Palette.SurfaceAlt)
|
||||||
.testTag("sticker-picker"),
|
.testTag("sticker-picker"),
|
||||||
@@ -66,23 +70,66 @@ fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) {
|
|||||||
androidx.compose.foundation.layout
|
androidx.compose.foundation.layout
|
||||||
.PaddingValues(Space.sm),
|
.PaddingValues(Space.sm),
|
||||||
) {
|
) {
|
||||||
items(stickers, key = { it.id }) { sticker ->
|
packs.forEach { pack ->
|
||||||
Box(
|
item(key = "pack-${pack.id}", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
modifier =
|
Text(
|
||||||
Modifier
|
pack.name,
|
||||||
.padding(Space.xs)
|
fontSize = 11.sp,
|
||||||
.clip(RoundedCornerShape(Radius.small))
|
fontWeight = FontWeight.SemiBold,
|
||||||
.clickable { onPick(sticker) }
|
color = Palette.SecondaryText,
|
||||||
.semantics { contentDescription = sticker.name }
|
modifier =
|
||||||
.testTag("sticker-pick-${sticker.id}"),
|
Modifier
|
||||||
contentAlignment = Alignment.Center,
|
.padding(horizontal = Space.xs, vertical = Space.xs)
|
||||||
) {
|
.testTag("sticker-pack-${pack.id}"),
|
||||||
Text(sticker.glyph, fontSize = 34.sp, modifier = Modifier.padding(Space.sm))
|
)
|
||||||
|
}
|
||||||
|
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.
|
* Emoji, straight into the draft.
|
||||||
*/
|
*/
|
||||||
@@ -116,13 +163,16 @@ fun ColumnScope.EmojiPicker(onPick: (String) -> Unit) {
|
|||||||
.testTag("emoji-picker"),
|
.testTag("emoji-picker"),
|
||||||
verticalArrangement = Arrangement.spacedBy(Space.xs),
|
verticalArrangement = Arrangement.spacedBy(Space.xs),
|
||||||
) {
|
) {
|
||||||
if (recents.isNotEmpty()) EmojiSection("Recent", recents, ::pick)
|
if (recents.isNotEmpty()) EmojiSection("Recent", stringResource(R.string.main_emoji_recent), recents, ::pick)
|
||||||
EMOJI_GROUPS.forEach { (name, emoji) -> EmojiSection(name, emoji, ::pick) }
|
EMOJI_GROUPS.forEach { (name, emoji) -> EmojiSection(name, stringResource(emojiGroupLabel(name)), emoji, ::pick) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EmojiSection(
|
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,
|
title: String,
|
||||||
emoji: List<String>,
|
emoji: List<String>,
|
||||||
onPick: (String) -> Unit,
|
onPick: (String) -> Unit,
|
||||||
@@ -149,7 +199,7 @@ private fun EmojiSection(
|
|||||||
// Scoped by section, as on iOS: the same emoji can sit
|
// Scoped by section, as on iOS: the same emoji can sit
|
||||||
// in Recent AND its own group, and a tag on the glyph
|
// in Recent AND its own group, and a tag on the glyph
|
||||||
// alone is ambiguous to a test.
|
// alone is ambiguous to a test.
|
||||||
.testTag("emoji-$title-$e"),
|
.testTag("emoji-$tag-$e"),
|
||||||
) {
|
) {
|
||||||
Text(e, fontSize = 24.sp, modifier = Modifier.padding(Space.xs))
|
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.
|
* 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.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -26,11 +27,13 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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 androidx.compose.ui.unit.sp
|
||||||
import io.flamenet.e2e.Poll
|
import io.flamenet.e2e.Poll
|
||||||
import io.flamenet.e2e.PollBook
|
import io.flamenet.e2e.PollBook
|
||||||
|
import io.flamenet.messenger.R
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A poll in a group, and what it is waiting for (§6.29).
|
* 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
|
// your vote and sees the tally, and subtracting gives it, with nobody
|
||||||
// to collude with. The word is not offered for a pair.
|
// to collude with. The word is not offered for a pair.
|
||||||
Text(
|
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,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = Palette.SecondaryText,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Text(poll.question, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
Text(poll.question, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
||||||
|
|
||||||
@@ -89,15 +92,15 @@ fun ColumnScope.PollCard(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
"You chose ${poll.options[poll.myChoice ?: 0]}.",
|
stringResource(R.string.social_poll_you_chose, poll.options[poll.myChoice ?: 0]),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = Palette.SecondaryText,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
waitingLine(poll, standing, name),
|
waitingLine(poll, standing, name),
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
color = Palette.SecondaryText,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,6 +108,7 @@ fun ColumnScope.PollCard(
|
|||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(Space.sm))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
private fun waitingLine(
|
private fun waitingLine(
|
||||||
poll: PollBook.Entry,
|
poll: PollBook.Entry,
|
||||||
standing: PollBook.Standing,
|
standing: PollBook.Standing,
|
||||||
@@ -113,27 +117,28 @@ private fun waitingLine(
|
|||||||
when (standing) {
|
when (standing) {
|
||||||
is PollBook.Standing.WaitingForVotes ->
|
is PollBook.Standing.WaitingForVotes ->
|
||||||
if (poll.myChoice == null) {
|
if (poll.myChoice == null) {
|
||||||
"Nobody sees a count until everyone has voted."
|
stringResource(R.string.social_poll_hidden_until_all_voted)
|
||||||
} else {
|
} 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 ->
|
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 -> ""
|
is PollBook.Standing.Counted -> ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
private fun list(
|
private fun list(
|
||||||
ids: List<Long>,
|
ids: List<Long>,
|
||||||
name: (Long) -> String,
|
name: (Long) -> String,
|
||||||
): String {
|
): String {
|
||||||
val names = ids.map(name)
|
val names = ids.map(name)
|
||||||
return when (names.size) {
|
return when (names.size) {
|
||||||
0 -> "nobody"
|
0 -> stringResource(R.string.social_poll_nobody)
|
||||||
1 -> names[0]
|
1 -> names[0]
|
||||||
2 -> "${names[0]} and ${names[1]}"
|
2 -> stringResource(R.string.social_poll_name_pair, names[0], names[1])
|
||||||
else -> names.dropLast(1).joinToString(", ") + " and " + names.last()
|
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.
|
// The guarantee, stated as it actually is.
|
||||||
Text(
|
Text(
|
||||||
if (poll.members.size > 2) {
|
if (poll.members.size > 2) {
|
||||||
"Nobody was sent anybody's vote. Everyone else here would have to " +
|
stringResource(R.string.social_poll_guarantee_group)
|
||||||
"work together to know how you voted."
|
|
||||||
} else {
|
} else {
|
||||||
"Nobody was sent your vote, but in a poll of two the other person " +
|
stringResource(R.string.social_poll_guarantee_pair)
|
||||||
"can work it out from the result."
|
|
||||||
},
|
},
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,13 +204,13 @@ fun NewPollDialog(
|
|||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("New poll") },
|
title = { Text(stringResource(R.string.social_poll_new)) },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) {
|
Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = question,
|
value = question,
|
||||||
onValueChange = { question = it },
|
onValueChange = { question = it },
|
||||||
label = { Text("Ask something") },
|
label = { Text(stringResource(R.string.social_poll_ask_something)) },
|
||||||
singleLine = false,
|
singleLine = false,
|
||||||
modifier = Modifier.fillMaxWidth().testTag("poll-question"),
|
modifier = Modifier.fillMaxWidth().testTag("poll-question"),
|
||||||
)
|
)
|
||||||
@@ -217,34 +220,29 @@ fun NewPollDialog(
|
|||||||
onValueChange = { text ->
|
onValueChange = { text ->
|
||||||
options = options.toMutableList().also { it[i] = 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,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth().testTag("poll-new-option-$i"),
|
modifier = Modifier.fillMaxWidth().testTag("poll-new-option-$i"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (options.size < Poll.MAX_OPTIONS) {
|
if (options.size < Poll.MAX_OPTIONS) {
|
||||||
TextButton(onClick = { options = 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
|
// Said before the poll is asked, not after, because it is what
|
||||||
// somebody is deciding on.
|
// somebody is deciding on.
|
||||||
Text(
|
Text(
|
||||||
when {
|
when {
|
||||||
members < 2 -> "A poll needs somebody else in the group."
|
members < 2 -> stringResource(R.string.social_poll_needs_members)
|
||||||
members == 2 ->
|
members == 2 ->
|
||||||
"In a group of two this is not anonymous: the other " +
|
stringResource(R.string.social_poll_pair_note)
|
||||||
"person can work out your vote from the result. " +
|
|
||||||
"It is still a poll."
|
|
||||||
|
|
||||||
else ->
|
else ->
|
||||||
"Nobody is sent anybody's vote, not even you as the " +
|
stringResource(R.string.social_poll_group_note)
|
||||||
"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."
|
|
||||||
},
|
},
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -256,9 +254,9 @@ fun NewPollDialog(
|
|||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
modifier = Modifier.testTag("poll-ask"),
|
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))
|
Spacer(Modifier.width(0.dp))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ fun ChannelHeader(
|
|||||||
.padding(start = 4.dp, end = 4.dp, top = 8.dp, bottom = 9.dp),
|
.padding(start = 4.dp, end = 4.dp, top = 8.dp, bottom = 9.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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) {
|
if (leading != null) {
|
||||||
leading()
|
leading()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class SafReferences(
|
|||||||
|
|
||||||
override fun read(bookmark: String): ByteArray =
|
override fun read(bookmark: String): ByteArray =
|
||||||
context.contentResolver.openInputStream(Uri.parse(bookmark))?.use { it.readBytes() }
|
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
|
* 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.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import io.flamenet.messenger.R
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Point the camera at somebody's address, the other half of [AddressCard].
|
* 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(
|
internal data class ScannedAddress(
|
||||||
val fnid: String,
|
val fnid: String,
|
||||||
val relay: String,
|
val relay: String,
|
||||||
val handle: String?,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `flamenet:<fnid>@<relay>[?u=<handle>&k=…]`, or the bare `fnid@relay` somebody
|
* A hello link (§6.34), which is what an Address screen's code holds now; the
|
||||||
* retypes.
|
* 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? {
|
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()
|
var s = text.trim()
|
||||||
if (s.lowercase().startsWith("flamenet:")) s = s.substring("flamenet:".length)
|
if (s.lowercase().startsWith("flamenet:")) s = s.substring("flamenet:".length)
|
||||||
var handle: String? = null
|
s = s.substringBefore('?')
|
||||||
val q = s.indexOf('?')
|
// The same rule as a hello link, so a code holding `mailto:someone@host`
|
||||||
if (q >= 0) {
|
// or a web address with an @ in it is not taken for a person.
|
||||||
s.substring(q + 1).split('&').forEach { pair ->
|
val address = HelloLink.canonical(s.trim()) ?: return null
|
||||||
if (pair.startsWith("u=")) handle = pair.substring(2)
|
val at = address.indexOf('@')
|
||||||
}
|
return ScannedAddress(address.substring(0, at), address.substring(at + 1))
|
||||||
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() })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. */
|
/** Full-screen camera dialog. Calls [onFound] once with the raw decoded text. */
|
||||||
@Composable
|
@Composable
|
||||||
fun ScanAddressDialog(
|
fun ScanAddressDialog(
|
||||||
onFound: (String) -> Unit,
|
onFound: (String) -> Unit,
|
||||||
onDismiss: () -> 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 context = LocalContext.current
|
||||||
val granted =
|
val granted =
|
||||||
@@ -77,8 +83,7 @@ fun ScanAddressDialog(
|
|||||||
// Says what to do, not just that it failed.
|
// Says what to do, not just that it failed.
|
||||||
Column(modifier = Modifier.align(Alignment.Center).padding(24.dp)) {
|
Column(modifier = Modifier.align(Alignment.Center).padding(24.dp)) {
|
||||||
Text(
|
Text(
|
||||||
"Flamenet cannot use the camera. Allow it in Android " +
|
denied,
|
||||||
"settings, or paste their address into Add contact instead.",
|
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
modifier = Modifier.testTag("scan-denied"),
|
modifier = Modifier.testTag("scan-denied"),
|
||||||
@@ -95,7 +100,7 @@ fun ScanAddressDialog(
|
|||||||
onRelease = { view -> (view.tag as? QrScan)?.stop() },
|
onRelease = { view -> (view.tag as? QrScan)?.stop() },
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"Point at the code on their Address screen.",
|
hint,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
modifier =
|
modifier =
|
||||||
@@ -111,7 +116,7 @@ fun ScanAddressDialog(
|
|||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.padding(top = 24.dp, end = 8.dp)
|
.padding(top = 24.dp, end = 8.dp)
|
||||||
.testTag("scan-cancel"),
|
.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."
|
"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
|
package io.flamenet.messenger.app
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import org.json.JSONObject
|
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 {
|
object StickerPack {
|
||||||
data class Sticker(
|
data class Sticker(
|
||||||
|
val pack: String,
|
||||||
val id: String,
|
val id: String,
|
||||||
val glyph: String,
|
val glyph: String,
|
||||||
val name: 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 CORE = "flamenet.core"
|
||||||
|
const val RETRO = "flamenet.retro"
|
||||||
|
|
||||||
private var loaded: Map<String, Sticker>? = null
|
/** Manifest order in the picker: the glyphs, then the art. */
|
||||||
private var order: List<Sticker> = emptyList()
|
private val PICKER_ORDER = listOf(CORE, RETRO)
|
||||||
private var fallbackGlyph = "🙂"
|
|
||||||
|
|
||||||
/** Every sticker in the shipped pack, in manifest order. */
|
private const val DEFAULT_FALLBACK = "\uD83D\uDE42"
|
||||||
fun all(context: Context): List<Sticker> {
|
|
||||||
ensure(context)
|
|
||||||
return order
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
@Volatile private var loaded: List<Pack>? = null
|
||||||
* 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
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
context: Context,
|
||||||
pack: String,
|
pack: String,
|
||||||
id: String,
|
id: String,
|
||||||
): String {
|
): Resolved = resolve(packs(context), pack, id)
|
||||||
ensure(context)
|
|
||||||
if (pack != CORE) return "Sticker"
|
|
||||||
return loaded?.get(id)?.name ?: "Sticker"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensure(context: Context) {
|
/** The image for a resolved sticker, or null when there is none or it will not decode. */
|
||||||
if (loaded != null) return
|
fun image(
|
||||||
// Failure is empty, not a crash.
|
context: Context,
|
||||||
val text =
|
path: String,
|
||||||
runCatching {
|
): ImageBitmap? = AssetImages.load(context, path)
|
||||||
context.assets
|
|
||||||
.open("stickers/$CORE.json")
|
|
||||||
.bufferedReader()
|
|
||||||
.use { it.readText() }
|
|
||||||
}.getOrNull() ?: run {
|
|
||||||
loaded = emptyMap()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val json =
|
private fun load(context: Context): List<Pack> {
|
||||||
runCatching { JSONObject(text) }.getOrNull()
|
// Failure is an empty list, never a crash: the picker says so.
|
||||||
?: run {
|
val names = runCatching { context.assets.list("stickers")?.toList() }.getOrNull().orEmpty()
|
||||||
loaded = emptyMap()
|
val packs =
|
||||||
return
|
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 array = json.optJSONArray("stickers")
|
||||||
val list = mutableListOf<Sticker>()
|
val stickers =
|
||||||
for (i in 0 until (array?.length() ?: 0)) {
|
(0 until (array?.length() ?: 0)).mapNotNull { i ->
|
||||||
val o = array!!.optJSONObject(i) ?: continue
|
val o = array!!.optJSONObject(i) ?: return@mapNotNull null
|
||||||
val id = o.optString("id")
|
val sid = o.optString("id").takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||||
if (id.isBlank()) continue
|
|
||||||
list +=
|
|
||||||
Sticker(
|
Sticker(
|
||||||
id = id,
|
pack = id,
|
||||||
glyph = o.optString("glyph").ifBlank { fallbackGlyph },
|
id = sid,
|
||||||
name = o.optString("name").ifBlank { id },
|
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.draw.clip
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.platform.testTag
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import io.flamenet.e2e.Vault
|
import io.flamenet.e2e.Vault
|
||||||
|
import io.flamenet.messenger.R
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stories as a feed you scroll, which is the kit's Timeline.
|
* Stories as a feed you scroll, which is the kit's Timeline.
|
||||||
@@ -76,17 +79,17 @@ fun ColumnScope.StoriesFeed(
|
|||||||
|
|
||||||
// A menu, not a picker.
|
// A menu, not a picker.
|
||||||
var choosing by remember { mutableStateOf(false) }
|
var choosing by remember { mutableStateOf(false) }
|
||||||
PortalHeader(title = "Stories") {
|
PortalHeader(title = stringResource(R.string.social_stories_title)) {
|
||||||
Box {
|
Box {
|
||||||
PortalHeaderIcon(
|
PortalHeaderIcon(
|
||||||
icon = KitIcon.Add,
|
icon = KitIcon.Add,
|
||||||
contentDescription = "New story",
|
contentDescription = stringResource(R.string.social_new_story),
|
||||||
modifier = Modifier.testTag("new-story"),
|
modifier = Modifier.testTag("new-story"),
|
||||||
onClick = { choosing = true },
|
onClick = { choosing = true },
|
||||||
)
|
)
|
||||||
DropdownMenu(expanded = choosing, onDismissRequest = { choosing = false }) {
|
DropdownMenu(expanded = choosing, onDismissRequest = { choosing = false }) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Photo or video") },
|
text = { Text(stringResource(R.string.social_story_photo_or_video)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
choosing = false
|
choosing = false
|
||||||
pickStory.launch("*/*")
|
pickStory.launch("*/*")
|
||||||
@@ -94,7 +97,7 @@ fun ColumnScope.StoriesFeed(
|
|||||||
modifier = Modifier.testTag("new-story-photo"),
|
modifier = Modifier.testTag("new-story-photo"),
|
||||||
)
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Several photos") },
|
text = { Text(stringResource(R.string.social_story_several_photos)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
choosing = false
|
choosing = false
|
||||||
pickStorySet.launch(
|
pickStorySet.launch(
|
||||||
@@ -107,7 +110,7 @@ fun ColumnScope.StoriesFeed(
|
|||||||
modifier = Modifier.testTag("new-story-set"),
|
modifier = Modifier.testTag("new-story-set"),
|
||||||
)
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Just words") },
|
text = { Text(stringResource(R.string.social_story_just_words)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
choosing = false
|
choosing = false
|
||||||
writingWords = true
|
writingWords = true
|
||||||
@@ -164,28 +167,27 @@ private fun StorySetComposerDialog(
|
|||||||
|
|
||||||
androidx.compose.material3.AlertDialog(
|
androidx.compose.material3.AlertDialog(
|
||||||
onDismissRequest = { model.discardStagedStorySet() },
|
onDismissRequest = { model.discardStagedStorySet() },
|
||||||
title = { Text("New story") },
|
title = { Text(stringResource(R.string.social_new_story)) },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) {
|
Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||||
Text(
|
Text(
|
||||||
"${items.size} pictures, posted as one story. The caption goes " +
|
pluralStringResource(R.plurals.social_story_set_summary, items.size, items.size),
|
||||||
"on the first.",
|
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.testTag("story-set-summary"),
|
modifier = Modifier.testTag("story-set-summary"),
|
||||||
)
|
)
|
||||||
androidx.compose.material3.OutlinedTextField(
|
androidx.compose.material3.OutlinedTextField(
|
||||||
value = caption.value,
|
value = caption.value,
|
||||||
onValueChange = { caption.value = it },
|
onValueChange = { caption.value = it },
|
||||||
label = { Text("Say something (optional)") },
|
label = { Text(stringResource(R.string.social_story_say_something_optional)) },
|
||||||
singleLine = false,
|
singleLine = false,
|
||||||
modifier = Modifier.fillMaxWidth().testTag("story-set-caption"),
|
modifier = Modifier.fillMaxWidth().testTag("story-set-caption"),
|
||||||
)
|
)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs)) {
|
||||||
listOf(
|
listOf(
|
||||||
Session.AudienceMode.EVERYONE to "Everyone",
|
Session.AudienceMode.EVERYONE to stringResource(R.string.social_audience_everyone),
|
||||||
Session.AudienceMode.ONLY to "Only…",
|
Session.AudienceMode.ONLY to stringResource(R.string.social_audience_only),
|
||||||
Session.AudienceMode.EXCEPT to "All except…",
|
Session.AudienceMode.EXCEPT to stringResource(R.string.social_audience_all_except),
|
||||||
).forEach { (m, label) ->
|
).forEach { (m, label) ->
|
||||||
androidx.compose.material3.FilterChip(
|
androidx.compose.material3.FilterChip(
|
||||||
selected = mode.value == m,
|
selected = mode.value == m,
|
||||||
@@ -228,12 +230,12 @@ private fun StorySetComposerDialog(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
modifier = Modifier.testTag("story-set-post"),
|
modifier = Modifier.testTag("story-set-post"),
|
||||||
) { Text("Post") }
|
) { Text(stringResource(R.string.social_post)) }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
androidx.compose.material3.TextButton(
|
androidx.compose.material3.TextButton(
|
||||||
onClick = { model.discardStagedStorySet() },
|
onClick = { model.discardStagedStorySet() },
|
||||||
) { Text("Cancel") }
|
) { Text(stringResource(R.string.common_cancel)) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -253,21 +255,24 @@ private fun StoryCard(
|
|||||||
*/
|
*/
|
||||||
setCount: Int = 1,
|
setCount: Int = 1,
|
||||||
) {
|
) {
|
||||||
|
val youLabel = stringResource(R.string.common_you)
|
||||||
|
val unknownAuthor = stringResource(R.string.social_user_fallback, story.authorId)
|
||||||
val author =
|
val author =
|
||||||
remember(story.authorId, phase.buddies) {
|
remember(story.authorId, phase.buddies) {
|
||||||
phase.buddies
|
phase.buddies
|
||||||
.firstOrNull { it.userId == story.authorId }
|
.firstOrNull { it.userId == story.authorId }
|
||||||
?.let { it.displayName.ifBlank { it.handle } }
|
?.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 =
|
val remaining =
|
||||||
remember(story.id) {
|
remember(story.id) {
|
||||||
val seconds = story.expires - System.currentTimeMillis() / 1000.0
|
val seconds = story.expires - System.currentTimeMillis() / 1000.0
|
||||||
when {
|
when {
|
||||||
story.expires <= 0L -> "no expiry"
|
story.expires <= 0L -> context.getString(R.string.social_story_no_expiry)
|
||||||
seconds <= 0 -> "expired"
|
seconds <= 0 -> context.getString(R.string.social_story_expired)
|
||||||
seconds < 3600 -> "${(seconds / 60).toInt()} min left"
|
seconds < 3600 -> context.getString(R.string.social_story_minutes_left, (seconds / 60).toInt())
|
||||||
else -> "${(seconds / 3600).toInt()} h left"
|
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
|
// The deadline, in the slot the kit gives a description, and
|
||||||
// §6.23's count beside it: a card that silently stands for
|
// §6.23's count beside it: a card that silently stands for
|
||||||
// five pictures is a card that loses four.
|
// 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(
|
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,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.testTag("story-set-count-${story.id}"),
|
modifier = Modifier.testTag("story-set-count-${story.id}"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -321,39 +339,32 @@ private fun StoryCard(
|
|||||||
|
|
||||||
Spacer(Modifier.height(Space.lg))
|
Spacer(Modifier.height(Space.lg))
|
||||||
|
|
||||||
// The image. 375x320 in the kit; an aspect ratio here, because the kit
|
// DESIGN item 27: the whole picture, never cut. The card takes the
|
||||||
// is drawn on one canvas and this has to hold on every phone.
|
// picture's own shape between a floor and a ceiling (`StoryFrame`),
|
||||||
Box(
|
// with plain bars past either. §6.13, an embedded live stream, sits in
|
||||||
modifier =
|
// the frame of its poster.
|
||||||
Modifier
|
val stream = story.streamUrl
|
||||||
.fillMaxWidth()
|
val streamHost = story.streamHost
|
||||||
.height(240.dp)
|
val ref = story.ref
|
||||||
.clip(RoundedCornerShape(Radius.small))
|
when {
|
||||||
.background(Palette.SurfaceAlt),
|
stream != null && streamHost != null &&
|
||||||
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 &&
|
|
||||||
io.flamenet.e2e.StreamDisclosure
|
io.flamenet.e2e.StreamDisclosure
|
||||||
.acceptable(stream)
|
.acceptable(stream) -> {
|
||||||
) {
|
StoryPhotoFrame(null, ref?.width ?: 0, ref?.height ?: 0) { StoryStreamGate(stream, streamHost) }
|
||||||
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)
|
|
||||||
|
|
||||||
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"),
|
modifier = Modifier.testTag("story-tally"),
|
||||||
) {
|
) {
|
||||||
tally.entries.sortedBy { it.key }.forEach { (emoji, count) ->
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,7 +444,7 @@ private fun StoryActions(
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.size(12.dp))
|
Spacer(Modifier.size(12.dp))
|
||||||
Text(
|
Text(
|
||||||
"Reply",
|
stringResource(R.string.social_story_reply),
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
color = Palette.Accent,
|
color = Palette.Accent,
|
||||||
@@ -439,10 +452,10 @@ private fun StoryActions(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.size(12.dp))
|
Spacer(Modifier.size(12.dp))
|
||||||
Text(
|
Text(
|
||||||
"Mute",
|
stringResource(R.string.common_mute),
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
color = Palette.SecondaryText,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.clickable { muting = true }.testTag("story-mute"),
|
modifier = Modifier.clickable { muting = true }.testTag("story-mute"),
|
||||||
)
|
)
|
||||||
// Reporting a story, which Android could not do. iOS has had it since
|
// Reporting a story, which Android could not do. iOS has had it since
|
||||||
@@ -451,10 +464,10 @@ private fun StoryActions(
|
|||||||
if (!story.mine) {
|
if (!story.mine) {
|
||||||
Spacer(Modifier.size(12.dp))
|
Spacer(Modifier.size(12.dp))
|
||||||
Text(
|
Text(
|
||||||
"Report",
|
stringResource(R.string.social_story_report),
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
color = Palette.SecondaryText,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.clickable { reporting = true }
|
.clickable { reporting = true }
|
||||||
@@ -464,9 +477,10 @@ private fun StoryActions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (reporting) {
|
if (reporting) {
|
||||||
|
val thisPerson = stringResource(R.string.social_this_person)
|
||||||
// The SAME sheet a message report uses, deliberately.
|
// The SAME sheet a message report uses, deliberately.
|
||||||
ReportSheet(
|
ReportSheet(
|
||||||
authorName = authorName.ifBlank { "this person" },
|
authorName = authorName.ifBlank { thisPerson },
|
||||||
preview = story.caption,
|
preview = story.caption,
|
||||||
onDismiss = { reporting = false },
|
onDismiss = { reporting = false },
|
||||||
onSend = { reason, includeContent, note ->
|
onSend = { reason, includeContent, note ->
|
||||||
@@ -484,20 +498,19 @@ private fun StoryActions(
|
|||||||
var draft by remember { mutableStateOf("") }
|
var draft by remember { mutableStateOf("") }
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { replying = false },
|
onDismissRequest = { replying = false },
|
||||||
title = { Text("Reply privately") },
|
title = { Text(stringResource(R.string.social_reply_privately)) },
|
||||||
// Says where it goes.
|
// Says where it goes.
|
||||||
text = {
|
text = {
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
"Goes to them in your conversation. Nobody else who " +
|
stringResource(R.string.social_reply_privately_note),
|
||||||
"saw this story can read it.",
|
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(Space.sm))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = draft,
|
value = draft,
|
||||||
onValueChange = { draft = it },
|
onValueChange = { draft = it },
|
||||||
placeholder = { Text("Message") },
|
placeholder = { Text(stringResource(R.string.social_story_reply_placeholder)) },
|
||||||
modifier = Modifier.fillMaxWidth().testTag("story-reply-field"),
|
modifier = Modifier.fillMaxWidth().testTag("story-reply-field"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -510,22 +523,20 @@ private fun StoryActions(
|
|||||||
},
|
},
|
||||||
enabled = draft.isNotBlank(),
|
enabled = draft.isNotBlank(),
|
||||||
modifier = Modifier.testTag("story-reply-send"),
|
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) {
|
if (muting) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { muting = false },
|
onDismissRequest = { muting = false },
|
||||||
title = { Text("Mute their stories?") },
|
title = { Text(stringResource(R.string.social_mute_stories_title)) },
|
||||||
// Says what muting is NOT.
|
// Says what muting is NOT.
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
"Their stories stop showing on this phone. They aren't " +
|
stringResource(R.string.social_mute_stories_body),
|
||||||
"told, and their messages still arrive as usual. You " +
|
|
||||||
"can turn it back on from their contact page.",
|
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -536,13 +547,168 @@ private fun StoryActions(
|
|||||||
model.setStoriesMuted(story.authorId, true)
|
model.setStoriesMuted(story.authorId, true)
|
||||||
},
|
},
|
||||||
modifier = Modifier.testTag("story-mute-confirm"),
|
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
|
* 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
|
* whichever screen picked it (the strip or the Stories tab) gets the same
|
||||||
@@ -588,39 +754,21 @@ fun StoryComposerDialog(
|
|||||||
}
|
}
|
||||||
androidx.compose.material3.AlertDialog(
|
androidx.compose.material3.AlertDialog(
|
||||||
onDismissRequest = { model.discardStagedStory() },
|
onDismissRequest = { model.discardStagedStory() },
|
||||||
title = { Text("New story") },
|
title = { Text(stringResource(R.string.social_new_story)) },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) {
|
Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||||
image?.let {
|
image?.let { StoryComposerPreview(it, draft.width, draft.height, draft.duration) }
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
androidx.compose.material3.OutlinedTextField(
|
androidx.compose.material3.OutlinedTextField(
|
||||||
value = caption.value,
|
value = caption.value,
|
||||||
onValueChange = { caption.value = it },
|
onValueChange = { caption.value = it },
|
||||||
placeholder = { Text("Add a caption…") },
|
placeholder = { Text(stringResource(R.string.social_story_add_caption)) },
|
||||||
modifier = Modifier.fillMaxWidth().testTag("story-caption"),
|
modifier = Modifier.fillMaxWidth().testTag("story-caption"),
|
||||||
)
|
)
|
||||||
// Who THIS story goes to.
|
// Who THIS story goes to.
|
||||||
androidx.compose.material3.OutlinedTextField(
|
androidx.compose.material3.OutlinedTextField(
|
||||||
value = streamUrl.value,
|
value = streamUrl.value,
|
||||||
onValueChange = { streamUrl.value = it },
|
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,
|
singleLine = true,
|
||||||
isError = !streamOk,
|
isError = !streamOk,
|
||||||
modifier = Modifier.fillMaxWidth().testTag("story-stream-field"),
|
modifier = Modifier.fillMaxWidth().testTag("story-stream-field"),
|
||||||
@@ -630,17 +778,14 @@ fun StoryComposerDialog(
|
|||||||
// moment of attaching one.
|
// moment of attaching one.
|
||||||
Text(
|
Text(
|
||||||
if (streamOk) {
|
if (streamOk) {
|
||||||
"Your picture becomes the cover. Anyone who taps play connects " +
|
stringResource(R.string.social_story_stream_ok_note)
|
||||||
"to that site, which will see their IP address, they are " +
|
|
||||||
"told before it happens."
|
|
||||||
} else {
|
} else {
|
||||||
"That needs to be a full http:// or https:// link with a site " +
|
stringResource(R.string.social_story_stream_bad_link)
|
||||||
"name in it."
|
|
||||||
},
|
},
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color =
|
color =
|
||||||
if (streamOk) {
|
if (streamOk) {
|
||||||
Palette.SecondaryText
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.error
|
MaterialTheme.colorScheme.error
|
||||||
},
|
},
|
||||||
@@ -657,7 +802,7 @@ fun StoryComposerDialog(
|
|||||||
androidx.compose.material3.FilterChip(
|
androidx.compose.material3.FilterChip(
|
||||||
selected = toGroup.value == null,
|
selected = toGroup.value == null,
|
||||||
onClick = { 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"),
|
modifier = Modifier.testTag("story-dest-contacts"),
|
||||||
)
|
)
|
||||||
phase.groups.forEach { group ->
|
phase.groups.forEach { group ->
|
||||||
@@ -672,18 +817,16 @@ fun StoryComposerDialog(
|
|||||||
}
|
}
|
||||||
if (toGroup.value != null) {
|
if (toGroup.value != null) {
|
||||||
Text(
|
Text(
|
||||||
"Everyone in that group receives it and it disappears after " +
|
stringResource(R.string.social_story_group_note),
|
||||||
"a day. Your story audience does not apply, and people " +
|
|
||||||
"outside the group are not sent it.",
|
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Palette.SecondaryText,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs)) {
|
||||||
listOf(
|
listOf(
|
||||||
Session.AudienceMode.EVERYONE to "Everyone",
|
Session.AudienceMode.EVERYONE to stringResource(R.string.social_audience_everyone),
|
||||||
Session.AudienceMode.ONLY to "Only…",
|
Session.AudienceMode.ONLY to stringResource(R.string.social_audience_only),
|
||||||
Session.AudienceMode.EXCEPT to "All except…",
|
Session.AudienceMode.EXCEPT to stringResource(R.string.social_audience_all_except),
|
||||||
).forEach { (m, label) ->
|
).forEach { (m, label) ->
|
||||||
androidx.compose.material3.FilterChip(
|
androidx.compose.material3.FilterChip(
|
||||||
selected = mode.value == m,
|
selected = mode.value == m,
|
||||||
@@ -719,19 +862,19 @@ fun StoryComposerDialog(
|
|||||||
Text(
|
Text(
|
||||||
when (mode.value) {
|
when (mode.value) {
|
||||||
Session.AudienceMode.EVERYONE -> {
|
Session.AudienceMode.EVERYONE -> {
|
||||||
"Goes to all your contacts and disappears after a day."
|
stringResource(R.string.social_story_audience_everyone_note)
|
||||||
}
|
}
|
||||||
|
|
||||||
Session.AudienceMode.ONLY -> {
|
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 -> {
|
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,
|
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.
|
// Refuses to post a stream URL it could not disclose.
|
||||||
enabled = streamOk,
|
enabled = streamOk,
|
||||||
modifier = Modifier.testTag("story-post"),
|
modifier = Modifier.testTag("story-post"),
|
||||||
) { Text("Post") }
|
) { Text(stringResource(R.string.social_post)) }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
androidx.compose.material3.TextButton(
|
androidx.compose.material3.TextButton(
|
||||||
onClick = { model.discardStagedStory() },
|
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
|
* The story cards themselves, everyone's stories, or one honest message when
|
||||||
* there are none.
|
* there are none.
|
||||||
@@ -782,16 +948,14 @@ fun ColumnScope.StoriesCards(
|
|||||||
Icon(
|
Icon(
|
||||||
KitIcon.Stories,
|
KitIcon.Stories,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Palette.SecondaryText,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.size(Space.xxl),
|
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(
|
Text(
|
||||||
"A story goes to your contacts and disappears after a day, on " +
|
stringResource(R.string.social_stories_empty_body),
|
||||||
"your device and theirs, because their app deletes it too. " +
|
|
||||||
"It is not a secret you can take back.",
|
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = Palette.SecondaryText,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -844,14 +1008,18 @@ private fun StoryVideoCard(
|
|||||||
|
|
||||||
failed.value -> {
|
failed.value -> {
|
||||||
Text(
|
Text(
|
||||||
"Video unavailable",
|
stringResource(R.string.social_video_unavailable),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = Palette.SecondaryText,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
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
|
if (bitmap.value == null) failed.value = true
|
||||||
}
|
}
|
||||||
val loaded = bitmap.value
|
val loaded = bitmap.value
|
||||||
when {
|
// The decoded picture's own shape when there is one: the envelope's size
|
||||||
loaded != null -> {
|
// is the sender's claim, the pixels are not.
|
||||||
androidx.compose.foundation.Image(
|
StoryPhotoFrame(
|
||||||
bitmap = loaded.asImageBitmap(),
|
bitmap = loaded?.asImageBitmap(),
|
||||||
contentDescription = "Story image",
|
photoWidth = loaded?.width ?: ref.width,
|
||||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
|
photoHeight = loaded?.height ?: ref.height,
|
||||||
modifier = Modifier.fillMaxSize(),
|
) {
|
||||||
)
|
Text(
|
||||||
}
|
if (failed.value) {
|
||||||
|
stringResource(R.string.social_image_unavailable)
|
||||||
failed.value -> {
|
} else {
|
||||||
Text(
|
stringResource(R.string.common_loading)
|
||||||
"Image unavailable",
|
},
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = Palette.SecondaryText,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
Text("Loading…", fontSize = 14.sp, color = Palette.SecondaryText)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -938,12 +1101,12 @@ private fun TextStoryComposer(
|
|||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("New story") },
|
title = { Text(stringResource(R.string.social_new_story)) },
|
||||||
text = {
|
text = {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = text,
|
value = text,
|
||||||
onValueChange = { text = it },
|
onValueChange = { text = it },
|
||||||
placeholder = { Text("Say something") },
|
placeholder = { Text(stringResource(R.string.social_story_say_something)) },
|
||||||
modifier = Modifier.fillMaxWidth().testTag("story-text-field"),
|
modifier = Modifier.fillMaxWidth().testTag("story-text-field"),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -952,8 +1115,8 @@ private fun TextStoryComposer(
|
|||||||
onClick = { onPost(text.trim()) },
|
onClick = { onPost(text.trim()) },
|
||||||
enabled = text.isNotBlank(),
|
enabled = text.isNotBlank(),
|
||||||
modifier = Modifier.testTag("story-text-post"),
|
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)) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||