diff --git a/.e2e-ref b/.e2e-ref index 012ea28..d57cc20 100644 --- a/.e2e-ref +++ b/.e2e-ref @@ -1 +1 @@ -4b850007a748be7d0716c0da3455a424cf58ddcc +4b4042f72880758ee2dea7713f7a2cb63fa12f73 diff --git a/.e2e-ref.md b/.e2e-ref.md index 0f83e1c..e5ed036 100644 --- a/.e2e-ref.md +++ b/.e2e-ref.md @@ -1,6 +1,6 @@ # Why this repository pins flamenet-e2e -`.e2e-ref` is the commit of [flamenet-e2e](https://gitlab.com/extant2000/flamenet-e2e) +`.e2e-ref` is the commit of [flamenet-e2e](https://git.extant2000.com/extant2000/flamenet-e2e) whose protocol vectors the `unit-tests` job checks against. `AccountKdfTest`, `PQVectorTest` and `PublishedVectorsTest` all read them. @@ -17,7 +17,7 @@ A pin alone is worse than HEAD, though. The argument for HEAD is real: a client tested against a pinned old harness is tested against a pinned old spec, and if the vectors move and nothing says so, this suite quietly stops checking the protocol anybody is actually running. That is why `vectors-head` exists and -is `allow_failure: true`. +is `continue-on-error: true` in `.gitea/workflows/ci.yml`. Being behind is LOUD and NON-BLOCKING. Being broken is a hard stop. A red `vectors-head` means an update is waiting, not that anything is wrong. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..045bcfc --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 6ee4b1a..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,226 +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/extant2000/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" - - export FLAMENET_PADDING_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/padding-v1.json" - - export FLAMENET_SYNC_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/sync-merge-v1.json" - - export FLAMENET_HANDLE_CERT_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/handle-cert-v1.json" - - export FLAMENET_LINK_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/links-v1.json" - - export FLAMENET_BRIDGE_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/bridge-offer-v1.json" - # 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" \ - "$FLAMENET_PADDING_VECTORS" "$FLAMENET_SYNC_VECTORS" \ - "$FLAMENET_HANDLE_CERT_VECTORS" "$FLAMENET_LINK_VECTORS" "$FLAMENET_BRIDGE_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/extant2000/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" - - export FLAMENET_PADDING_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/padding-v1.json" - - export FLAMENET_SYNC_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/sync-merge-v1.json" - - export FLAMENET_HANDLE_CERT_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/handle-cert-v1.json" - - export FLAMENET_LINK_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/links-v1.json" - - export FLAMENET_BRIDGE_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/bridge-offer-v1.json" - # Only the modules that read vectors. The rest of the suite has nothing - # to say about whether flamenet-e2e moved, and running it here would double - # the board's cost to answer a question it cannot answer. `:obfs4` reads - # the bridge-offer vectors (SPEC section 24). - - ./gradlew --no-daemon --rerun-tasks :engine:test :obfs4:test - timeout: 20m diff --git a/PARITY.md b/PARITY.md index b1403b6..4e6ee92 100644 --- a/PARITY.md +++ b/PARITY.md @@ -1206,3 +1206,50 @@ Android injects the policy and the counter into `Conversations`/`Session` as lambdas, those modules have no Android dependency, and wires them in ONE helper, because two sign-in paths building a session and only one applying the policy is how a setting silently stops working on half the app. + +## 0.32: stickers, cards, events, games, Today and hello links + +Everything 0.32 adds, with the DESIGN.md item that decided it where there is +one. Each row names the iOS file to re-check it against, and the Android test +that holds it. iOS was read on its `release/0-32-0` branch at `1230277f` +on 2026-10-04. + +| Feature | iOS | Android | Note | +|---|:--:|:--:|---| +| Sticker packs in FlameNet's art | yes | yes | iOS `StickerPack.swift` reads the same manifests; Android `StickerPack.kt`, every pack under `assets/stickers` | +| A sticker or a card to a group, queued for the members nothing reached | yes | yes | iOS `E2EInbox.sendGroupSticker`, `sendGroupCard`; Android `Session.sendGroupSticker`, `sendGroupCard` | +| Greeting cards (§6.31), sealed until a day | yes | yes | iOS `ChatCard.swift`, `CardViews.swift`; Android `CardUi.kt`, `CardArt.kt`, `CardsAndEventsInASessionTest` | +| Events and answers (§6.32) | yes | yes | iOS `ChatEvent.swift`, `EventViews.swift`; Android `ChatEvents.kt` (engine), `EventUi.kt`, `ChatEventsTest` | +| A rev 1 that takes over from a stand-in organizer is redrawn and never notifies (DESIGN 36) | yes | yes | iOS `ChatEvent.fold` returns `.settled`; Android `ChatEvents.fold` returns `SETTLED` | +| Event and game ids: any non-empty string, a space included (DESIGN 36) | yes | yes | iOS `ChatEvent.isValid`, `ChatGame.swift`; Android `Content.Event`, `Content.Game`, `ChatGames.fromRow` | +| An event runs 1 to 31 days, read and written (DESIGN 45) | yes | yes | iOS `ChatEvent.dayCount`; Android `Content.Event.clampDays`, the event composer | +| A card, an event or a game follows the chat's timer; its own `secs` is ignored (DESIGN 46) | yes | yes | iOS `E2EAttachment.swift`; Android `Content.lifetimeSeconds`, `CardEventEnvelopeTest` | +| Any `ctl` but 0, false or null makes an envelope control (DESIGN 43) | yes | yes | iOS `E2EAttachment.swift`; Android `Content.isControl`, `ControlFlagInteropTest` | +| Games in a one-to-one chat (§6.33): chess, checkers, reversi, tic-tac-toe | yes | yes | iOS `ChatGame.swift`, `GameViews.swift`, `FlamenetGames`; Android `:games`, `ChatGames.kt`, `GameUi.kt`; both engines pinned to `games-v1.json` | +| A `gmove` with no log or an empty one, and a `gend` with no log, are dropped (DESIGN 37) | yes | yes | iOS `GamePart.init`; Android `ChatGames.receive`, `ChatGamesTest` | +| A draw accept counts only at the whole known log (DESIGN 37) | yes | yes | iOS `GameState.ending`; Android `ChatGames.applyEnding` | +| A start or a move that carries `r` is still one; a field a kind does not use never drops it (DESIGN 44) | yes | yes | iOS `GameWire` reads `r` leniently; Android `Content.Game.fromJson`, `GameEnvelopeTest` | +| "Sam invited you to play chess", with a sound, and the chat is unread (DESIGN 29) | yes | yes | iOS `GameWords.invited`, `E2EInbox.receiveGame`; Android `Session.Notice.GameInvite`, `GamesInASessionTest` | +| A move, a draw offer or an ending is said but never unread (DESIGN 29) | yes | yes | iOS `GameWords.isQuiet`; Android `Session.takeGameNews`: an ending with a sound, a move or an offer without | +| Boards turn round for the second player, tic-tac-toe never (DESIGN 30) | yes | yes | iOS `GameViews.swift`; Android `ChatGames.turnedRound`, `GameScreensTest` | +| Games and events in the shared backup (§18) | yes | yes | iOS `E2EBackupPayload.swift`; Android `BackupPayload.kt` | +| One-to-one threads backed up under the user id | yes | yes | iOS `E2EBackupPayload.swift` keys `threads` by user id; Android `BackupPayload.kt`, `BackupThreadKeyTest` | +| Link previews on incoming messages | yes | yes | iOS `LinkPreview.swift`, `Message.swift`; Android `Session.kt`, `IncomingLinkPreviewTest` | +| The Today tab beside Messages (§8.12) | yes | yes | iOS `TodayFile.swift`, `TodayStore.swift`, `TodayView.swift`; Android `TodayFile.kt`, `TodayFeed.kt` (client), `TodayModel.kt`, `TodayScreen.kt` | +| Today fetch: once an hour, five minutes after a failure, an ETag only when it is the hash of the cached file (DESIGN 19) | yes | yes | iOS `TodayStore.swift`; Android `TodayFeedTest` | +| Today strictness: no redirects, a market needs a `name` and a non-empty `last` and nothing else, `v` is the integer `1` as written (DESIGN 38) | yes | yes | iOS `TodayFile.swift` (`TodayJSON.exactInteger`); Android `TodayFile.verify`, `TodayFileTest` | +| The Today file cited as §8.12, not §8.11 (DESIGN 34) | yes | yes | Android `TodayIsSection812Test` | +| Today bounds: a figure 24 characters, five notes of 200, `cc` two letters, `tz` 64, and an unknown zone shows the days as given (DESIGN 42) | yes | yes | iOS `TodayFile.swift`, `TodayFormat.swift`; Android `TodayFile.kt`, `TodayText.ahead`, `TodayFileTest`, `TodayTextTest` | +| The small-web directory and "Send to a chat" (§6.12) | yes | yes | iOS `SmallWebDirectory.swift`, `DirectoryView.swift`; Android `Directory.kt`, `DirectoryScreen.kt`, `DirectoryShare.kt` | +| Hello links (§6.34): the address card, the app link, the scanners | yes | yes | iOS `HelloLink.swift`, `MyAddressSection.swift`, `CardScanner.swift`; Android `HelloLink.kt`, `AddressCard.kt`, `ScanAddress.kt` | +| A hello link's path is exactly `/hi` or `/es/hi`: no trailing slash, no query (DESIGN 35) | yes | yes | iOS `HelloLink.swift`; Android `HelloLink.isHead`, `HelloLinkTest` | +| A hello link's host is ASCII, an address has no spaces, a fragment is 400 characters at most (DESIGN 41) | yes | yes | iOS `HelloLink.canonicalAddress`, `ContactCard.swift`; Android `HelloLink.canonical`, `ContactCard.decode` (typed or pasted), `HelloLinkTest`, `ContactCardTest` | +| Every scanner binds by fnid; an older card's `?u=` is ignored (DESIGN 32) | yes | yes | iOS `CardScanner.swift`; Android `prefillFor`, `HomeScanFillsInTheAddressTest` | +| Parental controls hide the Today tab and the directory (DESIGN 31) | yes | yes | iOS `ParentalControls.swift`, `MainTabView.swift`; Android `ParentalControls.todayHidden`, `NavDestinationsTest` | +| Sign-out and a lost-device wipe clear the Today file and the weather picks, and a fetch still on its way is dropped when it returns (DESIGN 32) | yes | yes | iOS `TodayStore.forgetOnThisPhone`, `TodayStore.generation`; Android `TodayData.forget`, the `TodayFeed` epoch, `TodayDataIsClearedTest`, `TodayFeedTest` | +| Edits, reactions, pins and story reactions go with the relay's silent flag and never notify (owner, 0.32) | partly | yes | iOS `E2EInbox.warrantsAlert` at `d7d0c83e` wakes for any envelope with text, an edit among them, and the owner has approved making all four silent there; Android `Conversations.warrantsAlert`, every kind pinned in `SilentKindsTest` | +| A stored account opens to its chats and the saved Today page with the relay out of reach, and connects after (DESIGN 47) | partly | yes | iOS is being changed to do so; Android `Session.storedRoster`, `Session.storedGroups`, `catchUpAfterStart`, `StoredStartStateTest` | +| A snoozed chat holds every notification, not only a game's | no | yes | Owner, 0.32. iOS `NotificationManager.postIncomingMessage` checks mute and never snooze, though `AttentionSummary` says a snoozed chat "stops asking"; Android `NotificationSettings.due`, `ASnoozedChatHoldsItsNotificationsTest` | +| A group photo that reached nobody is queued, and leaves pending when it goes | yes | yes | iOS `E2EInbox.sendGroupImage`; Android `Session.sendGroupImage`, `OutboxTest` | +| A photo, voice note or file to one person, and a file to a group, that reached nobody is queued and leaves pending when it goes (owner, 0.32) | no | yes | iOS `E2EInbox.sendAttachment` and `sendFile` stop with an error when the person has no device, and nothing waits; iOS has no group file send; Android `Session.sendUploaded`, `Session.sendGroupFile`, `OutboxTest` | +| Event, card, poll and stories feed secondary text reads in the dark theme | yes | yes | iOS `Theme.secondaryText` has a dark value; Android uses the theme's `onSurfaceVariant`, as the game card does; `CardsReadInTheDarkTest` | diff --git a/README.md b/README.md index 6df1671..fefd210 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ less than the two existing engines quietly agreed on, but only if it is written against the spec and the published vectors rather than against a reading of our own Swift and JavaScript. So the engine is held to `test/vectors/protocol-v1.json` in -[flamenet-e2e](https://gitlab.com/extant2000/flamenet-e2e), the same file the +[flamenet-e2e](https://git.extant2000.com/extant2000/flamenet-e2e), the same file the other two engines are checked against, read from that repository rather than copied here, because a copied vector file drifts and a drifting vector file stops being a check. `PublishedVectorsTest` looks for it beside this checkout diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 02f8b07..0879e66 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -51,6 +51,10 @@ android { sourceSets { getByName("main") { java.srcDir("src/main/kotlin") } getByName("androidTest") { java.srcDir("src/androidTest/kotlin") } + // Code that differs between the two builds. The Today dev key is + // compiled into debug builds and is absent from release. + getByName("debug") { java.srcDir("src/debug/kotlin") } + getByName("release") { java.srcDir("src/release/kotlin") } } buildFeatures { compose = true } @@ -198,6 +202,8 @@ android { dependencies { implementation(project(":engine")) implementation(project(":client")) + // The rules of the games played in chats (§6.33). Plain JVM, no Android. + implementation(project(":games")) // A direct dependency rather than leaning on `:client` to re-export it. // The settings screen reads bridge lines and the handshake counter itself, // so the app uses these types; borrowing them through another diff --git a/app/src/debug/kotlin/io/flamenet/messenger/app/TodayDevKey.kt b/app/src/debug/kotlin/io/flamenet/messenger/app/TodayDevKey.kt new file mode 100644 index 0000000..06a81ef --- /dev/null +++ b/app/src/debug/kotlin/io/flamenet/messenger/app/TodayDevKey.kt @@ -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 = listOf("o6fwSoRjw0zldgUOpSClF9/Z2ySQcf0bTDtmtoDgFoA=") +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f4076c0..ee0bd32 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -104,17 +104,26 @@ + diff --git a/app/src/main/assets/cards/birthday.png b/app/src/main/assets/cards/birthday.png new file mode 100644 index 0000000..74ff5b9 Binary files /dev/null and b/app/src/main/assets/cards/birthday.png differ diff --git a/app/src/main/assets/cards/congrats.png b/app/src/main/assets/cards/congrats.png new file mode 100644 index 0000000..73b1b95 Binary files /dev/null and b/app/src/main/assets/cards/congrats.png differ diff --git a/app/src/main/assets/cards/getwell.png b/app/src/main/assets/cards/getwell.png new file mode 100644 index 0000000..63cd894 Binary files /dev/null and b/app/src/main/assets/cards/getwell.png differ diff --git a/app/src/main/assets/cards/hello.png b/app/src/main/assets/cards/hello.png new file mode 100644 index 0000000..4b744d1 Binary files /dev/null and b/app/src/main/assets/cards/hello.png differ diff --git a/app/src/main/assets/cards/love.png b/app/src/main/assets/cards/love.png new file mode 100644 index 0000000..4a73156 Binary files /dev/null and b/app/src/main/assets/cards/love.png differ diff --git a/app/src/main/assets/cards/season.png b/app/src/main/assets/cards/season.png new file mode 100644 index 0000000..edae5c6 Binary files /dev/null and b/app/src/main/assets/cards/season.png differ diff --git a/app/src/main/assets/cards/sympathy.png b/app/src/main/assets/cards/sympathy.png new file mode 100644 index 0000000..47d0484 Binary files /dev/null and b/app/src/main/assets/cards/sympathy.png differ diff --git a/app/src/main/assets/cards/thanks.png b/app/src/main/assets/cards/thanks.png new file mode 100644 index 0000000..996bae4 Binary files /dev/null and b/app/src/main/assets/cards/thanks.png differ diff --git a/app/src/main/assets/cards/thinking.png b/app/src/main/assets/cards/thinking.png new file mode 100644 index 0000000..dee24e3 Binary files /dev/null and b/app/src/main/assets/cards/thinking.png differ diff --git a/app/src/main/assets/directory/directory-v1.json b/app/src/main/assets/directory/directory-v1.json new file mode 100644 index 0000000..549ec42 --- /dev/null +++ b/app/src/main/assets/directory/directory-v1.json @@ -0,0 +1,3060 @@ +{"v":1,"made":"2026-10-04","from":"https://flamenet.io/directory/", +"note":"Descriptions here were drafted from each site’s own pages with software, then rewritten and checked against the site. They are not the words of whoever runs it.", +"cats":[ +{"id":"arts-humanities","name":"Arts & Humanities","parent":""}, +{"id":"architecture","name":"Architecture","parent":"arts-humanities"}, +{"id":"design","name":"Design","parent":"arts-humanities"}, +{"id":"literature","name":"Literature","parent":"arts-humanities"}, +{"id":"music","name":"Music","parent":"arts-humanities"}, +{"id":"performing-arts","name":"Performing Arts","parent":"arts-humanities"}, +{"id":"photography","name":"Photography","parent":"arts-humanities"}, +{"id":"visual-arts","name":"Visual Arts","parent":"arts-humanities"}, +{"id":"business-economy","name":"Business & Economy","parent":""}, +{"id":"companies","name":"Companies","parent":"business-economy"}, +{"id":"e-commerce","name":"E-Commerce","parent":"business-economy"}, +{"id":"finance-investing","name":"Finance & Investing","parent":"business-economy"}, +{"id":"international-trade","name":"International Trade","parent":"business-economy"}, +{"id":"jobs-careers","name":"Jobs & Careers","parent":"business-economy"}, +{"id":"marketing-advertising","name":"Marketing & Advertising","parent":"business-economy"}, +{"id":"real-estate","name":"Real Estate","parent":"business-economy"}, +{"id":"small-business","name":"Small Business","parent":"business-economy"}, +{"id":"computers-internet","name":"Computers & Internet","parent":""}, +{"id":"hardware","name":"Hardware","parent":"computers-internet"}, +{"id":"internet","name":"Internet","parent":"computers-internet"}, +{"id":"networking","name":"Networking","parent":"computers-internet"}, +{"id":"programming-development","name":"Programming & Development","parent":"computers-internet"}, +{"id":"security","name":"Security","parent":"computers-internet"}, +{"id":"software","name":"Software","parent":"computers-internet"}, +{"id":"web-development","name":"Web Development","parent":"computers-internet"}, +{"id":"education","name":"Education","parent":""}, +{"id":"distance-learning","name":"Distance Learning","parent":"education"}, +{"id":"financial-aid","name":"Financial Aid","parent":"education"}, +{"id":"graduate-schools","name":"Graduate Schools","parent":"education"}, +{"id":"k-12","name":"K-12","parent":"education"}, +{"id":"libraries","name":"Libraries","parent":"education"}, +{"id":"special-education","name":"Special Education","parent":"education"}, +{"id":"entertainment","name":"Entertainment","parent":""}, +{"id":"celebrities","name":"Celebrities","parent":"entertainment"}, +{"id":"television","name":"Television","parent":"entertainment"}, +{"id":"games","name":"Games","parent":""}, +{"id":"government","name":"Government","parent":""}, +{"id":"international-organizations","name":"International Organizations","parent":"government"}, +{"id":"military","name":"Military","parent":"government"}, +{"id":"politics","name":"Politics","parent":"government"}, +{"id":"us-federal","name":"US Federal","parent":"government"}, +{"id":"health","name":"Health","parent":""}, +{"id":"alternative-medicine","name":"Alternative Medicine","parent":"health"}, +{"id":"medicine","name":"Medicine","parent":"health"}, +{"id":"mental-health","name":"Mental Health","parent":"health"}, +{"id":"news-media","name":"News & Media","parent":""}, +{"id":"magazines","name":"Magazines","parent":"news-media"}, +{"id":"newspapers","name":"Newspapers","parent":"news-media"}, +{"id":"online-media","name":"Online Media","parent":"news-media"}, +{"id":"radio","name":"Radio","parent":"news-media"}, +{"id":"television-news","name":"Television News","parent":"news-media"}, +{"id":"weather","name":"Weather","parent":"news-media"}, +{"id":"recreation-sports","name":"Recreation & Sports","parent":""}, +{"id":"automotive","name":"Automotive","parent":"recreation-sports"}, +{"id":"fitness","name":"Fitness","parent":"recreation-sports"}, +{"id":"hobbies-crafts","name":"Hobbies & Crafts","parent":"recreation-sports"}, +{"id":"outdoors-nature","name":"Outdoors & Nature","parent":"recreation-sports"}, +{"id":"pets","name":"Pets","parent":"recreation-sports"}, +{"id":"sports-recreation-sports","name":"Sports","parent":"recreation-sports"}, +{"id":"travel-tourism","name":"Travel & Tourism","parent":"recreation-sports"}, +{"id":"reference","name":"Reference","parent":""}, +{"id":"almanacs","name":"Almanacs","parent":"reference"}, +{"id":"dictionaries","name":"Dictionaries","parent":"reference"}, +{"id":"encyclopedias","name":"Encyclopedias","parent":"reference"}, +{"id":"phone-books","name":"Phone Books","parent":"reference"}, +{"id":"regional","name":"Regional","parent":""}, +{"id":"africa","name":"Africa","parent":"regional"}, +{"id":"asia","name":"Asia","parent":"regional"}, +{"id":"canada","name":"Canada","parent":"regional"}, +{"id":"europe","name":"Europe","parent":"regional"}, +{"id":"middle-east","name":"Middle East","parent":"regional"}, +{"id":"united-states","name":"United States","parent":"regional"}, +{"id":"science","name":"Science","parent":""}, +{"id":"astronomy","name":"Astronomy","parent":"science"}, +{"id":"biology","name":"Biology","parent":"science"}, +{"id":"chemistry","name":"Chemistry","parent":"science"}, +{"id":"mathematics","name":"Mathematics","parent":"science"}, +{"id":"physics","name":"Physics","parent":"science"}, +{"id":"social-sciences","name":"Social Sciences","parent":"science"}, +{"id":"shopping","name":"Shopping","parent":""}, +{"id":"consumer-electronics","name":"Consumer Electronics","parent":"shopping"}, +{"id":"society-culture","name":"Society & Culture","parent":""}, +{"id":"cultures-groups","name":"Cultures & Groups","parent":"society-culture"}, +{"id":"environment","name":"Environment","parent":"society-culture"}, +{"id":"food-drink-society-culture","name":"Food & Drink","parent":"society-culture"}, +{"id":"genealogy","name":"Genealogy","parent":"society-culture"}, +{"id":"people-community","name":"People & Community","parent":"society-culture"}, +{"id":"relationships-family","name":"Relationships & Family","parent":"society-culture"}, +{"id":"religion-spirituality","name":"Religion & Spirituality","parent":"society-culture"}, +{"id":"seniors","name":"Seniors","parent":"society-culture"} +], +"sites":[ +{"t":"Smithsonian Magazine","u":"https://www.smithsonianmag.com/","d":"History, science, arts and culture.","c":["arts-humanities","magazines"]}, +{"t":"ArchDaily","u":"https://www.archdaily.com/","d":"Architecture news, projects and interviews on contemporary building design worldwide.","c":["architecture"]}, +{"t":"Archidose","u":"http://www.archidose.org/","d":"Weekly dose of architecture with commentary on buildings old and new from around the globe.","c":["architecture"]}, +{"t":"ArchiExpo Virtual Fair","u":"http://www.archiexpo.com/","d":"An online architecture and design expo: building products, materials, furnishings.","c":["architecture"]}, +{"t":"Archinect","u":"https://archinect.com/","d":"Architecture school and professional resource with project portfolios and industry news.","c":["architecture"]}, +{"t":"ArchInform","u":"http://www.archinform.net/","d":"International architecture database indexing 40,000+ buildings and construction projects.","c":["architecture"]}, +{"t":"Architectural Record","u":"http://www.archrecord.com/","d":"An architecture magazine covering design, technology and the business of architecture, since 1891.","c":["architecture"]}, +{"t":"ArchitecturalDigest.com","u":"https://www.architecturaldigest.com/","d":"Architectural Digest online: luxury home tours, design ideas, celebrity homes.","c":["architecture"]}, +{"t":"Architectuul","u":"http://www.architectuul.com/","d":"Curated guide to the world's best contemporary and classic architecture with visitor reviews.","c":["architecture"]}, +{"t":"ArchNet","u":"https://archnet.org/","d":"Digital library of Islamic architecture and art from MIT and the Aga Khan Trust for Culture.","c":["architecture"]}, +{"t":"ArchVoices","u":"http://www.archvoices.org/","d":"Student-run advocacy group discussing issues facing emerging architects and the state of architecture education.","c":["architecture"]}, +{"t":"ArtDecoSociety.org","u":"http://www.artdecosociety.org/","d":"Art Deco architectural preservation society with building directories and events.","c":["architecture"]}, +{"t":"AtriumArchive","u":"http://www.atriumarchive.com/","d":"Online resource documenting Atrium architecture with photo galleries and historical notes.","c":["architecture"]}, +{"t":"BauhausGallery","u":"http://www.bauhausgallery.com/","d":"Architects discussing Bauhaus projects, blueprints and preservation.","c":["architecture"]}, +{"t":"Building Design Partnership","u":"http://www.bdp.com/","d":"An international practice of architects, designers, engineers and urbanists.","c":["architecture"]}, +{"t":"BuildingOnline","u":"http://www.buildingonline.com/","d":"Web design and marketing agency for building products manufacturers: strategy, development, CMS integration, SEO and email campaigns.","c":["architecture"]}, +{"t":"ChurchArchitecture.org","u":"http://www.churcharchitecture.org/","d":"Sacred architecture resource with church design history and restoration guides.","c":["architecture"]}, +{"t":"Coastal Archive Online","u":"http://www.coastalarchive.org/","c":["architecture"]}, +{"t":"CoastalJournal","u":"http://www.coastaljournal.com/","c":["architecture"]}, +{"t":"CraftsmanGallery","u":"http://www.craftsmangallery.com/","d":"Architecture enthusiast site exploring Craftsman structures from around the world.","c":["architecture"]}, +{"t":"CyArk","u":"http://www.cyark.org/","d":"Digital preservation of cultural heritage sites through laser scanning and 3D modeling technology.","c":["architecture"]}, +{"t":"DesignBoom Architecture","u":"http://www.designboom.com/architecture/","d":"Independent architecture and design magazine showcasing the best projects from around the world.","c":["architecture"]}, +{"t":"Emporis Buildings","u":"http://www.emporis.com/","d":"A global building data resource: high-rise stats, images, construction timelines.","c":["architecture"]}, +{"t":"FuturisticBuildings.com","u":"http://www.futuristicbuildings.com/","d":"Showcase of futuristic and experimental architecture projects worldwide.","c":["architecture"]}, +{"t":"GarageBuilder.com","u":"http://www.garagebuilder.com/","d":"Garage and workshop building plans with material calculators.","c":["architecture"]}, +{"t":"HistoricBuildingsDB","u":"http://www.historicbuildings.net/","d":"A database of historically significant buildings: photos, dates, architectural styles.","c":["architecture"]}, +{"t":"HomeDesignIdeas.net","u":"http://www.homedesignideas.net/","d":"Residential architecture inspiration: floor plans, exterior designs, landscaping.","c":["architecture"]}, +{"t":"HospitalDesign.com","u":"http://www.hospitaldesign.com/","d":"Healthcare facility architecture resource with patient safety design principles.","c":["architecture"]}, +{"t":"Industrial Gallery Online","u":"http://www.industrialgallery.com/","d":"A community of architects on industrial projects: blueprints, preservation.","c":["architecture"]}, +{"t":"Lighthouse Journal Online","u":"http://www.lighthousejournal.com/","d":"Online resource documenting Lighthouse architecture with photo galleries and historical notes.","c":["architecture"]}, +{"t":"Mimoa Guide","u":"http://www.mimoa.eu/","d":"Modern architecture travel guides curated by architects for architecture enthusiasts visiting cities.","c":["architecture"]}, +{"t":"MuseumDesign.org","u":"http://www.museumdesign.org/","d":"Museum architecture and exhibition design resource with case studies.","c":["architecture"]}, +{"t":"PoolDesigner.com","u":"http://www.pooldesigner.com/","d":"Swimming pool design and landscaping resource with contractor directories.","c":["architecture"]}, +{"t":"PrefabHomes.net","u":"http://www.prefabhomes.net/","d":"Prefabricated and modular home directory with manufacturer listings and pricing.","c":["architecture"]}, +{"t":"ResidentialDesigner.com","u":"http://www.residentialdesigner.com/","d":"Residential architecture portfolio gallery with custom home design services.","c":["architecture"]}, +{"t":"SchoolDesign.net","u":"http://www.schooldesign.net/","d":"Educational facility design resource with classroom layout and safety standards.","c":["architecture"]}, +{"t":"SkyScraperCity Forums","u":"http://www.skyscrapercity.com/","d":"World's largest forum for skyscraper and urban development enthusiasts with city-specific subforums.","c":["architecture"]}, +{"t":"SkyscraperDB.com","u":"http://www.skyscraperdb.com/","d":"A global skyscraper database: height rankings, floor counts, photos.","c":["architecture"]}, +{"t":"StorefrontDesign.com","u":"http://www.storefrontdesign.com/","d":"Retail store design and visual merchandising resource.","c":["architecture"]}, +{"t":"The Victorian Gallery","u":"http://www.victoriangallery.com/","c":["architecture"]}, +{"t":"The Villa Gallery","u":"http://www.villagallery.com/","d":"Where architects talk villa projects, blueprints and preservation.","c":["architecture"]}, +{"t":"TinyHouseBuild.com","u":"http://www.tinyhousebuild.com/","d":"Tiny house building plans, workshops, a lifestyle community.","c":["architecture"]}, +{"t":"UrbanPlanningOnline","u":"http://www.urbanplanningonline.com/","d":"A city planning resource: zoning information, smart growth strategies, case studies.","c":["architecture"]}, +{"t":"Victorian Archive Online","u":"http://www.victorianarchive.com/","c":["architecture"]}, +{"t":"Vitruvio.ch","u":"http://www.vitruvio.ch/","d":"Architecture portal connecting students and professionals with building design resources and forums.","c":["architecture"]}, +{"t":"World Architecture","u":"http://www.worldarchitecture.org/","d":"A directory of architectural firms, building projects and design competitions worldwide.","c":["architecture"]}, +{"t":"A List Apart","u":"https://www.alistapart.com/","d":"Web design and development articles exploring best practices for standards-based design.","c":["design"]}, +{"t":"AIGA The Professional Assoc.","u":"http://www.aiga.org/","d":"The professional association for design, advancing it as a craft, a strategic tool and a cultural force.","c":["design"]}, +{"t":"Behance Network","u":"http://www.behance.net/","d":"Online platform for creatives to showcase and discover creative work portfolios.","c":["design"]}, +{"t":"ButtonGallery","u":"http://www.buttongallery.com/","c":["design"]}, +{"t":"ColorSchemer","u":"http://www.colorschemer.com/","d":"Color scheme creation tools for web designers with complementary palette generators.","c":["design"]}, +{"t":"Communication Arts","u":"https://www.commarts.com/","d":"Visual communications magazine: graphic design, advertising, illustration.","c":["design"]}, +{"t":"Coolhunting","u":"https://www.coolhunting.com/","d":"Design, technology and culture, curated for the creatively curious.","c":["design"]}, +{"t":"Core77","u":"https://www.core77.com/","d":"Industrial design magazine: projects, job listings, design school portfolios.","c":["design"]}, +{"t":"CSS Zen Garden","u":"https://www.csszengarden.com/","d":"A demonstration of what can be accomplished through CSS-based design. One HTML, infinite designs.","c":["design"]}, +{"t":"Dafont.com","u":"http://www.dafont.com/","d":"Archive of freely downloadable fonts organized by style and category for personal and commercial use.","c":["design"]}, +{"t":"Design Observer","u":"https://designobserver.com/","d":"Writings on design and culture from practitioners and critics.","c":["design"]}, +{"t":"DesignAwards.org","u":"http://www.designawards.org/","d":"International design awards directory with submission guidelines and past winners.","c":["design"]}, +{"t":"DesignBoom","u":"http://www.designboom.com/","d":"An independent international magazine on architecture, design, art and technology.","c":["design"]}, +{"t":"DesignContests.com","u":"http://www.designcontests.com/","d":"A crowdsourced design contest platform: logos, websites, packaging.","c":["design"]}, +{"t":"DesignHistory.org","u":"http://www.designhistory.org/","d":"The history of graphic, industrial and fashion design, with timelines.","c":["design"]}, +{"t":"Designspiration","u":"http://www.designspiration.com/","d":"Visual search engine for discovering creative inspiration from designers and artists.","c":["design"]}, +{"t":"DesignTaxi","u":"http://www.designtaxi.com/","d":"Creative industry news: graphic design, advertising, art, architecture, technology.","c":["design"]}, +{"t":"EcoDesign.org","u":"http://www.ecodesign.org/","d":"Sustainable and eco-friendly product design resource with material guides.","c":["design"]}, +{"t":"FashionDesignSchool.com","u":"http://www.fashiondesignschool.com/","d":"Fashion design education resource with school listings and portfolio tips.","c":["design"]}, +{"t":"FontShop Intl","u":"http://www.fontshop.com/","d":"A digital type foundry and retailer, with a large library of professional typefaces.","c":["design"]}, +{"t":"GraphicDesignForum","u":"http://www.graphicdesignforum.com/","d":"A community for graphic designers: critique boards, tutorials, job postings.","c":["design"]}, +{"t":"HOW Design Magazine","u":"http://www.howdesign.com/","d":"Graphic design magazine with practical business and creative guidance for working designers.","c":["design"]}, +{"t":"I Love Typography","u":"https://ilovetypography.com/","d":"Font shop selling curated typefaces from independent foundries to license and download, including free and discounted fonts.","c":["design"]}, +{"t":"InfoGraphicWorld","u":"http://www.infographicworld.com/","d":"Data visualization and infographic design resource with tools and inspiration.","c":["design"]}, +{"t":"Interior Design Magazine","u":"https://www.interiordesign.net/","d":"Interior design news, projects and product information, for professionals.","c":["design"]}, +{"t":"JewelryDesignPro.com","u":"http://www.jewelrydesignpro.com/","d":"Jewelry design tutorials: metalwork, gemstones, CAD.","c":["design"]}, +{"t":"LogoGallery","u":"http://www.logogallery.net/","d":"Tutorials and inspiration for Logo design with downloadable templates.","c":["design"]}, +{"t":"LogoLounge","u":"http://www.logolounge.com/","d":"Searchable database of logo designs submitted by designers worldwide with annual logo trend reports.","c":["design"]}, +{"t":"Online Origami","u":"https://www.origami.com/","d":"Diagrams, folding instructions and community galleries for the Japanese art of paper folding.","c":["design"]}, +{"t":"PackagingDesigner.com","u":"http://www.packagingdesigner.com/","d":"Product packaging design gallery with structural and graphic design portfolios.","c":["design"]}, +{"t":"PackagingStudio","u":"http://www.packagingstudio.com/","c":["design"]}, +{"t":"Pattern Workshop Online","u":"http://www.patternworkshop.com/","c":["design"]}, +{"t":"PatternGallery","u":"http://www.patterngallery.com/","d":"Sewing pattern shop carrying Simplicity, McCall's, Butterick and Know Me designs for garments, costumes and bags.","c":["design"]}, +{"t":"PixelPerfectDesign","u":"http://www.pixelperfectdesign.com/","d":"Personal site of a self-taught designer with experience in print marketing management, branding and promotional work.","c":["design"]}, +{"t":"Poster Studio Online","u":"http://www.posterstudio.com/","d":"Online shop selling fine art prints and framed canvases by emerging artists.","c":["design"]}, +{"t":"SIGGRAPH","u":"https://www.siggraph.org/","d":"ACM's computer graphics and interactive techniques conference with research papers and art galleries.","c":["design"]}, +{"t":"Signage Studio Online","u":"http://www.signagestudio.com/","d":"Resource for designers specializing in Signage projects and visual identity.","c":["design"]}, +{"t":"SignageWorkshop","u":"http://www.signageworkshop.com/","d":"Signage company handling consultation, design, fabrication, installation and regulatory approvals for signboards.","c":["design"]}, +{"t":"SignDesigner.com","u":"http://www.signdesigner.com/","d":"Sign and wayfinding design resource with material and fabrication guides.","c":["design"]}, +{"t":"Skin Gallery Online","u":"http://www.skingallery.com/","c":["design"]}, +{"t":"SkinStudio","u":"http://www.skinstudio.net/","c":["design"]}, +{"t":"Smashing Magazine","u":"http://www.smashingmagazine.com/","d":"Online magazine for web designers and developers. Articles on coding, design, UX.","c":["design"]}, +{"t":"StationeryGallery","u":"http://www.stationerygallery.com/","c":["design"]}, +{"t":"StockIcons.com","u":"http://www.stockicons.com/","d":"Portfolio of the Iconfactory, a studio that designs icons and interfaces for apps.","c":["design"]}, +{"t":"TextileDesignStudio","u":"http://www.textiledesignstudio.com/","d":"Textile and fabric pattern design tutorials with printing methods.","c":["design"]}, +{"t":"The Brochure Gallery","u":"http://www.brochuregallery.com/","d":"Tutorials and inspiration for Brochure design with downloadable templates.","c":["design"]}, +{"t":"The Packaging Workshop","u":"http://www.packagingworkshop.com/","c":["design"]}, +{"t":"The Pattern Studio","u":"http://www.patternstudio.com/","c":["design"]}, +{"t":"The Poster Gallery","u":"http://www.postergallery.com/","d":"Showcase of Poster design trends with critique boards and how-to articles.","c":["design"]}, +{"t":"The Stationery Studio","u":"http://www.stationerystudio.net/","c":["design"]}, +{"t":"TypePad Design","u":"http://www.typepad.com/design/","d":"Blog and community platform popular with designers for sharing portfolios and design journals.","c":["design"]}, +{"t":"Typographica","u":"https://typographica.org/","d":"Reviews of typefaces, type books and the design of letterforms.","c":["design"]}, +{"t":"UXPioneer.com","u":"http://www.uxpioneer.com/","d":"Development and advertising agency that builds websites, runs ad campaigns and handles marketing.","c":["design"]}, +{"t":"VectorGraphics.org","u":"http://www.vectorgraphics.org/","d":"Free vector graphics download library organized by category and style.","c":["design"]}, +{"t":"Wallpaper Gallery Online","u":"http://www.wallpapergallery.com/","d":"Free Wallpaper design assets and walkthroughs for the aspiring graphic artist.","c":["design"]}, +{"t":"Webby Awards","u":"https://www.webbyawards.com/","d":"The internet's most prestigious award recognizing excellence in website design and digital creativity.","c":["design"]}, +{"t":"WebDesignMuseum","u":"http://www.webdesignmuseum.org/","d":"Archive of influential web designs from the 1990s and 2000s with screenshots.","c":["design"]}, +{"t":"WebMonkey","u":"https://www.webmonkey.com/","d":"Wired's web development tutorial site. How-tos on HTML, JavaScript and backend programming.","c":["design"]}, +{"t":"WireframeTools.com","u":"http://www.wireframetools.com/","d":"Website wireframing and prototyping tool comparisons with tutorials.","c":["design"]}, +{"t":"Wordmark Studio Online","u":"http://www.wordmarkstudio.com/","d":"Independent design studio based in Salt Lake City, Utah.","c":["design"]}, +{"t":"AudioBookReview.com","u":"http://www.audiobookreview.com/","d":"Audiobook reviews and narrator profiles with listening recommendations.","c":["literature"]}, +{"t":"Bartleby.com","u":"https://www.bartleby.com/","d":"Public domain fiction, nonfiction, reference and verse. Great Books Online.","c":["literature"]}, +{"t":"Bibliomania","u":"http://www.bibliomania.com/","d":"Free online literature: over 2,000 classic texts, study guides, reference resources.","c":["literature"]}, +{"t":"BiographyChannel.com","u":"http://www.biographychannel.com/","d":"Biographies, articles and videos about historical figures, celebrities, musicians and authors.","c":["literature"]}, +{"t":"Book-A-Minute","u":"http://www.rinkworks.com/bookaminute/","d":"Ultra-condensed versions of classic books that give you the gist in about a minute.","c":["literature"]}, +{"t":"BookAwardsList.com","u":"http://www.bookawardslist.com/","d":"A list of literary awards, with past winners and nomination criteria.","c":["literature"]}, +{"t":"BookCrossing","u":"http://www.bookcrossing.com/","d":"Global book sharing community where members release books into the wild for others to find.","c":["literature"]}, +{"t":"BookPage Reviews","u":"http://www.bookpage.com/","d":"A monthly book review publication: fiction, nonfiction, children's books, audiobooks.","c":["literature"]}, +{"t":"Bookwire","u":"http://www.bookwire.com/","d":"Digital publishing platform for publishers handling production, distribution, marketing and analytics of ebooks, audiobooks and print-on-demand.","c":["literature"]}, +{"t":"Daily Lit","u":"https://www.dailylit.com/","d":"Read books by email or RSS, delivered in short daily installments that fit your schedule.","c":["literature"]}, +{"t":"ElectricStory.com","u":"http://www.electricstory.com/","d":"Publisher of new science fiction and fantasy works in electronic and print editions.","c":["literature"]}, +{"t":"Electronic Literature Organization","u":"https://eliterature.org/","d":"Promoting the reading, writing, teaching and understanding of literature as it develops digitally.","c":["literature"]}, +{"t":"FanFiction.net","u":"https://www.fanfiction.net/","d":"The largest fan fiction archive on the internet with stories from every fandom imaginable.","c":["literature","entertainment"]}, +{"t":"FictionAlley.org","u":"http://www.fictionalley.org/","d":"Fan fiction archive featuring Harry Potter stories reviewed and organized by genre.","c":["literature"]}, +{"t":"FlashFictionDaily","u":"http://www.flashfictiondaily.com/","d":"Daily flash fiction publication with stories under 1,000 words from emerging writers.","c":["literature"]}, +{"t":"HistoricalFiction.net","u":"http://www.historicalfiction.net/","d":"Historical fiction reviews organized by era and region.","c":["literature"]}, +{"t":"LiteraryAgents.com","u":"http://www.literaryagents.com/","d":"Literary agent directory with query letter advice and submission tracking.","c":["literature"]}, +{"t":"LiteraryAnalysis.net","u":"http://www.literaryanalysis.net/","d":"Study guides and critical essays on classic and modern literary works.","c":["literature"]}, +{"t":"Luminarium","u":"https://www.luminarium.org/","d":"Anthology of English literature from Medieval to Restoration periods with critical essays.","c":["literature"]}, +{"t":"Narrative Magazine","u":"http://www.narrativemagazine.com/","d":"An online literary magazine publishing fiction, poetry and nonfiction by new and established authors.","c":["literature"]}, +{"t":"Noir Archive Online","u":"http://www.noirarchive.com/","d":"A study companion for noir: summaries, themes, analysis.","c":["literature"]}, +{"t":"Online Books Page","u":"https://onlinebooks.library.upenn.edu/","d":"University of Pennsylvania index of over 25,000 free books available on the web.","c":["literature"]}, +{"t":"Online Literature Library","u":"http://www.literature.org/","d":"Full text of classic works of English literature from Shakespeare to Dickens to Twain.","c":["literature"]}, +{"t":"Poetry Foundation","u":"https://www.poetryfoundation.org/","d":"Home of Poetry magazine, with an archive of poems and poet biographies.","c":["literature"]}, +{"t":"PoetryArchive.org","u":"http://www.poetryarchive.org/","d":"Archive of recorded poets reading their own work with biographies.","c":["literature"]}, +{"t":"PoetrySoup.com","u":"http://www.poetrysoup.com/","d":"An online poetry community: member-submitted poems, contests, writing prompts.","c":["literature"]}, +{"t":"PoetryWorkshop.org","u":"http://www.poetryworkshop.org/","d":"Personal site of poet Mickie Kennedy, author of the poetry collections Worth Burning and Glandscapes.","c":["literature"]}, +{"t":"Poets.org","u":"https://www.poets.org","d":"The Academy of American Poets website. Poems, biographies, resources.","c":["literature"]}, +{"t":"Project Gutenberg","u":"https://www.gutenberg.org/","d":"Over 25,000 free eBooks of classic literature, freely reproducible worldwide.","c":["literature"]}, +{"t":"PublishersMarketplace.com","u":"http://www.publishersmarketplace.com/","d":"Book deal tracking service with agent and editor directories.","c":["literature"]}, +{"t":"Salon Books","u":"http://www.salon.com/books/","d":"Salon's book review section: author interviews, essays, literary criticism.","c":["literature"]}, +{"t":"Shakespeare Online","u":"https://www.shakespeare-online.com/","d":"The complete works of Shakespeare, with annotations, study guides and performance history.","c":["literature"]}, +{"t":"ShortStoryArchive","u":"http://www.shortstoryarchive.com/","d":"Free collection of classic short stories organized by author and genre.","c":["literature"]}, +{"t":"ShortStoryGuide.com","u":"http://www.shortstoryguide.com/","d":"Guide to the best short stories available online organized by theme.","c":["literature"]}, +{"t":"The Fairy Tales Archive","u":"http://www.fairytalesarchive.com/","d":"Collection of traditional fairytales and folklore from around the world, with new stories added regularly.","c":["literature"]}, +{"t":"The Gutenberg Project","u":"http://www.gutenberg.net/","d":"Mirror site of Project Gutenberg offering thousands of free classic literature eBooks.","c":["literature"]}, +{"t":"The Literary Kicks","u":"https://www.litkicks.com/","d":"Literary website running for 32 years, now publishing articles, podcasts and a greatest hits archive.","c":["literature"]}, +{"t":"The Speculative Fiction Review","u":"http://www.speculativefictionreview.com/","d":"Online literary journal celebrating Speculative Fiction with essays and recommendations.","c":["literature"]}, +{"t":"ThrillerBooks.com","u":"http://www.thrillerbooks.com/","d":"Thriller and suspense book reviews with bestseller lists.","c":["literature"]}, +{"t":"TravelWriting.com","u":"http://www.travelwriting.com/","d":"Travel writing resource with publication markets and craft articles.","c":["literature"]}, +{"t":"WarPoetry.org","u":"http://www.warpoetry.org/","d":"War poetry archive from WWI through modern conflicts with poet biographies.","c":["literature"]}, +{"t":"WriteANovel.com","u":"http://www.writeanovel.com/","d":"Novel writing resource with plot structure guides and NaNoWriMo preparation.","c":["literature"]}, +{"t":"WritersDigestOnline","u":"http://www.writersdigestonline.com/","d":"Writing advice, workshop listings and publishing industry news.","c":["literature"]}, +{"t":"Zuzu's Petals Quarterly","u":"http://www.zuzu.com/","d":"An independent literary journal publishing poetry, fiction and creative nonfiction online.","c":["literature"]}, +{"t":"AbsolutePunk.net","u":"http://www.absolutepunk.net/","d":"Music news and reviews: punk rock, emo, hardcore, indie.","c":["music"]}, +{"t":"AcousticGuitar.com","u":"http://www.acousticguitar.com/","d":"Acoustic guitar magazine: fingerstyle lessons, reviews, artist interviews.","c":["music"]}, +{"t":"All Music Guide","u":"https://www.allmusic.com/","d":"Music database with reviews, artist biographies and complete discographies.","c":["music"]}, +{"t":"BandCamp Underground","u":"http://www.bandcamp.com/","d":"Platform for independent musicians to sell music directly to fans.","c":["music"]}, +{"t":"BandNameGenerator.com","u":"http://www.bandnamegenerator.com/","d":"Random band name generator with genre-specific suggestions and availability checking.","c":["music"]}, +{"t":"BearShare","u":"https://www.bearshare.com/","d":"Gnutella-based peer-to-peer file sharing client for music and media downloads.","c":["music"]}, +{"t":"Billboard Online","u":"https://www.billboard.com/","d":"The Hot 100, album charts and music industry news, from the chart of record.","c":["music"]}, +{"t":"Bluegrass Forum Online","u":"http://www.bluegrassforum.com/","d":"For bluegrass fans. Discographies, tablature, concert listings.","c":["music"]}, +{"t":"BluesGuitar.com","u":"http://www.bluesguitar.com/","d":"Blues guitar instruction: tabs, video lessons, artist profiles.","c":["music"]}, +{"t":"BluesWeb","u":"http://www.bluesweb.com/","d":"Blues music resource: artist profiles, CD reviews, festival calendars, links.","c":["music"]}, +{"t":"BrooklynVegan.com","u":"http://www.brooklynvegan.com/","d":"NYC music blog: concerts, indie rock, punk, metal, festival news.","c":["music"]}, +{"t":"ChoralMusic.org","u":"http://www.choralmusic.org/","d":"A choral music resource: repertoire, recording reviews, festival listings.","c":["music"]}, +{"t":"Classical Archives","u":"https://www.classicalarchives.com/","d":"Largest classical music streaming site with MIDI and MP3 recordings of thousands of works.","c":["music"]}, +{"t":"ClassicalArchive.com","u":"http://www.classicalarchive.com/","d":"Classical music streaming and encyclopedia with composer biographies and discographies.","c":["music"]}, +{"t":"CMT.com","u":"https://www.cmt.com/","d":"Country Music Television: music videos, artist news, concert information.","c":["music"]}, +{"t":"ConcertArchive.com","u":"http://www.concertarchive.com/","d":"Official site of the band O.A.R., with music, tour dates, tickets and merchandise.","c":["music"]}, +{"t":"CountryMusicCentral","u":"http://www.countrymusiccentral.com/","c":["music"]}, +{"t":"CountryMusicWeb.com","u":"http://www.countrymusicweb.com/","d":"Country music news, album reviews, Nashville industry coverage.","c":["music"]}, +{"t":"DJTechTools.com","u":"http://www.djtechtools.com/","d":"DJ equipment reviews, mixing tutorials, performance technique guides.","c":["music"]}, +{"t":"Doo-Wop Radio Online","u":"http://www.doowopradio.net/","d":"Internet radio station playing doowop, the vocal group harmony style that originated in the 1950s.","c":["music"]}, +{"t":"DrumLessons.com","u":"http://www.drumlessons.com/","d":"Online drum lessons with video tutorials and practice exercises.","c":["music"]}, +{"t":"DrummerWorld.com","u":"http://www.drummerworld.com/","d":"A drummer's resource: video lessons, gear reviews, artist profiles.","c":["music"]}, +{"t":"eDonkey","u":"https://www.edonkey2000.com/","d":"Decentralized peer-to-peer file sharing network using the eDonkey protocol.","c":["music"]}, +{"t":"ElectronicMusicGuide","u":"http://www.electronicmusicguide.com/","d":"An electronic music resource: genre guides, DJ tutorials, production tips.","c":["music"]}, +{"t":"eMusic","u":"https://www.emusic.com/","d":"Independent music download store with DRM-free MP3s from indie labels.","c":["music"]}, +{"t":"Epitaph Records","u":"http://www.epitaph.com/","d":"Independent punk rock label: artist pages, music, tour dates.","c":["music"]}, +{"t":"FilmMusic.net","u":"http://www.filmmusic.net/","d":"Film score and soundtrack composer resource with reviews and interviews.","c":["music"]}, +{"t":"Grokster","u":"https://www.grokster.com/","d":"Peer-to-peer file sharing client similar to Kazaa, the subject of a landmark Supreme Court case.","c":["music"]}, +{"t":"Guitar World","u":"https://www.guitarworld.com/","d":"Guitar lessons, gear reviews, tablature, interviews with the greats.","c":["music"]}, +{"t":"GuitarLessonsOnline.com","u":"http://www.guitarlessonsonline.com/","d":"Free guitar lessons with video tutorials covering acoustic and electric styles.","c":["music"]}, +{"t":"Harmony Central","u":"http://www.harmony-central.com/","d":"A musician's resource: gear reviews, forums, classifieds, recording tips.","c":["music"]}, +{"t":"Hip-HopBuzz","u":"http://www.hiphopbuzz.com/","d":"Entertainment hub tracking Hip-Hop for music lovers.","c":["music"]}, +{"t":"IndiePages.com","u":"http://www.indiepages.com/","d":"Record label, mail-order distro and Portland, Oregon record shop specializing in indiepop, power pop and lo-fi pop.","c":["music"]}, +{"t":"Jazz Online","u":"http://www.jazzonline.com/","d":"Jazz magazine: album reviews, artist interviews, concert listings, streaming audio.","c":["music"]}, +{"t":"JazzStandards.com","u":"http://www.jazzstandards.com/","d":"Jazz standards reference: history, chord charts, recording recommendations.","c":["music"]}, +{"t":"KaraokeOnline.com","u":"http://www.karaokeonline.com/","d":"Free online karaoke with lyrics display and backing tracks for popular songs.","c":["music"]}, +{"t":"KaraokeSource.com","u":"http://www.karaokesource.com/","d":"Karaoke song database with backing track downloads and lyrics display.","c":["music"]}, +{"t":"KlezmerArchive","u":"http://www.klezmerarchive.org/","d":"Demo site for a digital archive of klezmer melodies and related people, with sample tune pages.","c":["music"]}, +{"t":"LastFM.com","u":"http://www.last.fm/","d":"Social music platform tracking your listening habits and recommending new music.","c":["music"]}, +{"t":"Launch.com","u":"https://www.launch.com/","d":"Streaming music and music videos with personalized radio stations. Acquired by Yahoo!","c":["music"]}, +{"t":"Lilith Fair","u":"https://www.lilithfair.com/","d":"All-female touring music festival founded by Sarah McLachlan featuring women in rock and folk.","c":["music"]}, +{"t":"LimeWire","u":"https://www.limewire.com/","d":"Free Gnutella-based P2P file sharing client for music and other media.","c":["music","software"]}, +{"t":"LiveMusicVenues.com","u":"http://www.livemusicvenues.com/","d":"A concert venue directory: capacity, acoustics, booking info.","c":["music"]}, +{"t":"LiveNation.com","u":"http://www.livenation.com/","d":"A concert and live event promoter: tour dates, venues, ticket sales.","c":["music"]}, +{"t":"Metacritic Music","u":"http://www.metacritic.com/music/","d":"Aggregated music critic scores for albums with review excerpts and user ratings.","c":["music"]}, +{"t":"MetalUnderground.com","u":"http://www.metalunderground.com/","d":"Heavy metal news, reviews and community, for metal fans.","c":["music"]}, +{"t":"MIDIWorld.com","u":"http://www.midiworld.com/","d":"MIDI file archive with free downloads organized by artist and genre.","c":["music"]}, +{"t":"MP3.com","u":"https://www.mp3.com/","d":"Discover, stream and download music in MP3 format, from independent and signed artists.","c":["music","software"]}, +{"t":"MTV Online","u":"https://www.mtv.com/","d":"Show pages and schedules for MTV's lifestyle and competition reality series, plus live events such as the VMAs.","c":["music"]}, +{"t":"MusicalInstruments.com","u":"http://www.musicalinstruments.com/","d":"Musical instrument buying guide with reviews for beginners.","c":["music"]}, +{"t":"MusicBiz.com","u":"http://www.musicbiz.com/","d":"Music business education: contracts, royalties, distribution.","c":["music"]}, +{"t":"MusicBrainz","u":"http://www.musicbrainz.org/","d":"Open music encyclopedia that collects and makes available music metadata to the public.","c":["music"]}, +{"t":"MusicGearReview.com","u":"http://www.musicgearreview.com/","d":"Musical instrument and gear reviews from working musicians and hobbyists.","c":["music"]}, +{"t":"MusicProductionSchool","u":"http://www.musicproductionschool.com/","d":"Music production tutorials: DAWs, mixing, mastering.","c":["music"]}, +{"t":"MusicStore.com","u":"http://www.musicstore.com/","d":"Online instrument retailer: guitars, keyboards, accessories.","c":["music"]}, +{"t":"NewWaveForum","u":"http://www.newwaveforum.com/","d":"A new wave fan community, with discographies, tablature and concert listings.","c":["music"]}, +{"t":"NME","u":"https://www.nme.com/","d":"The UK music and entertainment publication, covering new artists and album reviews.","c":["music"]}, +{"t":"Pandora Radio","u":"https://www.pandora.com/","d":"Personalized internet radio using the Music Genome Project to match songs to your taste.","c":["music"]}, +{"t":"PianoTeacher.net","u":"http://www.pianoteacher.net/","d":"A piano instruction resource: lesson plans, sheet music, technique videos.","c":["music"]}, +{"t":"PunkNews.org","u":"http://www.punknews.org/","d":"Punk rock news, reviews, interviews, message boards.","c":["music"]}, +{"t":"RapGenius.com","u":"http://www.rapgenius.com/","d":"Hip-hop lyrics annotation community explaining references and wordplay.","c":["music"]}, +{"t":"RecordLabel.net","u":"http://www.recordlabel.net/","d":"Independent record label directory with demo submission guidelines.","c":["music"]}, +{"t":"ReggaeVibes.com","u":"http://www.reggaevibes.com/","d":"Reggae, ska and dancehall news, with album reviews and artist profiles.","c":["music"]}, +{"t":"Rhapsody Music","u":"http://www.listen.com/","d":"Subscription-based music streaming service with millions of songs from major and indie labels.","c":["music"]}, +{"t":"Rolling Stone","u":"https://www.rollingstone.com/","d":"America's rock and roll magazine, covering music, film and popular culture.","c":["music"]}, +{"t":"SaxophoneWorld.com","u":"http://www.saxophoneworld.com/","d":"Japanese magazine for saxophone players, with issue contents, beginner jam session events and an online shop.","c":["music"]}, +{"t":"SongMeanings.net","u":"http://www.songmeanings.net/","d":"Community-driven song lyrics and interpretations with member discussions on song meanings.","c":["music"]}, +{"t":"SongwritersCafe","u":"http://www.songwriterscafe.com/","d":"A songwriting community: co-writing tools, lyric critique, demo sharing.","c":["music"]}, +{"t":"SoulSeek","u":"https://www.slsknet.org/","d":"File sharing network favored by music collectors for rare and independent recordings.","c":["music"]}, +{"t":"SoundClick","u":"http://www.soundclick.com/","d":"A music community hosting original songs, bands and listener charts, with free downloads.","c":["music"]}, +{"t":"Spin Magazine","u":"https://www.spin.com/","d":"Alternative and indie music coverage, album reviews, artist interviews.","c":["music"]}, +{"t":"StereoGum.com","u":"http://www.stereogum.com/","d":"Music blog with MP3s, reviews and news, leaning indie and alternative.","c":["music"]}, +{"t":"StudioRecording.com","u":"http://www.studiorecording.com/","d":"Recording studio technique: microphones, mixing, mastering.","c":["music"]}, +{"t":"SubPop Records","u":"http://www.subpop.com/","d":"The Seattle indie label that launched grunge, with Nirvana, Soundgarden and Mudhoney.","c":["music"]}, +{"t":"Surf Rock Radio Online","u":"http://www.surfrockradio.com/","d":"Music zine covering Surf Rock artists with interviews and rare recordings.","c":["music"]}, +{"t":"SynthMania.com","u":"http://www.synthmania.com/","d":"Synthesizer and electronic music production resource with patch libraries.","c":["music"]}, +{"t":"The Bluegrass Radio","u":"http://www.bluegrassradio.com/","d":"Radio network and home of the nationally syndicated bluegrass show Into the Blue.","c":["music"]}, +{"t":"The Classical Music Pages","u":"http://www.classicalmusicpages.com/","d":"A guide to classical music: composer biographies, discographies, concert listings.","c":["music"]}, +{"t":"The Heavy Metal Buzz","u":"http://www.heavymetalbuzz.com/","d":"A heavy metal fan community: charts, videos, discussion.","c":["music"]}, +{"t":"The Klezmer Radio","u":"http://www.klezmerradio.com/","d":"Klezmer history archived, with sound clips, liner notes and biographies.","c":["music"]}, +{"t":"The Power Pop Radio","u":"http://www.powerpopradio.com/","d":"Internet radio and reviews dedicated to Power Pop around the clock.","c":["music"]}, +{"t":"Ticketmaster","u":"https://www.ticketmaster.com/","d":"Concert, sports and event tickets, from America's dominant ticketing company.","c":["music"]}, +{"t":"Trip-HopRadio","u":"http://www.triphopradio.com/","d":"An archive of trip-hop history: sound clips, liner notes, biographies.","c":["music"]}, +{"t":"Ultimate Guitar Tabs","u":"http://www.ultimate-guitar.com/","d":"The largest guitar tab archive on the internet: chords, tabs, bass tablature.","c":["music"]}, +{"t":"VH1","u":"https://www.vh1.com/","d":"Celebrity reality television series, including Love & Hip Hop, The Surreal Life and To Catch a Beautician.","c":["music"]}, +{"t":"VinylCollectors.com","u":"http://www.vinylcollectors.com/","d":"A record collecting community: grading guides, want lists, trading forums.","c":["music"]}, +{"t":"VinylRecordClub.com","u":"http://www.vinylrecordclub.com/","d":"Monthly vinyl subscription service delivering curated albums to collectors.","c":["music"]}, +{"t":"ViolinMasterclass.com","u":"http://www.violinmasterclass.com/","d":"Violin instruction: technique videos, repertoire, a teacher directory.","c":["music"]}, +{"t":"WeirdAl.com","u":"https://www.weirdal.com/","d":"The official Weird Al Yankovic site: discography, tour dates, music video clips.","c":["music"]}, +{"t":"WinMX","u":"https://www.winmx.com/","d":"Free peer-to-peer file sharing program with chat rooms and a loyal user community.","c":["music"]}, +{"t":"WorldMusicCentral","u":"http://www.worldmusiccentral.org/","d":"World music news, album reviews and artist profiles, from every continent.","c":["music"]}, +{"t":"American Ballet Theatre","u":"http://www.abt.org/","d":"The official ABT site: ballet history, dancer profiles, season calendar, educational resources.","c":["performing-arts"]}, +{"t":"ArtsJournal","u":"https://www.artsjournal.com/","d":"A daily digest of arts and culture news, pulled from newspapers, magazines and the web.","c":["performing-arts"]}, +{"t":"BalletCompanies.com","u":"http://www.balletcompanies.com/","d":"Directory of ballet companies worldwide with season schedules and dancer profiles.","c":["performing-arts"]}, +{"t":"Broadway.com","u":"https://www.broadway.com/","d":"Broadway show tickets, reviews and behind-the-scenes features from New York theater.","c":["performing-arts"]}, +{"t":"CircusArtsGuild","u":"http://www.circusartsguild.com/","d":"Resource for Circus Arts performers with technique guides and audition listings.","c":["performing-arts"]}, +{"t":"CircusWeb","u":"http://www.circusweb.com/","d":"Circus news, history, performer profiles, event listings.","c":["performing-arts"]}, +{"t":"CurtainUp","u":"https://www.curtainup.com/","d":"Theater reviews, features and listings: Broadway, Off-Broadway, London's West End.","c":["performing-arts"]}, +{"t":"Dance Magazine Online","u":"http://www.dancemagazine.com/","d":"Dance publication: news, reviews, audition listings, training advice.","c":["performing-arts"]}, +{"t":"DanceResource.org","u":"http://www.danceresource.org/","d":"A dance education directory: studio listings, scholarship info, competition schedules.","c":["performing-arts"]}, +{"t":"DanceTabs.com","u":"http://www.dancetabs.com/","d":"Archive of dance and ballet reviews and galleries published from 2012 until the site closed in January 2022.","c":["performing-arts"]}, +{"t":"ImprovComedy.org","u":"http://www.improvcomedy.org/","d":"Improvisational comedy: theater listings, class directories, performer bios.","c":["performing-arts"]}, +{"t":"Internet Broadway Database","u":"https://www.ibdb.com/","d":"Broadway theatre history: shows, people, productions.","c":["performing-arts"]}, +{"t":"Kennedy Center","u":"https://www.kennedy-center.org/","d":"Performing arts schedules, educational programs and arts information from the national center.","c":["performing-arts"]}, +{"t":"MagicCastle.com","u":"http://www.magiccastle.com/","d":"The Academy of Magical Arts in Hollywood. Membership info, show schedule, magic history.","c":["performing-arts"]}, +{"t":"OperaBase","u":"http://www.operabase.com/","d":"A worldwide opera schedule database: cast lists, production photos, company directories.","c":["performing-arts"]}, +{"t":"Playbill Online","u":"https://www.playbill.com/","d":"Broadway and off-Broadway theater news, show listings, ticket information.","c":["performing-arts"]}, +{"t":"Puppetry Workshop Online","u":"http://www.puppetryworkshop.com/","d":"Online and in-person puppetry courses and workshops in puppet-making and performance, based in London.","c":["performing-arts"]}, +{"t":"StageAgent","u":"https://www.stageagent.com/","d":"Monologues, audition songs and show guides for theater actors and students.","c":["performing-arts"]}, +{"t":"TalkinBroadway","u":"http://www.talkinbroadway.com/","d":"Musical theatre fan site: show reviews, feature articles, active discussion forums.","c":["performing-arts"]}, +{"t":"The Stage Newspaper","u":"http://www.thestage.co.uk/","d":"The UK entertainment industry newspaper, covering theatre, television and the performing arts.","c":["performing-arts"]}, +{"t":"TheatreCrafts.com","u":"http://www.theatrecrafts.com/","d":"Technical theatre: set construction, props, costumes, stage management.","c":["performing-arts"]}, +{"t":"VoiceActorsNet","u":"http://www.voiceactors.net/","d":"A voice acting community: demo reels, casting calls, training resources.","c":["performing-arts"]}, +{"t":"WorldOfDance","u":"http://www.worldofdance.com/","d":"Dance competition, convention and performance listings, with video archives.","c":["performing-arts"]}, +{"t":"ArchitecturalPhoto.com","u":"http://www.architecturalphoto.com/","d":"Architectural photography techniques with perspective correction and lighting.","c":["photography"]}, +{"t":"Astrophotography Workshop Online","u":"http://www.astrophotographyworkshop.com/","d":"Astrophotography workshops and tours at dark sky locations in Europe, the USA and South America.","c":["photography"]}, +{"t":"ConcertPhotography.com","u":"http://www.concertphotography.com/","d":"Concert photography techniques with camera settings and venue tips.","c":["photography"]}, +{"t":"Digital Photography Review (DPReview)","u":"https://www.dpreview.com","d":"Reviews of digital cameras and accessories. Forums for photography enthusiasts.","c":["photography"]}, +{"t":"DigitalDarkroom","u":"http://www.digitaldarkroom.com/","d":"Photoshop and Lightroom tutorials for photographers with workflow optimization.","c":["photography"]}, +{"t":"DigitalTruth Film Chart","u":"http://www.digitaltruth.com/","d":"Film development time charts and darkroom technique resources for analog photographers.","c":["photography"]}, +{"t":"ePHOTOzine","u":"http://www.ephotozine.com/","d":"UK photography magazine online: gear reviews, technique guides, photo competitions.","c":["photography"]}, +{"t":"Food Gallery Online","u":"http://www.foodgallery.com/","d":"Resource exploring Food photography with location tips and editing walkthroughs.","c":["photography"]}, +{"t":"FredMiranda.com","u":"http://www.fredmiranda.com/","d":"Photography buy/sell forum and community with image galleries and Photoshop actions.","c":["photography"]}, +{"t":"HDR Gallery Online","u":"http://www.hdrgallery.com/","d":"Photography gallery and tutorials devoted to HDR imagery.","c":["photography"]}, +{"t":"ImageKind","u":"http://www.imagekind.com/","d":"Online gallery marketplace where photographers and artists sell prints directly to buyers.","c":["photography"]}, +{"t":"LandscapeWorkshop","u":"http://www.landscapeworkshop.com/","c":["photography"]}, +{"t":"LargeFormatPhotography.info","u":"http://www.largeformatphotography.info/","d":"Forum and resource center for large format camera users and fine art photographers.","c":["photography"]}, +{"t":"LensReviews.com","u":"http://www.lensreviews.com/","d":"Camera lens reviews: MTF charts, sample images, comparison tools.","c":["photography"]}, +{"t":"Lomography","u":"https://www.lomography.com/","d":"Experimental and analog photography community with camera reviews and photo galleries.","c":["photography"]}, +{"t":"Luminous Landscape","u":"http://www.luminous-landscape.com/","d":"Landscape and nature photography: essays, tutorials, equipment reviews, galleries.","c":["photography"]}, +{"t":"Macro Gallery Online","u":"http://www.macrogallery.com/","d":"Community of shutterbugs sharing Macro photos and critique.","c":["photography"]}, +{"t":"National Geographic Photography","u":"https://www.nationalgeographic.com/photography/","d":"World-class nature and documentary photography from the National Geographic archive.","c":["photography"]}, +{"t":"NatureShots.org","u":"http://www.natureshots.org/","d":"A nature photography community: wildlife galleries, trip reports, gear guides.","c":["photography"]}, +{"t":"NightWorkshop","u":"http://www.nightworkshop.com/","d":"Photography gallery and tutorials devoted to Night imagery.","c":["photography"]}, +{"t":"Ofoto","u":"https://www.ofoto.com/","d":"Digital photo processing and printing service letting you order prints from uploaded photos.","c":["photography"]}, +{"t":"Photo.net","u":"https://www.photo.net/","d":"Photography community. Equipment reviews, technique articles, user galleries.","c":["photography"]}, +{"t":"PhotoEditSoftware.com","u":"http://www.photoeditsoftware.com/","d":"Photo editing software reviews: Photoshop, Lightroom, the alternatives.","c":["photography"]}, +{"t":"PhotographyBlog.com","u":"http://www.photographyblog.com/","d":"Camera reviews, photography news and technique tutorials, updated daily.","c":["photography"]}, +{"t":"PhotographyReview.com","u":"http://www.photographyreview.com/","d":"Consumer camera reviews and ratings from real photographers with comparison tools.","c":["photography"]}, +{"t":"PhotoPrintShop.com","u":"http://www.photoprintshop.com/","d":"Online photo printing: canvas, metal, acrylic.","c":["photography"]}, +{"t":"ShutterBug Magazine","u":"http://www.shutterbug.com/","d":"Photography magazine: camera reviews, technique articles, reader photo galleries.","c":["photography"]}, +{"t":"Shutterfly","u":"https://www.shutterfly.com/","d":"Online photo printing, sharing and storage, plus custom photo books and gifts.","c":["photography"]}, +{"t":"StevesDigicams","u":"http://www.steves-digicams.com/","d":"Digital camera reviews since 1999, with sample photos, specifications and video.","c":["photography"]}, +{"t":"StockPhotoGuide.com","u":"http://www.stockphotoguide.com/","d":"Guide to selling stock photography with agency comparisons and keywording tips.","c":["photography"]}, +{"t":"The Landscape Gallery","u":"http://www.landscapegallery.com/","c":["photography"]}, +{"t":"The Pinhole Gallery","u":"http://www.pinholegallery.com/","d":"Technique guide for Pinhole photography with sample shots and gear advice.","c":["photography"]}, +{"t":"The Sports Workshop","u":"http://www.sportsworkshop.com/","c":["photography"]}, +{"t":"The Underwater Gallery","u":"http://www.underwatergallery.net/","d":"Gallery of underwater photographs of fish, sharks, mantas, whales, nudibranchs, seahorses and dive locations.","c":["photography"]}, +{"t":"UnderwaterWorkshop","u":"http://www.underwaterworkshop.com/","d":"Showcase of Underwater photography with monthly contests and how-to articles.","c":["photography"]}, +{"t":"Webshots","u":"https://www.webshots.com/","d":"Free photo sharing and desktop wallpaper community with millions of user-uploaded images.","c":["photography"]}, +{"t":"Art History Resources","u":"http://www.arthistoryresources.net/","d":"Art history links for students, organized by period, region and medium.","c":["visual-arts"]}, +{"t":"Artchive","u":"https://www.artchive.com/","d":"Mark Harden's online gallery featuring high-resolution images and commentary on fine art masterworks.","c":["visual-arts"]}, +{"t":"ArtCyclopedia","u":"http://www.artcyclopedia.com/","d":"Fine art search engine indexing 9,000+ artists with links to museums and image archives.","c":["visual-arts"]}, +{"t":"ArtistResidency.org","u":"http://www.artistresidency.org/","d":"Artist residency program directory with application deadlines and stipends.","c":["visual-arts"]}, +{"t":"Artnet","u":"https://www.artnet.com/","d":"Fine art auction results, gallery listings, contemporary art market news.","c":["visual-arts"]}, +{"t":"ArtSupplies.com","u":"http://www.artsupplies.com/","d":"An art supply retailer: paints, brushes, canvas, studio furniture.","c":["visual-arts"]}, +{"t":"CalligraphyArts.com","u":"http://www.calligraphyarts.com/","d":"Calligraphy services for reproduction and events, with a blog of recent posts.","c":["visual-arts"]}, +{"t":"CalligraphyStudio","u":"http://www.calligraphystudio.com/","d":"Online gallery and tutorials for Calligraphy artists of every skill level.","c":["visual-arts"]}, +{"t":"CeramicArtDaily","u":"http://www.ceramicartdaily.org/","d":"Ceramic art and pottery tutorials with kiln reviews and glaze recipe databases.","c":["visual-arts"]}, +{"t":"CeramicsMonthly","u":"http://www.ceramicsmonthly.org/","d":"Pottery and ceramics magazine: technique articles, artist features, kiln reviews.","c":["visual-arts"]}, +{"t":"CGFA Virtual Art Museum","u":"http://www.cgfa.org/","d":"Carol Gerten-Jackson's fine art collection with thousands of paintings organized by artist.","c":["visual-arts"]}, +{"t":"DeviantArt","u":"https://www.deviantart.com/","d":"Artists share, critique and sell original art here, digital and traditional.","c":["visual-arts"]}, +{"t":"Elfwood","u":"https://www.elfwood.com/","d":"Community gallery for fantasy and sci-fi artists and writers to share original work.","c":["visual-arts"]}, +{"t":"FolkArtStudio","u":"http://www.folkartstudio.com/","c":["visual-arts"]}, +{"t":"GlassArtSociety","u":"http://www.glassart.org/","d":"An international organization for glass artists: member gallery, events, educational programs.","c":["visual-arts"]}, +{"t":"GlassBlowing.com","u":"http://www.glassblowing.com/","d":"Glass blowing studio directory with technique videos and supply sources.","c":["visual-arts"]}, +{"t":"Guggenheim Museum","u":"https://www.guggenheim.org/","d":"Modern and contemporary art at the Guggenheim. The collection, exhibitions, the building itself.","c":["visual-arts"]}, +{"t":"Homestar Runner","u":"https://www.homestarrunner.com","d":"Flash-animated web cartoons with Homestar Runner, Strong Bad and the gang. Updated weekly.","c":["visual-arts","entertainment"]}, +{"t":"Illustration Art Gallery","u":"http://www.illustrationartgallery.com/","d":"Online gallery devoted to illustration art from Golden Age illustrators to modern digital artists.","c":["visual-arts"]}, +{"t":"LifeDrawing.org","u":"http://www.lifedrawing.org/","d":"Volunteer-run nonprofit in Vancouver offering life drawing sessions, gallery shows and educational opportunities to members.","c":["visual-arts"]}, +{"t":"Louvre Museum Online","u":"https://www.louvre.fr/","d":"Virtual tours and collection database from the world's most visited art museum in Paris.","c":["visual-arts"]}, +{"t":"MetalSculpture.com","u":"http://www.metalsculpture.com/","d":"A metal sculpture resource: welding techniques, galleries, commission info.","c":["visual-arts"]}, +{"t":"MixedMediaArt.com","u":"http://www.mixedmediaart.com/","d":"Mixed media techniques, tutorials and artist showcases, blending mediums.","c":["visual-arts"]}, +{"t":"MOMA Online","u":"https://www.moma.org/","d":"The Museum of Modern Art online. Collection, exhibitions, artist profiles.","c":["visual-arts"]}, +{"t":"Mosaic Studio Online","u":"http://www.mosaicstudio.com/","c":["visual-arts"]}, +{"t":"Newgrounds","u":"https://www.newgrounds.com/","d":"Flash animation portal. User-created cartoons, games and music since 1995.","c":["visual-arts","entertainment"]}, +{"t":"Pen and Ink Studio Online","u":"http://www.penandinkstudio.com/","d":"Step-by-step Pen and Ink lessons with material guides and project ideas.","c":["visual-arts"]}, +{"t":"Pop Art Studio Online","u":"http://www.popartstudio.net/","c":["visual-arts"]}, +{"t":"PotteryStudio","u":"http://www.potterystudio.org/","c":["visual-arts"]}, +{"t":"PublicArtGuide.com","u":"http://www.publicartguide.com/","d":"Public art installation directory with maps and commission information.","c":["visual-arts"]}, +{"t":"Renderosity","u":"https://www.renderosity.com/","d":"A 3D art community: galleries, tutorials, a digital art marketplace.","c":["visual-arts"]}, +{"t":"SculptureGallery.com","u":"http://www.sculpturegallery.com/","d":"An online sculpture gallery: 3D views, artist statements, commission information.","c":["visual-arts"]}, +{"t":"SculptureStudio","u":"http://www.sculpturestudio.com/","d":"Showcase celebrating Sculpture with artist interviews and historical features.","c":["visual-arts"]}, +{"t":"Smithsonian American Art","u":"https://americanart.si.edu/","d":"The Smithsonian's collection of American art spanning three centuries of creative expression.","c":["visual-arts"]}, +{"t":"Stained Glass Gallery Online","u":"http://www.stainedglassgallery.com/","d":"Step-by-step Stained Glass lessons with material guides and project ideas.","c":["visual-arts"]}, +{"t":"Tapestry Gallery Online","u":"http://www.tapestrygallery.com/","d":"Tapestry artists sharing work, critique and exhibition news.","c":["visual-arts"]}, +{"t":"Tate Online","u":"https://www.tate.org.uk/","d":"British and international modern art from the Tate Gallery with a vast online collection.","c":["visual-arts"]}, +{"t":"TextileArtCenter","u":"http://www.textileartcenter.com/","d":"A fiber art and textile design resource: workshops, exhibits, artist residencies.","c":["visual-arts"]}, +{"t":"The Art Renewal Center","u":"http://www.artrenewal.org/","d":"Museum of realist art with the world's largest encyclopedic collection of realist painting online.","c":["visual-arts"]}, +{"t":"The Collage Studio","u":"http://www.collagestudio.net/","d":"Art studio run by painters who host workshops and events for their hometown community.","c":["visual-arts"]}, +{"t":"The Folk Art Gallery","u":"http://www.folkartgallery.com/","c":["visual-arts"]}, +{"t":"The Metropolitan Museum of Art","u":"https://www.metmuseum.org","d":"One of the world's largest and finest art museums. Virtual tours and collection databases.","c":["visual-arts","entertainment"]}, +{"t":"The Pottery Gallery","u":"http://www.potterygallery.com/","c":["visual-arts"]}, +{"t":"The Stained Glass Studio","u":"http://www.stainedglassstudio.net/","d":"Ipswich stained glass studio for design, restoration and commissions, with beginner workshops and fused glass.","c":["visual-arts"]}, +{"t":"The Tapestry Studio","u":"http://www.tapestrystudio.com/","d":"Portfolio of Anna Kocherovsky, a Metro Detroit artist who makes abstract hand-woven tall-panel tapestries.","c":["visual-arts"]}, +{"t":"The Watercolor Gallery","u":"http://www.watercolorgallery.com/","d":"Watercolor gallery of Route 66, ocean and wildlife paintings, with original artwork and prints for sale.","c":["visual-arts"]}, +{"t":"UrbanSketchers.org","u":"http://www.urbansketchers.org/","d":"An urban sketching community: location draws, technique tips, galleries.","c":["visual-arts"]}, +{"t":"Victoria and Albert Museum","u":"https://www.vam.ac.uk/","d":"The world's largest museum of decorative arts and design, with collections online.","c":["visual-arts"]}, +{"t":"Web Gallery of Art","u":"https://www.wga.hu/","d":"Virtual museum and database of European fine arts from the 3rd to 19th centuries.","c":["visual-arts"]}, +{"t":"Forbes","u":"https://www.forbes.com/","d":"Business, investing and leadership.","c":["business-economy"]}, +{"t":"AccountingFirms.com","u":"http://www.accountingfirms.com/","d":"CPA firm directory with tax and audit service listings.","c":["companies"]}, +{"t":"AccountingToday.com","u":"http://www.accountingtoday.com/","d":"Accounting industry news with regulatory updates and practice management.","c":["companies"]}, +{"t":"AnnualReports.com","u":"http://www.annualreports.com/","d":"Free access to corporate annual reports from thousands of publicly traded companies.","c":["companies"]}, +{"t":"BizJournals.com","u":"http://www.bizjournals.com/","d":"Network of 41 local business journals covering companies and industries across major US metros.","c":["companies"]}, +{"t":"BusinessInsider","u":"http://www.businessinsider.com/","d":"Business news and analysis: tech, finance, markets, strategy.","c":["companies"]}, +{"t":"BusinessWeek Online","u":"https://www.businessweek.com/","d":"Business and financial news, analysis and B-school rankings from the McGraw-Hill publication.","c":["companies"]}, +{"t":"CompanyResearch.net","u":"http://www.companyresearch.net/","d":"A free company research tool, pulling together public filings, news and financial data.","c":["companies"]}, +{"t":"CompanyReviews.com","u":"http://www.companyreviews.com/","d":"Employee company reviews: salary data, culture ratings, interview tips.","c":["companies"]}, +{"t":"CorporateBoard.com","u":"http://www.corporateboard.com/","d":"Board of directors database with governance practices and executive compensation.","c":["companies"]}, +{"t":"CorporateInformation","u":"http://www.corporateinformation.com/","d":"Research reports on over 350,000 companies in 55 countries with financial summaries.","c":["companies"]}, +{"t":"CorporateWatch.org","u":"http://www.corporatewatch.org/","d":"Independent research and reporting on corporate activities and accountability.","c":["companies"]}, +{"t":"Defense Watch Online","u":"http://www.defensewatch.com/","d":"Industry watchdog covering Defense corporations and consumer feedback.","c":["companies"]}, +{"t":"Dun & Bradstreet","u":"https://www.dnb.com/","d":"Business credit reports, company databases and commercial data.","c":["companies"]}, +{"t":"Energy Watch Online","u":"http://www.energywatch.com/","d":"News on the energy industry, covering utilities, oil and gas, renewables and cleantech in Northern Europe.","c":["companies"]}, +{"t":"EngineeringNews.com","u":"http://www.engineeringnews.com/","d":"Engineering industry news with project features and technology updates.","c":["companies"]}, +{"t":"FedEx.com","u":"http://www.fedex.com/","d":"Global shipping and logistics company with package tracking and rate calculators.","c":["companies"]}, +{"t":"Forbes Global 2000","u":"http://www.forbes.com/global2000/","d":"The Forbes ranking of the world's largest public companies, by revenue, profit and market value.","c":["companies"]}, +{"t":"Fortune.com","u":"https://www.fortune.com/","d":"Fortune 500 rankings, business news and executive profiles.","c":["companies"]}, +{"t":"GlobalBrands.com","u":"http://www.globalbrands.com/","d":"Family-owned beverage distribution company in Panama handling liquor, wine, energy drinks and water, founded in 1996.","c":["companies"]}, +{"t":"GreenBusiness.com","u":"http://www.greenbusiness.com/","d":"Environmentally responsible business directory with sustainability certifications.","c":["companies"]}, +{"t":"Hoovers Online","u":"https://www.hoovers.com/","d":"Business information on public and private companies, including financials and executive profiles.","c":["companies"]}, +{"t":"Hospitality Watch Online","u":"http://www.hospitalitywatch.net/","d":"Hospitality companies, searchable by size, region and product line.","c":["companies"]}, +{"t":"HospitalityDirectory","u":"http://www.hospitalitydirectory.org/","d":"Hospitality companies: search by size, region or product line.","c":["companies"]}, +{"t":"Inc.com","u":"https://www.inc.com/","d":"Guides, strategies and resources for growing companies, from Inc. magazine.","c":["companies"]}, +{"t":"IndustryWeek.com","u":"http://www.industryweek.com/","d":"Manufacturing and industry management magazine with best practices and benchmarking.","c":["companies"]}, +{"t":"Insurance Directory Online","u":"http://www.insurancedirectory.org/","d":"Insurance firms listed by headquarters, executives and revenue.","c":["companies"]}, +{"t":"InsuranceAgents.com","u":"http://www.insuranceagents.com/","d":"Insurance agent directory by type and location with consumer reviews.","c":["companies"]}, +{"t":"InsuranceProfiles","u":"http://www.insuranceprofiles.com/","d":"A directory of insurance firms: headquarters, executives, revenue figures.","c":["companies"]}, +{"t":"Kompass Directory","u":"http://www.kompass.com/","d":"Global B2B company directory with 3 million companies across 60 countries.","c":["companies"]}, +{"t":"LawFirmDirectory.com","u":"http://www.lawfirmdirectory.com/","d":"Law firm directory with practice area specialties and attorney profiles.","c":["companies"]}, +{"t":"MergerWatch.com","u":"http://www.mergerwatch.com/","d":"Merger and acquisition tracking with deal terms and regulatory status.","c":["companies"]}, +{"t":"Mining Watch Online","u":"http://www.miningwatch.net/","d":"Canadian advocacy group pushing for laws that hold mining companies accountable for harm from their operations.","c":["companies"]}, +{"t":"MiningDirectory","u":"http://www.miningdirectory.com/","d":"Mining firms: headquarters, executives, revenue figures.","c":["companies"]}, +{"t":"NonprofitDirectory.org","u":"http://www.nonprofitdirectory.org/","d":"Nonprofit organization directory with financial data and program information.","c":["companies"]}, +{"t":"PharmaceuticalDirectory","u":"http://www.pharmaceuticaldirectory.com/","d":"Industry watchdog covering Pharmaceutical corporations and consumer feedback.","c":["companies"]}, +{"t":"Retail Directory Online","u":"http://www.retaildirectory.com/","d":"Retail companies, searchable by size, region and product line.","c":["companies"]}, +{"t":"SEC EDGAR Database","u":"http://www.sec.gov/edgar/","d":"Free access to corporate filings: 10-K annual reports, proxy statements, insider trades.","c":["companies"]}, +{"t":"Software Profiles Online","u":"http://www.softwareprofiles.com/","c":["companies"]}, +{"t":"StaffingAgencies.com","u":"http://www.staffingagencies.com/","d":"Staffing and temp agency directory with specialization and reviews.","c":["companies"]}, +{"t":"StartupTracker.com","u":"http://www.startuptracker.com/","d":"A database of venture-backed startups: funding rounds, investors, growth metrics.","c":["companies"]}, +{"t":"StartupValuations.com","u":"http://www.startupvaluations.com/","d":"Tech startup valuation estimates with funding history and comparable analysis.","c":["companies"]}, +{"t":"The Apparel Directory","u":"http://www.appareldirectory.com/","d":"Industry watchdog covering Apparel corporations and consumer feedback.","c":["companies"]}, +{"t":"The Hospitality Profiles","u":"http://www.hospitalityprofiles.com/","d":"A database of hospitality companies, searchable by size, region and product line.","c":["companies"]}, +{"t":"The Retail Watch","u":"http://www.retailwatch.com/","d":"Retail companies: search by size, region or product line.","c":["companies"]}, +{"t":"The Software Directory","u":"http://www.softwaredirectory.com/","c":["companies"]}, +{"t":"The Utility Watch","u":"http://www.utilitywatch.com/","d":"Utility companies: search by size, region or product line.","c":["companies"]}, +{"t":"Thomas Register","u":"https://www.thomasnet.com/","d":"Industrial supplier directory connecting manufacturers with buyers across all industries.","c":["companies"]}, +{"t":"TruckingCompanies.com","u":"http://www.truckingcompanies.com/","d":"Trucking and freight company directory with capacity and routes.","c":["companies"]}, +{"t":"UPS.com","u":"http://www.ups.com/","d":"United Parcel Service: package delivery, tracking, shipping, logistics.","c":["companies"]}, +{"t":"USPS.com","u":"https://www.usps.com/","d":"The United States Postal Service: tracking, rate calculators, online postage.","c":["companies","us-federal"]}, +{"t":"Vault.com","u":"https://www.vault.com/","d":"Insider company reviews, rankings and career intelligence for job seekers.","c":["companies"]}, +{"t":"WomanOwned.com","u":"http://www.womanowned.com/","d":"Women-owned business directory with certification information and resources.","c":["companies"]}, +{"t":"AffiliateNetwork","u":"http://www.affiliatenetwork.com/","d":"Affiliate marketing network that works with creators around the world.","c":["e-commerce"]}, +{"t":"AffiliateTips.com","u":"http://www.affiliatetips.com/","d":"Affiliate marketing strategies, program reviews, commission optimization guides.","c":["e-commerce"]}, +{"t":"Amazon","u":"https://www.amazon.com/","d":"Started as the internet's largest bookstore. Now the world's biggest e-commerce marketplace.","c":["e-commerce"]}, +{"t":"AuctionBytes.com","u":"http://www.auctionbytes.com/","d":"Online auction news and selling tips for eBay and other marketplace sellers.","c":["e-commerce"]}, +{"t":"BizRate","u":"https://www.bizrate.com/","d":"Store ratings and price comparisons based on real customer feedback and shopping experiences.","c":["e-commerce","consumer-electronics"]}, +{"t":"Buy.com","u":"https://www.buy.com/","d":"Now redirects to Rakuten, a cash back and coupon site covering more than 3,500 stores, with a browser extension.","c":["e-commerce"]}, +{"t":"Catalog Solutions Online","u":"http://www.catalogsolutions.net/","c":["e-commerce"]}, +{"t":"CDW (Computer Discount Warehouse)","u":"https://www.cdw.com","d":"Computer hardware and software for business. Competitive pricing on PC components and peripherals.","c":["e-commerce","consumer-electronics"]}, +{"t":"Checkout Network Online","u":"http://www.checkoutnetwork.com/","c":["e-commerce"]}, +{"t":"CheckoutHub","u":"http://www.checkouthub.com/","c":["e-commerce"]}, +{"t":"Coupon Hub Online","u":"http://www.couponhub.net/","d":"Guide to Coupon best practices with vendor comparisons and tutorials.","c":["e-commerce"]}, +{"t":"DealTime","u":"http://www.dealtime.com/","d":"A shopping search engine comparing prices, reviews and store ratings.","c":["e-commerce"]}, +{"t":"DigitalDownloads.com","u":"http://www.digitaldownloads.com/","d":"A digital product marketplace: software, music, ebooks, templates.","c":["e-commerce"]}, +{"t":"DigitalGoodsHub","u":"http://www.digitalgoodshub.com/","d":"Guide to Digital Goods best practices with vendor comparisons and tutorials.","c":["e-commerce"]}, +{"t":"eBay","u":"https://www.ebay.com/","d":"The world's online marketplace for buying and selling practically anything through auction.","c":["e-commerce"]}, +{"t":"EcommerceBytes.com","u":"http://www.ecommercebytes.com/","d":"News and commentary for online sellers covering marketplaces and selling tools.","c":["e-commerce"]}, +{"t":"eToys","u":"https://www.etoys.com/","d":"Online toy retailer with fast shipping and a wide selection of children's toys and games.","c":["e-commerce"]}, +{"t":"FlashSales.com","u":"http://www.flashsales.com/","d":"Limited-time flash sale event listings from fashion and electronics retailers.","c":["e-commerce"]}, +{"t":"Froogle","u":"http://www.froogle.google.com/","d":"Google's product search engine indexing online stores and comparing prices for free.","c":["e-commerce"]}, +{"t":"FulfillmentHub","u":"http://www.fulfillmenthub.org/","c":["e-commerce"]}, +{"t":"GiftCardMall.com","u":"http://www.giftcardmall.com/","d":"Gift card marketplace with discounted cards from popular retailers.","c":["e-commerce"]}, +{"t":"GroupBuying.com","u":"http://www.groupbuying.com/","d":"Early group buying platform offering discounts through collective purchasing.","c":["e-commerce"]}, +{"t":"LiquidationStore.com","u":"http://www.liquidationstore.com/","d":"Liquidation and overstock merchandise at deep discount prices.","c":["e-commerce"]}, +{"t":"Marketplace Hub Online","u":"http://www.marketplacehub.com/","d":"Service helping web stores manage Marketplace and grow their sales.","c":["e-commerce"]}, +{"t":"MerchantCircle.com","u":"http://www.merchantcircle.com/","d":"Local business marketing platform connecting small businesses with customers.","c":["e-commerce"]}, +{"t":"mySimon","u":"https://www.mysimon.com/","d":"Price comparison search engine helping shoppers find the best deals across online stores.","c":["e-commerce","consumer-electronics"]}, +{"t":"Newegg","u":"https://www.newegg.com/","d":"Online computer components and electronics store with low prices and fast shipping.","c":["e-commerce","consumer-electronics"]}, +{"t":"NextTag","u":"http://www.nextag.com/","d":"Price comparison service helping consumers find the best deals on millions of products.","c":["e-commerce"]}, +{"t":"OnlineAuctionSolutions","u":"http://www.onlineauctionsolutions.com/","d":"Platform offering Online Auction tools for small online retailers.","c":["e-commerce"]}, +{"t":"OnlineCoupons.com","u":"http://www.onlinecoupons.com/","d":"Coupon aggregation site collecting discount codes from online retailers.","c":["e-commerce"]}, +{"t":"Overstock.com","u":"https://www.overstock.com/","d":"Discount shopping with surplus and closeout merchandise at deeply reduced prices.","c":["e-commerce","consumer-electronics"]}, +{"t":"PaymentGateway.net","u":"http://www.paymentgateway.net/","d":"Comparison of online payment processing services for e-commerce merchants.","c":["e-commerce"]}, +{"t":"PayPal","u":"https://www.paypal.com","d":"Send money online securely. The preferred payment method for eBay and online shopping.","c":["e-commerce"]}, +{"t":"PriceGrabber","u":"http://www.pricegrabber.com/","d":"Comparison shopping engine that finds the lowest prices from thousands of online stores.","c":["e-commerce"]}, +{"t":"Priceline","u":"https://www.priceline.com/","d":"Name Your Own Price for flights, hotels and rental cars. Travel booking by bidding.","c":["e-commerce"]}, +{"t":"RetailGeek.com","u":"http://www.retailgeek.com/","d":"E-commerce industry news covering online retail technology and marketing trends.","c":["e-commerce"]}, +{"t":"ReturnPolicies.com","u":"http://www.returnpolicies.com/","d":"Retailer return policy database with consumer guides and comparison tools.","c":["e-commerce"]}, +{"t":"Returns Solutions Online","u":"http://www.returnssolutions.com/","d":"E-commerce resource covering Returns setup for online merchants.","c":["e-commerce"]}, +{"t":"SampleSales.com","u":"http://www.samplesales.com/","d":"Sample sale and warehouse sale event listings in major US cities.","c":["e-commerce"]}, +{"t":"ShopBot.com","u":"http://www.shopbot.com/","d":"Comparison shopping robot that scans online retailers for the lowest prices.","c":["e-commerce"]}, +{"t":"Shopping Cart Network Online","u":"http://www.shoppingcartnetwork.com/","c":["e-commerce"]}, +{"t":"Shopping.com","u":"http://www.shopping.com/","d":"Product comparison shopping: merchant reviews, price alerts, a best-deal finder.","c":["e-commerce"]}, +{"t":"Shopzilla","u":"http://www.shopzilla.com/","d":"Product search engine delivering millions of offers from thousands of online stores.","c":["e-commerce"]}, +{"t":"SlickDeals","u":"http://www.slickdeals.net/","d":"A user-driven deal sharing community: forums, coupons, price tracking tools.","c":["e-commerce"]}, +{"t":"TechBargains","u":"http://www.techbargains.com/","d":"Hand-picked technology deals, coupons and price drops, chosen by bargain-hunting editors.","c":["e-commerce"]}, +{"t":"The Catalog Hub","u":"http://www.cataloghub.com/","c":["e-commerce"]}, +{"t":"The Fulfillment Solutions","u":"http://www.fulfillmentsolutions.com/","c":["e-commerce"]}, +{"t":"The Inventory Solutions","u":"http://www.inventorysolutions.com/","d":"Platform offering Inventory tools for small online retailers.","c":["e-commerce"]}, +{"t":"UsedCars.com","u":"https://www.usedcars.com/","d":"Used car listings with vehicle history reports and dealer inventory nationwide.","c":["e-commerce"]}, +{"t":"WarrantyGuide.com","u":"http://www.warrantyguide.com/","d":"Extended warranty review site with cost-benefit analysis and claim data.","c":["e-commerce"]}, +{"t":"WebStoreSolutions","u":"http://www.webstoresolutions.com/","d":"Digital agency offering marketing, SEO, social media, web development and app services for businesses.","c":["e-commerce"]}, +{"t":"WholesaleDirectory.com","u":"http://www.wholesaledirectory.com/","d":"Wholesale supplier directory for retailers sourcing bulk merchandise.","c":["e-commerce"]}, +{"t":"Woot!","u":"http://www.woot.com/","d":"One deal a day on electronics, computers and other products, until sold out.","c":["e-commerce"]}, +{"t":"Zappos","u":"https://www.zappos.com/","d":"Online shoe retailer. Free shipping, free returns, customer service people talk about.","c":["e-commerce","shopping"]}, +{"t":"401k Advisor Online","u":"http://www.401kadvisor.com/","d":"Baltimore firm providing investment consulting to organizations on participant-directed retirement and defined contribution plans.","c":["finance-investing"]}, +{"t":"Ameritrade","u":"https://www.ameritrade.com/","d":"Online discount brokerage for self-directed investors: stocks, options, mutual funds.","c":["finance-investing"]}, +{"t":"Bankrate","u":"https://www.bankrate.com/","d":"Compare mortgage rates, CD rates, savings accounts and credit cards from lenders nationwide.","c":["finance-investing"]}, +{"t":"BigCharts","u":"http://www.bigcharts.com/","d":"Free stock charting site from MarketWatch with interactive charts and market indicators.","c":["finance-investing"]}, +{"t":"Bloomberg","u":"https://www.bloomberg.com/","d":"Business and financial news in real time, with market data and investment analysis.","c":["finance-investing"]}, +{"t":"Briefing.com","u":"http://www.briefing.com/","d":"Real-time stock market analysis, earnings estimates and economic calendars, for active traders.","c":["finance-investing"]}, +{"t":"CNNfn","u":"http://www.cnnfn.com/","d":"CNN's financial news network: real-time quotes, market analysis, portfolio tools.","c":["finance-investing"]}, +{"t":"CNNMoney","u":"https://money.cnn.com/","d":"The joint financial news site from CNN and Money magazine: markets, technology, personal finance.","c":["finance-investing"]}, +{"t":"CollegeSavings529.com","u":"http://www.collegesavings529.com/","d":"529 college savings plan comparison with state tax benefit calculators.","c":["finance-investing"]}, +{"t":"Credit Score Watch Online","u":"http://www.creditscorewatch.net/","d":"FICO's consumer site selling FICO Scores, credit reports, credit monitoring and identity theft protection plans.","c":["finance-investing"]}, +{"t":"CreditCardRatings.com","u":"http://www.creditcardratings.com/","d":"Credit card comparison: reviews, rewards analysis, application tools.","c":["finance-investing"]}, +{"t":"CreditScore.com","u":"https://www.creditscore.com/","d":"Free credit score monitoring with improvement tips and dispute guidance.","c":["finance-investing"]}, +{"t":"CreditScoreAdvisor","u":"https://creditscoreadvisor.com/","d":"Credit repair service offering dispute help and personal credit coaching to individuals and families.","c":["finance-investing"]}, +{"t":"DayTrader.com","u":"https://www.daytrader.com/","d":"Day trading strategies, stock picks and market analysis for active traders.","c":["finance-investing"]}, +{"t":"DebtFree.org","u":"http://www.debtfree.org/","d":"Debt help from Consolidated Credit, a nonprofit credit counseling agency.","c":["finance-investing"]}, +{"t":"DividendInvestor.com","u":"http://www.dividendinvestor.com/","d":"Dividend stock research: yield calculations, payout histories, screening tools.","c":["finance-investing"]}, +{"t":"E*TRADE","u":"https://www.etrade.com","d":"Online brokerage for stocks, options and mutual funds, with real-time quotes and trading.","c":["finance-investing"]}, +{"t":"ETFGuide.com","u":"http://www.etfguide.com/","d":"Exchange-traded fund research with fee comparisons and portfolio strategies.","c":["finance-investing"]}, +{"t":"Fidelity Investments","u":"http://www.fidelity.com/","d":"Mutual funds, retirement plans, brokerage services, financial education.","c":["finance-investing"]}, +{"t":"Investopedia","u":"http://www.investopedia.com/","d":"Investment education: a financial dictionary, tutorials, stock simulators.","c":["finance-investing"]}, +{"t":"Kiplinger.com","u":"https://www.kiplinger.com/","d":"Personal finance advice, investing tips, tax strategies, retirement planning.","c":["finance-investing"]}, +{"t":"MarketWatch","u":"https://www.marketwatch.com/","d":"Financial market news, stock quotes and investment analysis from CBS, in real time.","c":["finance-investing"]}, +{"t":"Morningstar","u":"https://www.morningstar.com/","c":["finance-investing"]}, +{"t":"MortgageCalculator.org","u":"http://www.mortgagecalculator.org/","d":"Free mortgage payment calculators with amortization schedules and refinance analysis.","c":["finance-investing"]}, +{"t":"MSN Money","u":"https://money.msn.com/","d":"Microsoft's financial portal: stock quotes, portfolio tracking, personal finance tools.","c":["finance-investing"]}, +{"t":"Portfolio Advisor Online","u":"https://portfolioadvisor.com/","d":"Connecticut asset management firm for high-net-worth individuals and families.","c":["finance-investing"]}, +{"t":"Quicken.com","u":"https://www.quicken.com/","d":"Money management tools for budgeting, savings and investments, plus bookkeeping for self-employed, small business and rental property owners.","c":["finance-investing"]}, +{"t":"RetirementWatch","u":"https://www.retirementwatch.com/","d":"Bob Carlson's newsletter and advice on retirement, estate planning, Social Security, annuities, IRAs and investing.","c":["finance-investing"]}, +{"t":"Schwab.com","u":"https://www.schwab.com/","d":"Charles Schwab online: stock trading, mutual funds, retirement planning.","c":["finance-investing"]}, +{"t":"Silicon Investor","u":"http://www.siliconinvestor.com/","d":"Online discussion forum for technology stock investors with real-time market commentary.","c":["finance-investing"]}, +{"t":"SmartMoney","u":"https://www.smartmoney.com/","d":"Dow Jones financial wellness resource with guidance on budgeting, saving, debt, credit scores and investment decisions.","c":["finance-investing"]}, +{"t":"SocialSecurityCalc.com","u":"http://www.socialsecuritycalc.com/","d":"Social Security benefit calculator from a financial adviser.","c":["finance-investing"]}, +{"t":"StockCharts.com","u":"http://www.stockcharts.com/","d":"Free stock charts: technical analysis tools, scanning, educational content.","c":["finance-investing"]}, +{"t":"The Motley Fool","u":"https://www.fool.com/","d":"Personal finance and investing advice for individual investors, written in plain English.","c":["finance-investing"]}, +{"t":"TheStreet.com","u":"https://www.thestreet.com/","d":"Financial news, stock analysis and investment commentary from Jim Cramer and staff.","c":["finance-investing"]}, +{"t":"Vanguard Group","u":"http://www.vanguard.com/","d":"Low-cost index funds and retirement savings plans from the investor-owned mutual fund company.","c":["finance-investing"]}, +{"t":"WealthManagement.com","u":"http://www.wealthmanagement.com/","d":"For financial advisors: practice management, investment strategies, industry news.","c":["finance-investing"]}, +{"t":"WillMaker.com","u":"http://www.willmaker.com/","d":"Online will and estate planning tool with legal document templates.","c":["finance-investing"]}, +{"t":"Yahoo! Finance","u":"https://finance.yahoo.com","d":"Stock quotes, news and financial data. Portfolio tracking and market analysis.","c":["finance-investing"]}, +{"t":"Alibaba.com","u":"https://www.alibaba.com/","d":"Global wholesale marketplace connecting manufacturers and importers for international trade.","c":["international-trade"]}, +{"t":"Bureau of Industry and Security","u":"http://www.bis.doc.gov/","d":"US Department of Commerce bureau overseeing export control and industry security policies.","c":["international-trade"]}, +{"t":"EC21.com","u":"http://www.ec21.com/","d":"Korean-based global B2B marketplace connecting manufacturers with international buyers.","c":["international-trade"]}, +{"t":"ExportGateway","u":"http://www.exportgateway.com/","d":"A reference on export procedures: forms, glossaries, case studies.","c":["international-trade"]}, +{"t":"FITA - Federation of Intl Trade","u":"http://www.fita.org/","d":"Association of 450 international trade organizations promoting cross-border commerce.","c":["international-trade"]}, +{"t":"FreightFinder.com","u":"http://www.freightfinder.com/","d":"An international freight rate comparison tool for ocean, air and ground shipping.","c":["international-trade"]}, +{"t":"GlobalSources","u":"https://www.globalsources.com/","d":"A B2B marketplace connecting Asian suppliers with global buyers: electronics, fashion, hardware.","c":["international-trade"]}, +{"t":"Import Gateway Online","u":"http://www.importgateway.com/","d":"Trade portal connecting buyers and sellers around Import services.","c":["international-trade"]}, +{"t":"ImportExportHelp.com","u":"http://www.importexporthelp.com/","d":"Guides for new importers and exporters: customs, tariffs, trade documentation.","c":["international-trade"]}, +{"t":"International Chamber of Commerce","u":"http://www.iccwbo.org/","d":"The world business organization, promoting international trade, investment and market economy.","c":["international-trade"]}, +{"t":"TradeBoss.com","u":"http://www.tradeboss.com/","d":"Free trade leads board for international buyers and sellers of wholesale merchandise.","c":["international-trade"]}, +{"t":"TradeCompass","u":"https://www.tradecompass.com/","d":"International trade data, tariff information and customs compliance tools, for importers and exporters.","c":["international-trade"]}, +{"t":"TradeIndia","u":"http://www.tradeindia.com/","d":"India's largest B2B marketplace, for manufacturers, suppliers and exporters.","c":["international-trade"]}, +{"t":"TradeKey","u":"https://www.tradekey.com/","d":"B2B marketplace connecting importers and exporters in international trade.","c":["international-trade"]}, +{"t":"TradeOut.com","u":"https://www.tradeout.com/","d":"B2B liquidation marketplace for surplus inventory and excess business merchandise.","c":["international-trade"]}, +{"t":"TradePort","u":"http://www.tradeport.org/","d":"International trade assistance center for California businesses with market research and guides.","c":["international-trade"]}, +{"t":"US Customs & Border Protection","u":"http://www.cbp.gov/","d":"The official customs agency: import/export regulations, tariff schedules, trade data.","c":["international-trade"]}, +{"t":"World Trade Organization","u":"https://www.wto.org/","d":"International organization overseeing global trade rules and dispute resolution.","c":["international-trade","international-organizations"]}, +{"t":"AccountingJobs.com","u":"http://www.accountingjobs.com/","d":"Accounting and finance job board with CPA positions and salary data.","c":["jobs-careers"]}, +{"t":"BankingJobs.com","u":"http://www.bankingjobs.com/","d":"Banking and financial services job board with analyst and officer positions.","c":["jobs-careers"]}, +{"t":"CareerBuilder","u":"https://www.careerbuilder.com/","d":"A job board with resume posting, salary comparison tools and career advice.","c":["jobs-careers"]}, +{"t":"CareerJournal.com","u":"http://www.careerjournal.com/","d":"The Wall Street Journal's career site: job listings, salary data, career management advice.","c":["jobs-careers"]}, +{"t":"CollegeRecruiter.com","u":"http://www.collegerecruiter.com/","d":"Entry-level jobs and internships for college students and recent graduates.","c":["jobs-careers"]}, +{"t":"ConstructionJobs.com","u":"http://www.constructionjobs.com/","d":"A construction industry job board for project managers, engineers and tradespeople.","c":["jobs-careers"]}, +{"t":"CoolWorks.com","u":"http://www.coolworks.com/","d":"Jobs in great places: national parks, ski resorts, ranches, camps, cruise ships.","c":["jobs-careers"]}, +{"t":"Dice.com","u":"https://www.dice.com/","d":"Job board focused exclusively on technology and engineering positions.","c":["jobs-careers"]}, +{"t":"FashionJobs.com","u":"https://www.fashionjobs.com/","d":"A fashion industry job board: design, retail, marketing.","c":["jobs-careers"]}, +{"t":"Finance Jobs Online","u":"https://www.financejobs.net/","d":"Job listings in finance.","c":["jobs-careers"]}, +{"t":"FlipDog","u":"https://www.flipdog.com/","d":"Job search engine crawling company websites to find listings not posted on traditional job boards.","c":["jobs-careers"]}, +{"t":"FreelanceWork.com","u":"http://www.freelancework.com/","d":"A freelance job marketplace for designers, developers, writers and consultants.","c":["jobs-careers"]}, +{"t":"Guru.com","u":"https://www.guru.com/","d":"Freelance marketplace connecting businesses with independent professionals for project-based work.","c":["jobs-careers"]}, +{"t":"Headhunter.net","u":"http://www.headhunter.net/","d":"Executive and professional job board with career management tools and recruiter connections.","c":["jobs-careers"]}, +{"t":"HealthcareJobSite.com","u":"http://www.healthcarejobsite.com/","d":"A healthcare job board for nurses, physicians, technicians and administrators.","c":["jobs-careers"]}, +{"t":"Indeed.com","u":"http://www.indeed.com/","d":"A job search engine pulling listings from job boards, newspapers and company career pages.","c":["jobs-careers"]}, +{"t":"InsuranceJobs.com","u":"http://www.insurancejobs.com/","d":"Insurance industry job board with underwriting and claims positions.","c":["jobs-careers"]}, +{"t":"InternshipFinder.com","u":"https://www.internshipfinder.com/","d":"Internship search engine matching students with paid and unpaid opportunities.","c":["jobs-careers"]}, +{"t":"InterviewCoach.com","u":"https://interviewcoach.com/","d":"Interview preparation: practice questions, tips, mock interviews.","c":["jobs-careers"]}, +{"t":"JobsInLogistics.com","u":"https://www.jobsinlogistics.com/","d":"Job board for logistics, transportation and distribution work.","c":["jobs-careers"]}, +{"t":"MediaBistro.com","u":"http://www.mediabistro.com/","d":"Media industry job board and career resource for journalism and communications.","c":["jobs-careers"]}, +{"t":"Monster.com","u":"https://www.monster.com/","d":"The world's largest job search engine with millions of listings from top employers.","c":["jobs-careers"]}, +{"t":"NationJob Network","u":"http://www.nationjob.com/","d":"Personal job scout service that emails matching job postings directly to your inbox.","c":["jobs-careers"]}, +{"t":"Net-Temps","u":"https://www.net-temps.com/","d":"Staffing industry job board connecting temporary and contract workers with agencies.","c":["jobs-careers"]}, +{"t":"NonprofitJobs.com","u":"http://www.nonprofitjobs.com/","d":"Nonprofit sector job board with positions in advocacy and social services.","c":["jobs-careers"]}, +{"t":"PharmacyJobs.com","u":"http://www.pharmacyjobs.com/","d":"Pharmacy job board with positions for pharmacists and technicians.","c":["jobs-careers"]}, +{"t":"ResumeBuilder.net","u":"http://www.resumebuilder.net/","d":"Free resume templates and builder tools with industry-specific examples.","c":["jobs-careers"]}, +{"t":"ResumeMaker","u":"https://www.resumemaker.com/","d":"Resume writing tools, templates and career advice for job seekers.","c":["jobs-careers"]}, +{"t":"RetailJobs","u":"https://www.retailjobs.com/","c":["jobs-careers"]}, +{"t":"SalaryExpert.com","u":"http://www.salaryexpert.com/","d":"A salary comparison tool, with compensation data by job title, location and experience.","c":["jobs-careers"]}, +{"t":"SalesJobs.com","u":"http://www.salesjobs.com/","d":"Sales career job board with commission structures and industry segments.","c":["jobs-careers"]}, +{"t":"SimplyHired","u":"http://www.simplyhired.com/","d":"Meta-search engine aggregating job listings from thousands of job boards and company websites.","c":["jobs-careers"]}, +{"t":"SnagAJob","u":"http://www.snagajob.com/","d":"Hourly employment job board specializing in part-time and shift work opportunities.","c":["jobs-careers"]}, +{"t":"TeachingJobs.com","u":"http://www.teachingjobs.com/","d":"A teaching job board: K-12, college, online education.","c":["jobs-careers"]}, +{"t":"TechCareers.com","u":"http://www.techcareers.com/","d":"Technology job board specializing in software development and IT positions.","c":["jobs-careers"]}, +{"t":"The Engineering Jobs","u":"https://www.engineeringjobs.com/","d":"A national index of engineering jobs and resources.","c":["jobs-careers"]}, +{"t":"The Government Jobs","u":"https://governmentjobs.org/","d":"Government job listings.","c":["jobs-careers"]}, +{"t":"The Healthcare Careers","u":"https://healthcarecareers.com/","d":"Healthcare retained search firm placing executives and physicians with hospitals, biotech, pharmaceutical and care facilities.","c":["jobs-careers"]}, +{"t":"USAJobs.gov","u":"http://www.usajobs.gov/","d":"Official federal government job board with searchable listings from all federal agencies.","c":["jobs-careers"]}, +{"t":"VetJobs.com","u":"http://www.vetjobs.com/","d":"Job placement and career support for military members, veterans, National Guard and Reserves, at no cost.","c":["jobs-careers"]}, +{"t":"AdAge","u":"https://www.adage.com/","d":"Advertising industry news, agency rankings and marketing analysis from Advertising Age.","c":["marketing-advertising"]}, +{"t":"Adweek.com","u":"http://www.adweek.com/","d":"Weekly advertising trade publication: agencies, brands, creative campaigns.","c":["marketing-advertising"]}, +{"t":"BrandingNetwork","u":"http://www.brandingnetwork.com/","d":"German-language creative studio for brand positioning, storytelling and design, founded in 2005 as Branders.","c":["marketing-advertising"]}, +{"t":"BrandStrategy.com","u":"https://brandstrategy.com/","d":"Brand strategy consulting resource with positioning frameworks and case studies.","c":["marketing-advertising"]}, +{"t":"Brandweek","u":"http://www.brandweek.com/","d":"Brand marketing news and analysis covering consumer packaged goods and retail marketing.","c":["marketing-advertising"]}, +{"t":"ClickZ","u":"https://www.clickz.com/","d":"Internet marketing news, tips and strategies for online advertising professionals.","c":["marketing-advertising"]}, +{"t":"Constant Contact","u":"http://www.constantcontact.com/","d":"Email marketing for small businesses: templates, list management, tracking.","c":["marketing-advertising"]}, +{"t":"EventMarketing.com","u":"http://www.eventmarketing.com/","d":"Event marketing and experiential advertising strategy resource.","c":["marketing-advertising"]}, +{"t":"iMediaConnection","u":"http://www.imediaconnection.com/","d":"An online marketing and advertising community: articles, events, job listings.","c":["marketing-advertising"]}, +{"t":"Influencer50","u":"https://influencer50.com/","d":"Influencer50: finding the people who influence a market, and marketing through them.","c":["marketing-advertising"]}, +{"t":"MarketingProfs.com","u":"http://www.marketingprofs.com/","d":"Marketing education and training: how-to articles, seminars, case studies.","c":["marketing-advertising"]}, +{"t":"MarketingSherpa","u":"https://www.marketingsherpa.com/","d":"Case studies, benchmarks and practical research for internet marketing professionals.","c":["marketing-advertising"]}, +{"t":"MarketResearch.com","u":"http://www.marketresearch.com/","d":"Market research reports and industry analysis from research firms.","c":["marketing-advertising"]}, +{"t":"MediaPost","u":"http://www.mediapost.com/","d":"Online media, marketing and advertising news, with analysis for industry professionals.","c":["marketing-advertising"]}, +{"t":"PRWeb","u":"http://www.prweb.com/","d":"Online press release distribution service for businesses seeking media coverage and SEO.","c":["marketing-advertising"]}, +{"t":"Search Engine Watch","u":"http://www.searchenginewatch.com/","d":"Tips on searching the web, search engine submission and web ranking.","c":["marketing-advertising"]}, +{"t":"SocialMediaToday.com","u":"http://www.socialmediatoday.com/","d":"Social media marketing news with strategy articles and platform updates.","c":["marketing-advertising"]}, +{"t":"The Direct Mail Pro","u":"https://directmailpro.net/","c":["marketing-advertising"]}, +{"t":"The Newsletter Pro","u":"https://newsletterpro.com/","d":"A service that writes and sends newsletters for businesses.","c":["marketing-advertising"]}, +{"t":"The Tradeshow Network","u":"https://www.thetradeshownetwork.com/","d":"Chicago company that rents and builds trade show exhibits, island and inline booths.","c":["marketing-advertising"]}, +{"t":"ValueClick","u":"https://www.valueclick.com/","d":"Performance-based online advertising network connecting advertisers with web publishers.","c":["marketing-advertising"]}, +{"t":"WebProNews","u":"http://www.webpronews.com/","d":"Internet business news: search engine marketing, e-commerce, technology trends.","c":["marketing-advertising"]}, +{"t":"Apartment Guide","u":"http://www.apartmentguide.com/","d":"Apartment and rental home search: photos, floor plans, neighborhood information.","c":["real-estate"]}, +{"t":"BankRate Mortgages","u":"http://www.bankrate.com/mortgages/","d":"Current mortgage rates, calculators and guides for home buyers and refinancers.","c":["real-estate"]}, +{"t":"CommercialCafe","u":"http://www.commercialcafe.com/","d":"Commercial real estate listings: office, retail and industrial properties.","c":["real-estate"]}, +{"t":"CondoFinder","u":"http://www.condofinder.com/","d":"Resource for Condo real estate with market trends and how-to articles.","c":["real-estate"]}, +{"t":"CyberHomes","u":"https://www.cyberhomes.com/","d":"Home value estimates, neighborhood comparisons, property search tools.","c":["real-estate"]}, +{"t":"FarmRealEstate.com","u":"http://www.farmrealestate.com/","d":"Agricultural property marketplace with ranch and farmland listings.","c":["real-estate"]}, +{"t":"FHAloans.com","u":"http://www.fhaloans.com/","d":"FHA loan information: requirements, calculators, lender comparisons.","c":["real-estate"]}, +{"t":"ForSaleByOwner.com","u":"http://www.forsalebyowner.com/","d":"List your home for sale without an agent with marketing tools and flat-fee MLS access.","c":["real-estate"]}, +{"t":"HistoricHomes.com","u":"http://www.historichomes.com/","d":"Real estate team at Seven Gables Real Estate in Tustin, California, helping clients buy, sell and invest.","c":["real-estate"]}, +{"t":"Homebuilder.com","u":"https://www.homebuilder.com/","d":"New home construction search with builder profiles and community information.","c":["real-estate"]}, +{"t":"HomeGain.com","u":"http://www.homegain.com/","d":"Tools to find real estate agents and home values, plus home improvement advice.","c":["real-estate"]}, +{"t":"HomeInspection.com","u":"http://www.homeinspection.com/","d":"Home inspection guide with inspector directories and checklist resources.","c":["real-estate"]}, +{"t":"Homes.com","u":"https://www.homes.com/","d":"Home listings, neighborhood data and an agent directory, for buyers and sellers.","c":["real-estate"]}, +{"t":"HomeStore","u":"https://www.homestore.com/","d":"The parent company of Realtor.com. Home buying, renting and moving resources.","c":["real-estate"]}, +{"t":"Inman News","u":"http://www.inman.com/","d":"Real estate industry news, technology coverage and market analysis.","c":["real-estate"]}, +{"t":"LakefrontListings","u":"http://www.lakefrontlistings.com/","d":"Real estate portal with Lakefront listings searchable by city and price.","c":["real-estate"]}, +{"t":"Land Finder Online","u":"http://www.landfinder.org/","c":["real-estate"]}, +{"t":"LandForSale.com","u":"http://www.landforsale.com/","d":"Undeveloped land and lot listings with acreage and zoning information.","c":["real-estate"]}, +{"t":"LandListings","u":"http://www.landlistings.com/","c":["real-estate"]}, +{"t":"LandlordAssoc.org","u":"http://www.landlordassoc.org/","d":"A landlord resource: lease templates, tenant screening, property management.","c":["real-estate"]}, +{"t":"LendingTree","u":"https://www.lendingtree.com/","d":"Online lending marketplace where lenders compete for your mortgage, auto loan, or credit card.","c":["real-estate"]}, +{"t":"LoopNet.com","u":"http://www.loopnet.com/","d":"A commercial real estate marketplace: property listings, auction deals, market reports.","c":["real-estate"]}, +{"t":"National Association of Realtors","u":"http://www.nar.realtor/","d":"Professional association for real estate agents with market statistics and advocacy information.","c":["real-estate"]}, +{"t":"PropertyInvestor.com","u":"https://www.propertyinvestor.com/","d":"Sold data, property research and analytics for real estate investors.","c":["real-estate"]}, +{"t":"PropertyShark","u":"http://www.propertyshark.com/","d":"A real estate data provider: ownership records, building details, comparable sales.","c":["real-estate"]}, +{"t":"Realtor.com","u":"https://www.realtor.com","d":"Search over 2 million home listings. Agents, mortgage rates, market data.","c":["real-estate"]}, +{"t":"Redfin","u":"http://www.redfin.com/","d":"Technology-powered real estate brokerage with discounted commissions and online home tours.","c":["real-estate"]}, +{"t":"Rent.com","u":"http://www.rent.com/","d":"Apartment search for renters: virtual tours, floor plans, neighborhood reviews.","c":["real-estate"]}, +{"t":"Rental Finder Online","u":"http://www.rentalfinder.com/","c":["real-estate"]}, +{"t":"RentalListings","u":"http://www.rentallistings.com/","c":["real-estate"]}, +{"t":"SimonProperty.com","u":"https://www.simon.com/","d":"Shopping mall directory and real estate investment trust with property listings nationwide.","c":["real-estate"]}, +{"t":"StudentHousing.com","u":"http://www.studenthousing.com/","d":"Off-campus student housing directory near colleges and universities.","c":["real-estate"]}, +{"t":"The Commercial Finder","u":"http://www.commercialfinder.com/","c":["real-estate"]}, +{"t":"The Investment Property Listings","u":"http://www.investmentpropertylistings.com/","d":"Real estate portal with Investment Property listings searchable by city and price.","c":["real-estate"]}, +{"t":"The Townhome Listings","u":"http://www.townhomelistings.com/","d":"Property search service specializing in Townhome listings nationwide.","c":["real-estate"]}, +{"t":"TimeshareResale.com","u":"http://www.timeshareresale.com/","d":"Timeshare resale marketplace with valuation tools and buyer guides.","c":["real-estate"]}, +{"t":"Trulia.com","u":"http://www.trulia.com/","d":"Real estate search, with neighborhood insight, crime maps and school information.","c":["real-estate"]}, +{"t":"Zillow.com","u":"http://www.zillow.com/","d":"A real estate marketplace: home value estimates, mortgage tools, property listings.","c":["real-estate"]}, +{"t":"AllBusiness","u":"https://www.allbusiness.com/","d":"Small business resources: legal forms, business plans, advice.","c":["small-business"]}, +{"t":"BPlans","u":"https://www.bplans.com/","d":"Free sample business plans, planning software and startup resources.","c":["small-business"]}, +{"t":"BusinessPlanHub","u":"http://www.businessplanhub.com/","d":"Paid business plan builder and writing service producing plans for lenders, investors and visa applications.","c":["small-business"]}, +{"t":"BusinessPlanPro.com","u":"http://www.businessplanpro.com/","d":"Business plan writing software with templates and financial projections.","c":["small-business"]}, +{"t":"BusinessTown.com","u":"http://www.businesstown.com/","d":"Free guides on starting, managing and growing a small business.","c":["small-business"]}, +{"t":"CateringCompanies.com","u":"http://www.cateringcompanies.com/","d":"Catering company directory with menu samples and event planning.","c":["small-business"]}, +{"t":"CommercialPrinter.com","u":"http://www.commercialprinter.com/","d":"Print shop directory with commercial printing services and quotes.","c":["small-business"]}, +{"t":"Consulting Resource Online","u":"http://www.consultingresource.com/","d":"Community of small business owners sharing Consulting advice.","c":["small-business"]}, +{"t":"Entrepreneur.com","u":"https://www.entrepreneur.com","d":"Resources for small business owners and entrepreneurs. Articles on starting and growing a business.","c":["small-business"]}, +{"t":"eTaxFiling.com","u":"http://www.etaxfiling.com/","d":"Small business tax filing guides with deduction checklists and quarterly estimates.","c":["small-business"]}, +{"t":"Franchising Resource Online","u":"http://www.franchisingresource.com/","c":["small-business"]}, +{"t":"FreelanceDesigner.com","u":"http://www.freelancedesigner.com/","d":"Freelance graphic designer marketplace with portfolios and rates.","c":["small-business"]}, +{"t":"HiringHub","u":"http://www.hiringhub.net/","d":"Educational portal explaining Hiring for the first-time business owner.","c":["small-business"]}, +{"t":"Home Business Hub Online","u":"http://www.homebusinesshub.com/","c":["small-business"]}, +{"t":"HomeBasedBiz.com","u":"http://www.homebasedbiz.com/","d":"Home-based business ideas: startup guides, success stories, tax tips.","c":["small-business"]}, +{"t":"HomeBusinessResource","u":"http://www.homebusinessresource.com/","c":["small-business"]}, +{"t":"HomeOfficeReports","u":"http://www.homeofficereports.com/","d":"Reviews and guides for home office equipment and remote work productivity.","c":["small-business"]}, +{"t":"Insurance Resource Online","u":"http://www.insuranceresource.com/","d":"Small business resource covering Insurance for owners and entrepreneurs.","c":["small-business"]}, +{"t":"InventionHelp.com","u":"http://www.inventionhelp.com/","d":"An inventor's resource: patent filing guides, prototype services, licensing.","c":["small-business"]}, +{"t":"InventoryManagement.com","u":"http://www.inventorymanagement.com/","d":"Inventory control software reviews and best practices for small businesses.","c":["small-business"]}, +{"t":"LLCFormation.com","u":"http://www.llcformation.com/","d":"LLC formation guide with state-by-state requirements and filing services.","c":["small-business"]}, +{"t":"MicroBizHub.com","u":"http://www.microbizhub.com/","d":"Micro-business resource with LLC formation guides and tax tips for solopreneurs.","c":["small-business"]}, +{"t":"MicroEnterprise Works","u":"http://www.microenterpriseworks.org/","d":"Resource center for very small businesses with training programs and microloans.","c":["small-business"]}, +{"t":"NetworkingResource","u":"http://www.networkingresource.com/","d":"Educational portal explaining Networking for the first-time business owner.","c":["small-business"]}, +{"t":"NFIB.com","u":"http://www.nfib.com/","d":"National Federation of Independent Business: small business advocacy and member resources.","c":["small-business"]}, +{"t":"Nolo Small Biz","u":"https://www.nolo.com/legal-encyclopedia/small-business","d":"Legal information for small business owners: LLCs, contracts, taxes.","c":["small-business"]}, +{"t":"Nolo.com","u":"https://www.nolo.com/","d":"Plain-English legal information for consumers: self-help law books, free legal articles.","c":["small-business","government"]}, +{"t":"Paychex.com","u":"http://www.paychex.com/","d":"Payroll processing, HR and benefits administration for small businesses.","c":["small-business"]}, +{"t":"PayrollResource","u":"http://www.payrollresource.com/","d":"A payroll guide: templates, checklists, tips.","c":["small-business"]}, +{"t":"Quickbooks.com","u":"http://www.quickbooks.com/","d":"Small business accounting software from Intuit with invoicing and payroll.","c":["small-business"]}, +{"t":"RestaurantStartup.com","u":"http://www.restaurantstartup.com/","d":"A restaurant startup guide: business plans, equipment, menu design.","c":["small-business"]}, +{"t":"SBA Online","u":"https://www.sba.gov/","d":"The U.S. Small Business Administration: loans, counseling, contracting assistance.","c":["small-business"]}, +{"t":"SCORE","u":"https://www.score.org/","d":"Free and confidential business advice and mentoring for small business owners from SCORE volunteers.","c":["small-business"]}, +{"t":"SmallBizLoans.com","u":"http://www.smallbizloans.com/","d":"Small business loan comparison: SBA, bank and alternative lender reviews.","c":["small-business"]}, +{"t":"SmallBizTrends.com","u":"http://www.smallbiztrends.com/","d":"Award-winning small business news and trends resource updated daily.","c":["small-business"]}, +{"t":"SmallBusinessTV","u":"http://www.sbtv.com/","d":"Small business advice on video: marketing, finance, technology, management.","c":["small-business"]}, +{"t":"StartupHub","u":"http://www.startuphub.com/","d":"Educational portal explaining Startup for the first-time business owner.","c":["small-business"]}, +{"t":"StartupJournal.com","u":"http://www.startupjournal.com/","d":"Wall Street Journal's guide to starting and running a small business.","c":["small-business"]}, +{"t":"StartupNation","u":"http://www.startupnation.com/","d":"A community and resource center for entrepreneurs: podcasts, forums, competitions.","c":["small-business"]}, +{"t":"Tax Filing Hub Online","u":"http://www.taxfilinghub.com/","d":"Service helping entrepreneurs manage Tax Filing and grow their company.","c":["small-business"]}, +{"t":"The Franchising Hub","u":"http://www.franchisinghub.com/","c":["small-business"]}, +{"t":"VirtualAssistant.com","u":"http://www.virtualassistant.com/","d":"Virtual assistant services directory with task specializations and pricing.","c":["small-business"]}, +{"t":"CNET","u":"https://www.cnet.com/","d":"Product reviews and technology news.","c":["computers-internet"]}, +{"t":"GitHub","u":"https://github.com/","d":"Where the world builds software.","c":["computers-internet"]}, +{"t":"Mozilla.org","u":"https://www.mozilla.org","d":"Home of the Mozilla browser and open source web tools. The Netscape-based alternative to Internet Explorer.","c":["computers-internet","software"]}, +{"t":"Python.org","u":"https://www.python.org","d":"The official Python website. Documentation, tutorials, downloads.","c":["computers-internet"]}, +{"t":"Stack Overflow","u":"https://stackoverflow.com/","d":"Q&A community for programmers.","c":["computers-internet"]}, +{"t":"Symbian.com","u":"https://www.symbian.com","d":"Operating system for mobile phones. Developer resources for Nokia and Ericsson handsets.","c":["computers-internet"]}, +{"t":"The Linux Kernel Archives","u":"https://www.kernel.org/","d":"Official source of the Linux kernel.","c":["computers-internet"]}, +{"t":"W3C","u":"https://www.w3.org/","d":"Web standards from the World Wide Web Consortium.","c":["computers-internet","internet"]}, +{"t":"AnandTech","u":"https://www.anandtech.com/","d":"CPU, GPU and system reviews with real-world benchmarks, for the serious hardware enthusiast.","c":["hardware"]}, +{"t":"AnandTech Forums","u":"http://www.forums.anandtech.com/","d":"Hardware enthusiast forums: benchmarks, build guides, overclocking tips.","c":["hardware"]}, +{"t":"Apple","u":"https://www.apple.com/","d":"Maker of iPhone, iPad, Apple Watch, Mac and Apple TV, with an online store, accessories and device support.","c":["hardware"]}, +{"t":"Ars Technica","u":"https://arstechnica.com/","d":"Technology news and analysis, with long features on hardware, software and science.","c":["hardware"]}, +{"t":"AudioHardware.net","u":"http://www.audiohardware.net/","d":"Sound card and PC audio equipment reviews with listening tests and measurements.","c":["hardware"]}, +{"t":"BenchmarkReviews","u":"http://www.benchmarkreviews.com/","d":"PC hardware testing with standardized benchmark suites for objective performance comparisons.","c":["hardware"]}, +{"t":"CaseModding.com","u":"http://www.casemodding.com/","d":"Computer case modification tutorials: custom builds, paint jobs, window mods.","c":["hardware"]}, +{"t":"CPU Scorecard","u":"http://www.cpuscorecard.com/","d":"CPU benchmark database with comparison tools for Intel Pentium and AMD Athlon processors.","c":["hardware"]}, +{"t":"CPUBenchmark.net","u":"http://www.cpubenchmark.net/","d":"Processor benchmark database with comparison charts and performance tiers.","c":["hardware"]}, +{"t":"Crucial Memory","u":"https://www.crucial.com/","d":"Computer memory and SSD upgrades with a system scanner to find compatible parts.","c":["hardware"]}, +{"t":"DataRecovery.com","u":"http://www.datarecovery.com/","d":"Hard drive data recovery service with pricing and success rate information.","c":["hardware"]}, +{"t":"Dell","u":"https://www.dell.com/","d":"Build-to-order PCs and laptops sold direct from the manufacturer at competitive prices.","c":["hardware"]}, +{"t":"Digit-Life","u":"http://www.digit-life.com/","d":"A Russian-based international hardware review site, with GPU and CPU analysis.","c":["hardware"]}, +{"t":"ExtremeTech","u":"https://www.extremetech.com/","d":"Technology coverage with hardware reviews and computing performance analysis.","c":["hardware"]}, +{"t":"Gateway","u":"https://www.gateway.com/","d":"PCs and monitors from the cow-spotted box company. Affordable desktop and laptop computers.","c":["hardware"]}, +{"t":"GPUReview.com","u":"http://www.gpureview.com/","d":"Video card reviews with 3D rendering benchmarks and price/performance analysis.","c":["hardware"]}, +{"t":"HardOCP","u":"https://www.hardocp.com/","d":"Site with occasional editorials by Kyle Bennett on NVIDIA, video card pricing and the hardware review industry.","c":["hardware"]}, +{"t":"IBM","u":"https://www.ibm.com/","d":"Enterprise computing, servers and business technology from Big Blue.","c":["hardware"]}, +{"t":"KeyboardGuide","u":"http://www.keyboardguide.com/","c":["hardware"]}, +{"t":"LostCircuits.com","u":"http://www.lostcircuits.com/","d":"Hardware reviews and analysis for serious PC enthusiasts and overclockers.","c":["hardware"]}, +{"t":"MaximumPC","u":"https://www.maximumpc.com/","d":"No-holds-barred PC hardware reviews, overclocking guides, dream machine builds.","c":["hardware"]}, +{"t":"Modem Guide Online","u":"http://www.modemguide.com/","d":"Tutorials and reviews covering Modem for home PC builders.","c":["hardware"]}, +{"t":"MonitorReview","u":"http://www.monitorreview.com/","d":"Hardware site reviewing Monitor products with benchmarks and buying advice.","c":["hardware"]}, +{"t":"MonitorTests.com","u":"http://www.monitortests.com/","d":"Free monitor-related software such as Custom Resolution Utility, pixel clock patchers and an EDID writer.","c":["hardware"]}, +{"t":"Motherboard Central Online","u":"http://www.motherboardcentral.com/","d":"Resource for upgrading your Motherboard with installation guides and comparisons.","c":["hardware"]}, +{"t":"OCWorkbench","u":"http://www.ocworkbench.com/","d":"Singapore-based overclocking community with hardware reviews and system modification guides.","c":["hardware"]}, +{"t":"Overclock.net","u":"https://www.overclock.net/","d":"Enthusiast community for overclocking CPUs and GPUs, plus system tweaking.","c":["hardware"]}, +{"t":"OverclockersClub","u":"http://www.overclockersclub.com/","d":"An overclocking community: hardware reviews, guides, extreme cooling projects.","c":["hardware"]}, +{"t":"OverclockZone.com","u":"http://www.overclockzone.com/","d":"Thai-language computer hardware site with product articles on components such as liquid CPU coolers.","c":["hardware"]}, +{"t":"PCStats.com","u":"http://www.pcstats.com/","d":"Canadian PC hardware reviews: thermal testing, noise measurements, benchmarks.","c":["hardware"]}, +{"t":"PCWorld","u":"https://www.pcworld.com/","d":"PC hardware and software reviews, buying guides and tech news, for the everyday user.","c":["hardware"]}, +{"t":"Power Supply Review Online","u":"http://www.powersupplyreview.com/","d":"Buyer's guide to Power Supply with price tracking and spec comparisons.","c":["hardware"]}, +{"t":"PrinterComparison.com","u":"http://www.printercomparison.com/","d":"Printer reviews comparing output, speed and cost per page across models.","c":["hardware"]}, +{"t":"RAMReview","u":"http://www.ramreview.com/","d":"Resource for upgrading your RAM with installation guides and comparisons.","c":["hardware"]}, +{"t":"RAMTest.com","u":"http://www.ramtest.com/","d":"Memory module reviews with compatibility guides and overclocking profiles.","c":["hardware"]}, +{"t":"ScannerGuide","u":"http://www.scannerguide.com/","c":["hardware"]}, +{"t":"SharkyExtreme","u":"https://www.sharkyextreme.com/","d":"Hardware reviews and overclocking guides for PC enthusiasts and gamers.","c":["hardware"]}, +{"t":"SilentPCReview.com","u":"http://www.silentpcreview.com/","d":"Quiet computing resource with fan noise measurements and silent PC build guides.","c":["hardware"]}, +{"t":"SSDReview.com","u":"http://www.ssdreview.com/","d":"Solid state drive reviews with sequential and random I/O performance testing.","c":["hardware"]}, +{"t":"StorageReview","u":"https://www.storagereview.com/","d":"Hard drive and storage benchmarks, reviews, performance comparisons.","c":["hardware"]}, +{"t":"The Keyboard Review","u":"http://www.keyboardreview.com/","c":["hardware"]}, +{"t":"The Printer Guide","u":"http://www.printerguide.com/","d":"Resource for upgrading your Printer with installation guides and comparisons.","c":["hardware"]}, +{"t":"The Scanner Review","u":"http://www.scannerreview.com/","c":["hardware"]}, +{"t":"The Tech Report","u":"http://www.techreport.com/","d":"PC hardware reviews notable for latency-focused GPU benchmarks and CPU pipeline analysis.","c":["hardware"]}, +{"t":"Tom's Hardware","u":"https://www.tomshardware.com/","d":"Hardware reviews, CPU and GPU benchmarks, buyer's guides for enthusiasts.","c":["hardware"]}, +{"t":"TweakTown","u":"https://www.tweaktown.com/","d":"Hardware reviews, system tweaking guides and benchmarking, for PC performance enthusiasts.","c":["hardware"]}, +{"t":"UPSReviews.com","u":"http://www.upsreviews.com/","d":"Uninterruptible power supply reviews with battery runtime tests and wattage guides.","c":["hardware"]}, +{"t":"USBGear.com","u":"http://www.usbgear.com/","d":"USB peripheral reviews and compatibility guides: cables, hubs, adapters.","c":["hardware"]}, +{"t":"WebCamReview.com","u":"http://www.webcamreview.com/","d":"Webcam reviews comparing video, microphones and software features.","c":["hardware"]}, +{"t":"ZDNet","u":"https://www.zdnet.com/","d":"Enterprise technology news, product reviews and IT management resources from Ziff Davis.","c":["hardware"]}, +{"t":"50megs","u":"https://www.50megs.com/","d":"Free web hosting: 50MB of space, CGI-BIN access, FTP support.","c":["internet"]}, +{"t":"AllTheWeb","u":"https://www.alltheweb.com/","d":"FAST Search-powered engine boasting the largest index of web pages on the internet.","c":["internet"]}, +{"t":"AltaVista","u":"https://www.altavista.com/","d":"Powerful full-text web search with advanced query syntax. Deep index unmatched for obscure pages.","c":["internet"]}, +{"t":"AnimatedGIFs.com","u":"http://www.animatedgifs.com/","d":"Animated GIF collection for websites organized by category.","c":["internet"]}, +{"t":"AOL","u":"https://www.aol.com/","d":"America Online. The internet's gateway for millions: email, chat rooms, instant messaging.","c":["internet"]}, +{"t":"Archive.org Wayback Machine","u":"http://www.web.archive.org/","d":"Browse over 150 billion archived web pages dating back to 1996 in the internet's memory.","c":["internet"]}, +{"t":"Ask Jeeves","u":"https://www.ask.com/","d":"Natural language search engine: ask questions in plain English and get direct answers.","c":["internet"]}, +{"t":"AvatarMaker.com","u":"http://www.avatarmaker.com/","d":"Online avatar creation tool for chat rooms and message board profiles.","c":["internet"]}, +{"t":"Bandwidth Resource Online","u":"http://www.bandwidthresource.com/","c":["internet"]}, +{"t":"Blogger","u":"https://www.blogger.com/","d":"Free blog publishing platform from Pyra Labs, acquired by Google. Start your own weblog in minutes.","c":["internet"]}, +{"t":"BloggingTips.com","u":"http://www.bloggingtips.com/","d":"Blogging advice: platform selection, content strategy, monetization.","c":["internet"]}, +{"t":"Bravenet","u":"https://www.bravenet.com/","d":"Web services offering a site builder with an AI content writer, web hosting, domains, email, blogs and free web tools.","c":["internet"]}, +{"t":"Brinkster","u":"https://www.brinkster.com/","d":"Managed hosting and IaaS provider offering cloud servers, disaster recovery, hosted email, data storage and DDoS mitigation.","c":["internet"]}, +{"t":"Broadband Guide Online","u":"http://www.broadbandguide.com/","d":"Internet resource covering Broadband for webmasters and surfers.","c":["internet"]}, +{"t":"CompuServe","u":"https://www.compuserve.com/","d":"Web portal with a feed of national and world news headlines.","c":["internet"]}, +{"t":"CoolText","u":"https://cooltext.com/","d":"Free graphics generator for web page logos, buttons and headers. Dozens of styles.","c":["internet"]}, +{"t":"Deja News Archive","u":"https://groups.google.com/","d":"Google's Usenet archive preserving decades of newsgroup discussions from the early internet.","c":["internet"]}, +{"t":"Digg.com","u":"http://www.digg.com/","d":"Social news website where users submit and vote on content from across the web.","c":["internet"]}, +{"t":"Dogpile","u":"https://www.dogpile.com/","d":"Metasearch engine. Fetches results from Google, Yahoo, Bing and others at once.","c":["internet"]}, +{"t":"DomainTools.com","u":"http://www.domaintools.com/","d":"DNS-based threat intelligence and domain security platform for detecting and investigating malicious domains.","c":["internet"]}, +{"t":"DotComBoom.com","u":"http://www.dotcomboom.com/","d":"Dot-com era history and analysis of the internet bubble and its aftermath.","c":["internet"]}, +{"t":"Doteasy","u":"https://www.doteasy.com/","d":"Affordable web hosting with domain registration and site building tools for small sites.","c":["internet"]}, +{"t":"Dreamhost","u":"https://www.dreamhost.com/","d":"Web hosting: unlimited bandwidth, one-click WordPress installs, a loyal indie community.","c":["internet"]}, +{"t":"DSL Reports","u":"https://www.dslreports.com","d":"Broadband reviews, speed tests, ISP forums. Find DSL and cable providers in your area.","c":["internet","networking"]}, +{"t":"EarthLink","u":"https://www.earthlink.net/","d":"Internet service provider offering dial-up and broadband access with spam filtering tools.","c":["internet"]}, +{"t":"EFF - Electronic Frontier","u":"http://www.eff.org/","d":"Digital rights advocacy: free speech, privacy and innovation online.","c":["internet"]}, +{"t":"Email Guide Online","u":"http://www.emailguide.com/","c":["internet"]}, +{"t":"Excite","u":"https://www.excite.com/","d":"Dot-com era web portal with search, news and personalized content.","c":["internet"]}, +{"t":"Fark","u":"https://www.fark.com/","d":"User-submitted news aggregator with sarcastic headlines and active discussion forums.","c":["internet","entertainment","online-media"]}, +{"t":"FortuneCity","u":"https://www.fortunecity.com/","d":"Free web hosting community organized into themed neighborhoods, similar to GeoCities.","c":["internet"]}, +{"t":"FreeEmailList.com","u":"http://www.freeemaillist.com/","d":"A comparison of free email providers: Hotmail, Yahoo Mail, Lycos.","c":["internet"]}, +{"t":"Freeservers","u":"https://www.freeservers.com/","d":"Free web hosting with ad-supported pages and easy site builder for personal homepages.","c":["internet"]}, +{"t":"Friendster","u":"https://www.friendster.com/","d":"One of the first social networking sites connecting friends and friends-of-friends online.","c":["internet"]}, +{"t":"GeekCulture.com","u":"http://www.geekculture.com/","d":"Geek culture news: tech, gaming, comics, science fiction.","c":["internet"]}, +{"t":"GeoCities","u":"https://www.geocities.com/","d":"Free web hosting organized into themed neighborhoods. Build your own homepage for free!","c":["internet"]}, +{"t":"Go.com","u":"https://www.go.com/","d":"Walt Disney Company hub linking to Disney's online properties, including park, resort and cruise vacation planning.","c":["internet"]}, +{"t":"Google","u":"https://www.google.com/","d":"Web search powered by the PageRank algorithm. Fast and simple; the new gold standard.","c":["internet"]}, +{"t":"GuestbookCentral.com","u":"http://www.guestbookcentral.com/","d":"Free guestbook service for personal websites with customizable designs.","c":["internet"]}, +{"t":"Hitt Hosting","u":"https://hitthosting.com","c":["internet"]}, +{"t":"HostGator","u":"https://www.hostgator.com/","d":"Affordable shared and dedicated web hosting with cPanel and 24/7 support.","c":["internet"]}, +{"t":"HotBot","u":"https://www.hotbot.com/","d":"AI chat service giving access to third-party models for answers, writing help, image creation and text summaries.","c":["internet"]}, +{"t":"Hotmail","u":"https://www.hotmail.com/","d":"Microsoft's free web-based email service, the world's most popular webmail provider.","c":["internet"]}, +{"t":"HTMLValidator.com","u":"http://www.htmlvalidator.com/","d":"HTML and CSS validation tool checking web pages for standards compliance.","c":["internet"]}, +{"t":"IANA","u":"http://www.iana.org/","d":"The Internet Assigned Numbers Authority, coordinating the DNS root, IP addressing and protocol parameters.","c":["internet"]}, +{"t":"InfoSeek","u":"https://www.infoseek.com/","d":"Web search and directory portal, later absorbed into Disney's Go.com network.","c":["internet"]}, +{"t":"InternetHistory.org","u":"http://www.internethistory.org/","d":"History of the internet from ARPANET to the World Wide Web with timelines.","c":["internet"]}, +{"t":"InternetTrafficReport","u":"http://www.internettrafficreport.com/","d":"Real-time internet performance monitoring with latency and packet loss data.","c":["internet"]}, +{"t":"IPChicken.com","u":"http://www.ipchicken.com/","d":"Simple IP address lookup tool showing your public IP and connection details.","c":["internet"]}, +{"t":"IRCHelp.org","u":"http://www.irchelp.org/","d":"Internet Relay Chat help resource with client setup and server lists.","c":["internet"]}, +{"t":"ISOC - Internet Society","u":"http://www.isoc.org/","d":"International organization promoting open development and use of the internet for everyone.","c":["internet"]}, +{"t":"ISP Central Online","u":"http://www.ispcentral.com/","c":["internet"]}, +{"t":"ISPGuide","u":"http://www.ispguide.com/","c":["internet"]}, +{"t":"Juno","u":"https://www.juno.com/","d":"Free and low-cost dial-up internet and email service for budget-conscious users.","c":["internet"]}, +{"t":"Kuro5hin","u":"https://www.kuro5hin.org/","d":"Technology and culture collaborative media site where users vote on which stories get published.","c":["internet","online-media"]}, +{"t":"LiveJournal","u":"https://www.livejournal.com/","d":"An online journal and social network. People blog, share interests and form communities.","c":["internet"]}, +{"t":"LookSmart","u":"https://www.looksmart.com/","d":"Human-edited web directory and search engine delivering categorized internet content.","c":["internet"]}, +{"t":"Lycos","u":"https://www.lycos.com/","d":"Web portal: search, email, chat, homepage building. Go get it!","c":["internet"]}, +{"t":"MetaCrawler","u":"https://www.metacrawler.com/","d":"Metasearch engine querying multiple search engines simultaneously and merging the results.","c":["internet"]}, +{"t":"MetaFilter","u":"https://www.metafilter.com/","d":"Community weblog where members share and discuss interesting links from around the web.","c":["internet","online-media"]}, +{"t":"MySpace.com","u":"http://www.myspace.com/","d":"Social networking site popular with bands and young adults.","c":["internet"]}, +{"t":"Netcraft","u":"https://www.netcraft.com/","d":"Internet infrastructure surveys, web server statistics and hosting analysis, since 1995.","c":["internet","networking"]}, +{"t":"Netscape Netcenter","u":"https://www.netscape.com/","d":"Netscape's web portal. Email and search, from the browser that started it all.","c":["internet"]}, +{"t":"NetZero","u":"https://www.netzero.net/","d":"Internet service provider selling broadband, mobile broadband, DSL and dial-up plans, with Norton 360 offers for customers.","c":["internet"]}, +{"t":"Northern Light","u":"https://www.northernlight.com/","d":"Market and competitive intelligence platform that combines licensed research, internal content and market signals for enterprise teams.","c":["internet"]}, +{"t":"Prodigy","u":"https://www.prodigy.com/","d":"An early online service from IBM and Sears. News, shopping, bulletin boards.","c":["internet"]}, +{"t":"SearchEngineList.com","u":"http://www.searchenginelist.com/","d":"Directory of search engines worldwide with specialty and regional options.","c":["internet"]}, +{"t":"SearchEnginesGuide","u":"http://www.searchenginesguide.com/","d":"Search engines explained, with reviews, tutorials and tips for getting online.","c":["internet"]}, +{"t":"SixApart","u":"https://www.sixapart.com/","d":"Makers of Movable Type and TypePad blogging platforms powering the weblog revolution.","c":["internet"]}, +{"t":"Slashdot","u":"https://slashdot.org/","d":"News for nerds: stuff that matters. Tech community link aggregation and discussion since 1997.","c":["internet","online-media"]}, +{"t":"Snap.com","u":"https://www.snap.com/","d":"Company site for Snap Inc., covering the Snapchat visual messaging service and the Lens Studio AR developer tool.","c":["internet"]}, +{"t":"StumbleUpon","u":"http://www.stumbleupon.com/","d":"Web discovery engine that finds and recommends content matching your personal interests.","c":["internet"]}, +{"t":"Techdirt","u":"http://www.techdirt.com/","d":"Technology policy blog: patents, copyright, innovation, internet regulation.","c":["internet"]}, +{"t":"TechSupport.com","u":"http://www.techsupport.com/","d":"Remote computer support service with troubleshooting and repair.","c":["internet"]}, +{"t":"The Bandwidth Network","u":"http://www.bandwidthnetwork.com/","c":["internet"]}, +{"t":"The FTP Central","u":"http://www.ftpcentral.com/","d":"Reference on FTP with comparisons and beginner-friendly explanations.","c":["internet"]}, +{"t":"The Register","u":"https://www.theregister.co.uk/","d":"Irreverent IT news and analysis, out of the UK.","c":["internet","online-media"]}, +{"t":"The Web Hosting Guide","u":"http://www.webhostingguide.com/","d":"A web hosting guide: reviews, tutorials, tips for getting online.","c":["internet"]}, +{"t":"Web Hosting Resource Online","u":"http://www.webhostingresource.com/","d":"Web hosting: reviews, tutorials, tips for getting online.","c":["internet"]}, +{"t":"WebAccessibility.org","u":"http://www.webaccessibility.org/","d":"Web accessibility standards and testing tools for ADA compliance.","c":["internet"]}, +{"t":"WebCrawler","u":"https://www.webcrawler.com/","d":"One of the first web search engines, launched in 1994 at the University of Washington.","c":["internet"]}, +{"t":"WebDesignGallery.com","u":"http://www.webdesigngallery.com/","d":"Curated web design gallery showcasing the best website designs by category.","c":["internet"]}, +{"t":"WebHostingTalk","u":"http://www.webhostingtalk.com/","d":"The largest web hosting community: reviews, discussions, industry news.","c":["internet"]}, +{"t":"WebHostReview.com","u":"http://www.webhostreview.com/","d":"Web hosting reviews and ratings from verified customers with uptime monitoring.","c":["internet"]}, +{"t":"Webmaster Resource Online","u":"http://www.webmasterresource.org/","c":["internet"]}, +{"t":"WebRings.org","u":"http://www.webrings.org/","d":"Web ring directory connecting themed personal websites in navigable rings.","c":["internet"]}, +{"t":"WikiTravel.org","u":"http://www.wikitravel.org/","d":"Collaborative travel guide wiki with worldwide destination articles.","c":["internet"]}, +{"t":"Wired","u":"https://www.wired.com/","d":"Technology and culture through the lens of Silicon Valley: the definitive tech lifestyle magazine.","c":["internet","magazines"]}, +{"t":"Xanga","u":"https://www.xanga.com/","d":"Blogging platform and social network popular with teens for online diaries and personal expression.","c":["internet"]}, +{"t":"Yahoo!","u":"https://www.yahoo.com/","d":"Search, email, news, finance and sports, plus the original human-curated web directory.","c":["internet"]}, +{"t":"Yahoo! Mail","u":"https://mail.yahoo.com/","d":"Free web-based email service from Yahoo! with generous storage and spam filtering.","c":["internet"]}, +{"t":"3Com","u":"http://www.3com.com/","d":"Networking equipment manufacturer: switches, routers, wireless for business.","c":["networking"]}, +{"t":"Cisco Networking Academy","u":"https://www.netacad.com","d":"Free networking curriculum. CCNA preparation and IT fundamentals courses.","c":["networking"]}, +{"t":"Cisco.com","u":"http://www.cisco.com/","d":"World's largest networking equipment manufacturer with technical documentation and training.","c":["networking"]}, +{"t":"CNET News.com","u":"https://news.cnet.com","d":"Technology news and analysis, with product reviews, industry coverage and downloads.","c":["networking"]}, +{"t":"DNSGuide","u":"http://www.dnsguide.com/","c":["networking"]}, +{"t":"DNSStuff.com","u":"http://www.dnsstuff.com/","d":"DNS and network diagnostic tools for administrators and webmasters.","c":["networking"]}, +{"t":"HomeNetworkSetup.com","u":"http://www.homenetworksetup.com/","d":"Step-by-step home network setup guides for wired and wireless configurations.","c":["networking"]}, +{"t":"ITCertification.com","u":"http://www.itcertification.com/","d":"An IT certification guide: CompTIA, Microsoft, Cisco.","c":["networking"]}, +{"t":"Juniper Networks","u":"http://www.juniper.net/","d":"Enterprise networking and security: routers, switches, firewalls.","c":["networking"]}, +{"t":"Linksys","u":"https://www.linksys.com/","d":"Home networking equipment: routers, switches, wireless access points.","c":["networking"]}, +{"t":"LinksysInfo.org","u":"http://www.linksysinfo.org/","d":"Unofficial community resource for Linksys router users with firmware mods and networking tips.","c":["networking"]}, +{"t":"NetAdmin Tools","u":"http://www.netadmintools.com/","d":"Free network administration utilities: monitoring, troubleshooting, security.","c":["networking"]}, +{"t":"NetGear Community","u":"http://www.netgear.com/","d":"Consumer and small business networking products with support forums and firmware updates.","c":["networking"]}, +{"t":"NetworkCable.com","u":"http://www.networkcable.com/","d":"A network cabling guide: Cat5, Cat6, fiber optic specifications.","c":["networking"]}, +{"t":"NetworkDiagram.net","u":"http://www.networkdiagram.net/","d":"Free network diagram templates and drawing tools for IT documentation.","c":["networking"]}, +{"t":"NetworkMonitoring.net","u":"http://www.networkmonitoring.net/","d":"Network monitoring software reviews with SNMP tools and alerting features.","c":["networking"]}, +{"t":"NetworkWorld","u":"https://www.networkworld.com/","d":"Enterprise networking news, product reviews, infrastructure management resources.","c":["networking"]}, +{"t":"Practically Networked","u":"https://www.practicallynetworked.com/","d":"Home and small office networking tutorials, troubleshooting guides, product reviews.","c":["networking"]}, +{"t":"RouterGuide.net","u":"http://www.routerguide.net/","d":"Home and enterprise router configuration guides with firmware update instructions.","c":["networking"]}, +{"t":"SearchNetworking","u":"http://www.searchnetworking.com/","d":"TechTarget's networking resource, with tutorials and advice.","c":["networking"]}, +{"t":"SendmailORG","u":"http://www.sendmail.org/","d":"Proofpoint page for the open source Sendmail mail server, with release downloads and security advisory contacts.","c":["networking"]}, +{"t":"ServerWatch","u":"http://www.serverwatch.com/","d":"Server technology news, reviews and how-to guides for system administrators.","c":["networking"]}, +{"t":"SmallNetBuilder","u":"http://www.smallnetbuilder.com/","d":"Home and small office networking equipment reviews with real-world testing.","c":["networking"]}, +{"t":"SpeedGuide.net","u":"https://www.speedguide.net/","d":"TCP/IP optimization guides, broadband tweaks, internet speed testing tools.","c":["networking"]}, +{"t":"TCPGuide","u":"http://www.tcpipguide.com/","d":"An online reference for the TCP/IP protocol suite and networking fundamentals.","c":["networking"]}, +{"t":"TechSupportForum.com","u":"http://www.techsupportforum.com/","d":"Free technical support community with hardware and networking troubleshooting.","c":["networking"]}, +{"t":"The Switches Central","u":"http://www.switchescentral.com/","d":"Tutorials and reference on Switches for IT professionals and students.","c":["networking"]}, +{"t":"VoIPGuide.com","u":"http://www.voipguide.com/","d":"Voice over IP service reviews and technology explanations for business and home.","c":["networking"]}, +{"t":"VPNCentral","u":"http://www.vpncentral.com/","d":"Technical site exploring VPN fundamentals and best practices.","c":["networking"]}, +{"t":"VPNReviews.com","u":"http://www.vpnreviews.com/","d":"Virtual private network service comparisons with speed tests and privacy analysis.","c":["networking"]}, +{"t":"WiFiPlanet","u":"http://www.wi-fiplanet.com/","d":"Wireless networking news, reviews and tutorials, for enterprise and consumer Wi-Fi.","c":["networking"]}, +{"t":"Wireless Central Online","u":"http://www.wirelesscentral.com/","d":"Wireless, with diagrams, glossaries and certification tips.","c":["networking"]}, +{"t":"WirelessGuide","u":"http://www.wirelessguide.com/","d":"A wireless guide: diagrams, glossaries, certification tips.","c":["networking"]}, +{"t":"WirelessRouter.com","u":"http://www.wirelessrouter.com/","d":"Wireless router reviews with speed tests and range measurements.","c":["networking"]}, +{"t":"AlgorithmsBook.com","u":"http://www.algorithmsbook.com/","d":"Three MIT Press textbooks by Mykel J. Kochenderfer and coauthors on optimization, decision making and validation, with free PDFs.","c":["programming-development"]}, +{"t":"APIDirectory.com","u":"http://www.apidirectory.com/","d":"Directory of public web APIs with documentation links and usage examples.","c":["programming-development"]}, +{"t":"CodeGuru.com","u":"http://www.codeguru.com/","d":"C++ and Visual Basic programming tutorials with downloadable source code.","c":["programming-development"]}, +{"t":"ColdFusion Central Online","u":"http://www.coldfusioncentral.com/","d":"Tutorials, troubleshooting guides and downloads for ColdFusion (CFML) developers.","c":["programming-development"]}, +{"t":"CPAN - Perl Archive","u":"http://www.cpan.org/","d":"The Comprehensive Perl Archive Network, holding over 25,000 Perl module distributions.","c":["programming-development"]}, +{"t":"DeveloperWorks.com","u":"http://www.developerworks.com/","d":"IBM's developer resource: tutorials, tools, community forums.","c":["programming-development"]}, +{"t":"DevShed","u":"https://www.devshed.com/","d":"Programming tutorials and articles for web developers: PHP, Python, Java.","c":["programming-development"]}, +{"t":"Experts Exchange","u":"https://www.experts-exchange.com/","d":"Q&A community where IT professionals help each other solve technical problems.","c":["programming-development"]}, +{"t":"Java.sun.com","u":"https://java.sun.com/","d":"Sun Microsystems' official Java programming language documentation and downloads.","c":["programming-development"]}, +{"t":"JavaRanch.com","u":"http://www.javaranch.com/","d":"Friendly Java programming community with certification study groups.","c":["programming-development"]}, +{"t":"LiveJournal Developer","u":"https://www.livejournal.com/developer/","d":"Open source blogging platform APIs and developer tools for integrating with LiveJournal.","c":["programming-development"]}, +{"t":"MSDN Online","u":"http://www.msdn.microsoft.com/","d":"Microsoft's official documentation, practical guidance, training and technical credentials for working with Microsoft products.","c":["programming-development"]}, +{"t":"O'Reilly Network","u":"http://www.oreillynet.com/","d":"Technology and business learning platform with expert content, hands-on learning, interactive workshops and AI-powered guidance.","c":["programming-development"]}, +{"t":"Perl.com","u":"https://www.perl.com/","d":"The official Perl portal: tutorials, documentation, CPAN module search.","c":["programming-development"]}, +{"t":"Perl.org","u":"http://www.perl.org/","d":"The official Perl site: downloads, documentation, community resources.","c":["programming-development"]}, +{"t":"Perlmonks","u":"https://www.perlmonks.org/","d":"Perl programming community: tutorials, code snippets, collaborative problem solving.","c":["programming-development"]}, +{"t":"PHP.net","u":"https://www.php.net/","d":"The official PHP documentation, plus downloads and community resources.","c":["programming-development"]}, +{"t":"PythonWare.com","u":"http://www.pythonware.com/","d":"Python programming resources including the PIL imaging library documentation.","c":["programming-development"]}, +{"t":"RegexTester.com","u":"http://www.regextester.com/","d":"Online regular expression testing tool with syntax reference and examples.","c":["programming-development"]}, +{"t":"Ruby-Lang.org","u":"http://www.ruby-lang.org/","d":"Official Ruby programming language site with installation guides and community resources.","c":["programming-development"]}, +{"t":"Slashdot Developer","u":"https://developers.slashdot.org/","d":"Developer-focused section of Slashdot with open source and programming news.","c":["programming-development"]}, +{"t":"The Code Project","u":"https://www.codeproject.com/","d":"Free source code, tutorials and articles for Windows and .NET developers.","c":["programming-development"]}, +{"t":"The Python Resource","u":"https://www.pythonresource.net/","d":"Python basics, from installing it to writing your own functions, with a few short example programs.","c":["programming-development"]}, +{"t":"The Ruby Central","u":"http://www.rubycentral.org/","d":"Organization supporting the Ruby community since 2001, running RubyConf and keeping RubyGems and RubyGems.org available.","c":["programming-development"]}, +{"t":"AntivirusReview.com","u":"http://www.antivirusreview.com/","d":"Antivirus software reviews: detection rates, performance impact, pricing.","c":["security"]}, +{"t":"BackupCentral","u":"http://www.backupcentral.com/","d":"Independent backup and recovery consultant offering cyber recovery assessments, ransomware recovery support and expert testimony.","c":["security"]}, +{"t":"CERT Coordination Center","u":"https://www.cert.org/","d":"Internet security vulnerability tracking and incident response coordination from Carnegie Mellon.","c":["security"]}, +{"t":"EmailSecurity.com","u":"http://www.emailsecurity.com/","d":"An email security guide: spam filtering, phishing prevention, encryption.","c":["security"]}, +{"t":"FirewallGuide.com","u":"http://www.firewallguide.com/","d":"Personal and enterprise firewall reviews with configuration tutorials.","c":["security"]}, +{"t":"ForensicTools.com","u":"http://www.forensictools.com/","d":"Computer forensics toolkit reviews for digital investigation and evidence recovery.","c":["security"]}, +{"t":"Gibson Research Corporation (GRC)","u":"https://www.grc.com","d":"ShieldsUP! firewall test and security tools. Steve Gibson's security research and freeware.","c":["security"]}, +{"t":"Grisoft AVG","u":"https://www.avg.com/","d":"Free antivirus software for Windows with real-time protection and virus database updates.","c":["security"]}, +{"t":"HackerWatch.org","u":"http://www.hackerwatch.org/","d":"Internet security monitoring service tracking hacker activity and port scan trends.","c":["security"]}, +{"t":"Hacking Central Online","u":"http://www.hackingcentral.com/","c":["security"]}, +{"t":"HackingWatch","u":"http://www.hackingwatch.com/","c":["security"]}, +{"t":"Insecure.org","u":"http://www.insecure.org/","d":"Home of Nmap network scanner and security resources maintained by Fyodor.","c":["security"]}, +{"t":"McAfee","u":"https://www.mcafee.com/","d":"Antivirus software, security suites and virus removal tools, for home and business.","c":["security"]}, +{"t":"McAfee AVERT","u":"http://www.mcafee.com/avert/","d":"A virus information library: threat descriptions, removal tools, security advisories.","c":["security"]}, +{"t":"OpenSSL Project","u":"http://www.openssl.org/","d":"Open source implementation of SSL and TLS protocols with a general-purpose cryptography library.","c":["security"]}, +{"t":"Packet Storm Security","u":"https://packetstormsecurity.org/","d":"Security advisories, exploits and tools for security professionals and researchers.","c":["security"]}, +{"t":"SANS Institute","u":"https://www.sans.org/","d":"Information security training, certification and research.","c":["security"]}, +{"t":"Secunia","u":"http://www.secunia.com/","d":"Vulnerability research team at Flexera providing curated, validated vulnerability intelligence on specific software product versions.","c":["security"]}, +{"t":"SecurityFocus","u":"https://www.securityfocus.com/","d":"Home of the Bugtraq mailing list and a security vulnerability database.","c":["security"]}, +{"t":"Snort IDS","u":"http://www.snort.org/","d":"Open source network intrusion detection and prevention system maintained by Sourcefire.","c":["security"]}, +{"t":"Spybot Search & Destroy","u":"https://www.safer-networking.org/","d":"Free spyware detection and removal tool protecting Windows PCs from malicious software.","c":["security"]}, +{"t":"SpywareCentral","u":"http://www.spywarecentral.com/","d":"Reference on Spyware with checklists and software recommendations.","c":["security"]}, +{"t":"SpywareRemoval.com","u":"http://www.spywareremoval.com/","d":"Spyware detection and removal guides with free scanning tool downloads.","c":["security"]}, +{"t":"Symantec","u":"https://www.symantec.com/","d":"Norton AntiVirus, internet security software and threat intelligence.","c":["security"]}, +{"t":"Symantec Security Response","u":"http://www.symantec.com/security_response/","d":"Virus definitions, security advisories and threat intelligence from Norton's parent company.","c":["security"]}, +{"t":"The Antivirus Watch","u":"http://www.antiviruswatch.com/","d":"Security news and advisories focused on Antivirus threats.","c":["security"]}, +{"t":"The Firewall Central","u":"http://www.firewallcentral.com/","d":"A firewall guide: tools, tutorials, protection tips.","c":["security"]}, +{"t":"The Identity Theft Watch","u":"http://www.identitytheftwatch.com/","d":"Community of security professionals discussing Identity Theft issues.","c":["security"]}, +{"t":"Trend Micro","u":"https://www.trendmicro.com/","d":"Enterprise AI cybersecurity platform with layered protection, cyber risk exposure management and security operations tools.","c":["security"]}, +{"t":"WirelessSecurity.net","u":"http://www.wirelesssecurity.net/","d":"A Wi-Fi security guide: WEP, WPA, network intrusion prevention.","c":["security"]}, +{"t":"Zone Alarm (Zone Labs)","u":"https://www.zonelabs.com","d":"Free personal firewall for Windows. Monitors inbound and outbound internet traffic.","c":["security"]}, +{"t":"Zone-H.org","u":"http://www.zone-h.org/","d":"A web security portal: a defacement archive, news, vulnerability research.","c":["security"]}, +{"t":"ZoneAlarm","u":"https://www.zonealarm.com/","d":"Free personal firewall software protecting your PC from hackers and internet threats.","c":["security"]}, +{"t":"Acrobat Reader","u":"https://www.adobe.com/acrobat/","d":"Adobe's free PDF reader, for viewing documents on the web.","c":["software"]}, +{"t":"Apache.org","u":"https://www.apache.org/","d":"The Apache Software Foundation and its open source HTTP server powering most of the web.","c":["software"]}, +{"t":"BetaNews","u":"http://www.betanews.com/","d":"Software news and downloads focusing on beta and pre-release software for early adopters.","c":["software"]}, +{"t":"CDRipper.com","u":"http://www.cdripper.com/","d":"CD ripping software reviews for converting audio CDs to MP3 and FLAC.","c":["software"]}, +{"t":"Database Archive Online","u":"http://www.databasearchive.com/","c":["software"]}, +{"t":"DatabaseLibrary","u":"http://www.databaselibrary.com/","c":["software"]}, +{"t":"DesktopThemes.net","u":"http://www.desktopthemes.net/","d":"Windows desktop themes, wallpapers and icon packs.","c":["software"]}, +{"t":"DistroWatch.com","u":"http://www.distrowatch.com/","d":"Linux distribution news and popularity ranking with reviews and feature comparisons.","c":["software"]}, +{"t":"Download.com","u":"http://www.download.com/","d":"CNET's freeware and shareware library with editor reviews and user ratings for Windows and Mac.","c":["software"]}, +{"t":"Download.com","u":"https://download.cnet.com/","d":"Software downloads with user ratings and editorial reviews from CNET.","c":["software"]}, +{"t":"DriverArchive","u":"http://www.driverarchive.com/","d":"Resource reviewing Driver programs for Windows and Mac.","c":["software"]}, +{"t":"Eudora","u":"https://www.eudora.com/","d":"One of the oldest desktop email clients for Windows and Mac.","c":["software"]}, +{"t":"FileHippo.com","u":"http://www.filehippo.com/","d":"Free software downloads with automatic update checker to keep your programs current.","c":["software"]}, +{"t":"FreeBSD.org","u":"http://www.freebsd.org/","d":"The FreeBSD operating system project: documentation, ports, community.","c":["software"]}, +{"t":"Freeware Library Online","u":"http://www.freewarelibrary.com/","d":"Directory of Freeware applications with screenshots and comparisons.","c":["software"]}, +{"t":"Gentoo Linux","u":"https://www.gentoo.org/","d":"Source-based Linux distribution with Portage package management for power users.","c":["software"]}, +{"t":"GetRight","u":"https://www.getright.com/","d":"Download manager: resume capability, a scheduler, mirror search for faster downloads.","c":["software"]}, +{"t":"GIMP","u":"https://www.gimp.org/","d":"GNU Image Manipulation Program: powerful free and open source alternative to Photoshop.","c":["software"]}, +{"t":"ImageEditor.net","u":"http://www.imageeditor.net/","d":"Free image editing software reviews: Paint Shop Pro, GIMP and others.","c":["software"]}, +{"t":"Linux.com","u":"https://www.linux.com/","d":"News, tutorials and community resources for Linux.","c":["software"]}, +{"t":"LinuxJournal.com","u":"http://www.linuxjournal.com/","d":"Linux magazine: tutorials, reviews and open source news, for system administrators.","c":["software"]}, +{"t":"Litestep.net","u":"http://www.litestep.net/","d":"Alternative Windows shell replacement with themes and plugins for desktop customization.","c":["software"]}, +{"t":"MajorGeeks.com","u":"http://www.majorgeeks.com/","d":"Hand-picked freeware and shareware downloads tested for spyware and reliability.","c":["software"]}, +{"t":"MIDIFiles.com","u":"http://www.midifiles.com/","d":"MIDI music file archive for personal website background music.","c":["software"]}, +{"t":"mIRC","u":"https://www.mirc.com/","d":"The most popular Internet Relay Chat client for Windows with scripting support.","c":["software"]}, +{"t":"Nero Burning ROM","u":"https://www.nero.com/","d":"Multimedia software suite for PCs covering backup, video editing, disc burning, PC tuning and AI applications.","c":["software"]}, +{"t":"Nonags.com","u":"http://www.nonags.com/","d":"Quality freeware downloads only: no nagware, no adware, no spyware.","c":["software"]}, +{"t":"OfficeAlternatives.com","u":"http://www.officealternatives.com/","d":"Free alternatives to Microsoft Office with feature comparisons and reviews.","c":["software"]}, +{"t":"OpenOffice.org","u":"http://www.openoffice.org/","d":"Free open source office suite compatible with Microsoft Office documents.","c":["software"]}, +{"t":"Opera Browser","u":"https://www.opera.com/","d":"Fast web browser with tabbed browsing, mouse gestures and a built-in email client.","c":["software"]}, +{"t":"Paint Shop Pro","u":"https://www.jasc.com/","d":"Affordable image editing software rivaling Photoshop for web graphics and photo editing.","c":["software"]}, +{"t":"PCLinuxOS.com","u":"http://www.pclinuxos.com/","d":"User-friendly Linux distribution with rolling release model and community support.","c":["software"]}, +{"t":"PDFTools.com","u":"http://www.pdftools.com/","d":"Developer SDKs and on-premise services for PDF and PDF/A conversion, viewing, security and document processing.","c":["software"]}, +{"t":"Pegasus Mail","u":"https://www.pmail.com/","d":"Free email client for Windows: filtering, newsgroup reading, POP3/SMTP.","c":["software"]}, +{"t":"PortableApps.com","u":"http://www.portableapps.com/","d":"Portable software that runs from USB drives without installation.","c":["software"]}, +{"t":"PowerDVD","u":"https://www.cyberlink.com/","d":"CyberLink video and photo editing software, including PowerDirector and PhotoDirector, plus media playback, online tools and mobile apps.","c":["software"]}, +{"t":"QuickTime.com","u":"http://www.quicktime.com/","d":"Apple's QuickTime media player with movie trailers and streaming.","c":["software"]}, +{"t":"RealPlayer","u":"https://www.real.com/","d":"Streaming media player for RealAudio and RealVideo content across the web.","c":["software"]}, +{"t":"RealPlayer.com","u":"http://www.realplayer.com/","d":"RealNetworks streaming media player for audio and video content.","c":["software"]}, +{"t":"Red Hat","u":"https://www.redhat.com/","d":"Enterprise Linux distributor and open source technology leader for servers and workstations.","c":["software"]}, +{"t":"ScreensaverPlanet","u":"http://www.screensaverplanet.com/","d":"Free screensaver downloads for Windows: 3D, nature, holiday themes.","c":["software"]}, +{"t":"ShockwavePlayer.com","u":"http://www.shockwaveplayer.com/","d":"Macromedia Shockwave player for interactive web content and games.","c":["software"]}, +{"t":"Slackware Linux","u":"https://www.slackware.com/","d":"One of the oldest Linux distributions known for simplicity and Unix-like design.","c":["software"]}, +{"t":"SnapFiles","u":"http://www.snapfiles.com/","d":"A freeware and shareware directory, with reviews and screenshot galleries.","c":["software"]}, +{"t":"Softonic.com","u":"http://www.softonic.com/","d":"Software downloads portal with editorial reviews and user ratings in multiple languages.","c":["software"]}, +{"t":"Softpedia","u":"http://www.softpedia.com/","d":"A free software download library, with reviews, screenshots and clean download guarantees.","c":["software"]}, +{"t":"Spreadsheet Library Online","u":"http://www.spreadsheetlibrary.com/","c":["software"]}, +{"t":"SystemTweaker.com","u":"http://www.systemtweaker.com/","d":"Windows optimization and tweaking utilities for performance improvement.","c":["software"]}, +{"t":"The Shareware Library","u":"http://www.sharewarelibrary.com/","c":["software"]}, +{"t":"Tucows","u":"https://www.tucows.com/","d":"Toronto technology company offering domain name services, fiber internet through Ting, and telecom software through Wavelo.","c":["software"]}, +{"t":"WinAmp","u":"https://www.winamp.com/","d":"It really whips the llama's ass! The most popular MP3 player for Windows.","c":["software"]}, +{"t":"WinCustomize","u":"http://www.wincustomize.com/","d":"Desktop customization downloads for Windows: themes, skins, icons, wallpapers.","c":["software"]}, +{"t":"WinZip","u":"https://www.winzip.com/","d":"The world's most popular file compression utility for Windows. Zip and unzip with ease.","c":["software"]}, +{"t":"CGI Resources","u":"https://cgi.resourceindex.com/","d":"A directory of CGI scripts, Perl modules and server-side resources for web developers.","c":["web-development"]}, +{"t":"ColdFusion Community","u":"https://www.macromedia.com/coldfusion/","d":"Macromedia ColdFusion web application server with developer resources and tutorials.","c":["web-development"]}, +{"t":"CSSReference.com","u":"http://www.cssreference.com/","d":"Reference site for the book HTML: The Complete Reference, covering XHTML, CSS and web markup standards.","c":["web-development"]}, +{"t":"FlashKit","u":"https://www.flashkit.com/","d":"Community forums for Flash and ActionScript design and development, including a section for new Flash users.","c":["web-development"]}, +{"t":"JavaScriptWeekly.com","u":"http://www.javascriptweekly.com/","d":"JavaScript newsletter and tutorial archive for front-end web developers.","c":["web-development"]}, +{"t":"MovableType.com","u":"http://www.movabletype.com/","d":"Blog publishing platform with templates and plugin architecture.","c":["web-development"]}, +{"t":"PHPBuilder.com","u":"http://www.phpbuilder.com/","d":"PHP development tutorials, code snippets and community resources.","c":["web-development"]}, +{"t":"PHPFreaks.com","u":"http://www.phpfreaks.com/","d":"PHP tutorial site with code examples and community support forums.","c":["web-development"]}, +{"t":"ScriptArchive.com","u":"http://www.scriptarchive.com/","d":"CGI and Perl script archive with free downloads and installation guides.","c":["web-development"]}, +{"t":"SitePoint","u":"https://www.sitepoint.com/","d":"Web development tutorials, articles and books: HTML, CSS, JavaScript, PHP.","c":["web-development"]}, +{"t":"W3Schools","u":"https://www.w3schools.com/","d":"Free web development tutorials: HTML, CSS, JavaScript, SQL. Great for beginners.","c":["web-development"]}, +{"t":"WebReference.com","u":"http://www.webreference.com/","d":"A web developer resource, with tutorials on JavaScript, DHTML, XML and web design.","c":["web-development"]}, +{"t":"AlumniGuide","u":"http://www.alumniguide.com/","d":"College resource covering Alumni for prospective and current students.","c":["education"]}, +{"t":"AthleticScholarships.net","u":"http://www.athleticscholarships.net/","d":"Athletic scholarship guide with recruiting tips and NCAA eligibility rules.","c":["education"]}, +{"t":"Brown University","u":"https://www.brown.edu/","d":"Ivy League research university in Providence, Rhode Island with open curriculum.","c":["education"]}, +{"t":"CampusCompare.com","u":"http://www.campuscompare.com/","d":"A side-by-side college comparison tool: tuition, enrollment, ranking data.","c":["education"]}, +{"t":"CampusTours.com","u":"http://www.campustours.com/","d":"Virtual campus tours and interactive college maps for prospective students.","c":["education"]}, +{"t":"Cappex.com","u":"http://www.cappex.com/","d":"College matching and scholarship search tool with student reviews and admissions data.","c":["education"]}, +{"t":"College Confidential","u":"http://www.collegeconfidential.com/","d":"Student community forums on admissions, campus life and financial aid.","c":["education"]}, +{"t":"CollegeBoard","u":"https://www.collegeboard.com/","d":"SAT registration, AP exam info, college search tools, financial aid calculators.","c":["education"]}, +{"t":"CollegeBoard Online","u":"http://www.collegeboard.org/","d":"SAT registration, college search, financial aid information.","c":["education"]}, +{"t":"CollegeNET","u":"http://www.collegenet.com/","d":"Scheduling, calendaring and resource management software for universities, including Series25 and an interface for Workday Student.","c":["education"]}, +{"t":"CollegeSports.com","u":"http://www.collegesports.com/","d":"College athletics: recruitment, scores, conference standings.","c":["education"]}, +{"t":"CommonApp.org","u":"http://www.commonapp.org/","d":"Common Application used by 900+ colleges to simplify the admissions process.","c":["education"]}, +{"t":"CommunityColleges.com","u":"http://www.communitycolleges.com/","d":"Community college directory with program listings and transfer agreements.","c":["education"]}, +{"t":"Coursera","u":"https://www.coursera.org","d":"Online courses from top universities. Learn at your own pace with certificates.","c":["education","distance-learning"]}, +{"t":"DormRoom.com","u":"http://www.dormroom.com/","d":"College dorm room basics, decoration ideas, roommate advice.","c":["education"]}, +{"t":"Embark.com","u":"https://www.embark.com/","d":"Online college and graduate school application service with program search tools.","c":["education","graduate-schools"]}, +{"t":"FastWeb","u":"https://www.fastweb.com/","d":"Free scholarship search engine matching students with over 1.5 million scholarships.","c":["education","financial-aid"]}, +{"t":"Harvard University","u":"https://www.harvard.edu/","d":"America's oldest university. Admissions, academic programs, campus information.","c":["education"]}, +{"t":"Ivy League Admissions","u":"https://www.ivyleagueadmission.com/","d":"Admissions advice and strategies for applicants to Ivy League and elite universities.","c":["education"]}, +{"t":"Khan Academy (predecessor sites)","u":"https://www.khanacademy.org","d":"Free math and science tutorials. Self-paced learning for students of all ages.","c":["education","k-12"]}, +{"t":"LawSchoolAdmissions.com","u":"http://www.lawschooladmissions.com/","d":"Law school application resource with LSAT prep and school comparisons.","c":["education"]}, +{"t":"MIT","u":"https://web.mit.edu/","d":"Massachusetts Institute of Technology with academic programs and research excellence.","c":["education"]}, +{"t":"MIT","u":"https://www.mit.edu/","d":"Massachusetts Institute of Technology.","c":["education"]}, +{"t":"MIT OpenCourseWare","u":"https://ocw.mit.edu/","d":"Free access to virtually all MIT course materials online. A landmark in open education.","c":["education","distance-learning"]}, +{"t":"Niche.com Campus","u":"http://www.niche.com/","d":"School reviews and rankings with data-driven profiles for K-12 schools and colleges.","c":["education"]}, +{"t":"Peterson's Guide","u":"https://www.petersons.com/","d":"College and graduate school search, test prep, financial aid information.","c":["education"]}, +{"t":"RateMyProfessors.com","u":"http://www.ratemyprofessors.com/","d":"Student reviews of college professors, rated on helpfulness and clarity.","c":["education"]}, +{"t":"SATPrepOnline.com","u":"http://www.satpreponline.com/","d":"Test prep service offering coaching and study materials for the SAT, ACT, AP and PSAT exams.","c":["education"]}, +{"t":"ScholarshipFinder.com","u":"http://www.scholarshipfinder.com/","d":"Scholarship matching service pairing students with financial aid opportunities.","c":["education"]}, +{"t":"Stanford University","u":"https://www.stanford.edu/","d":"A research university in Silicon Valley, with academic programs and campus resources.","c":["education"]}, +{"t":"StudentsReview.com","u":"http://www.studentsreview.com/","d":"Unfiltered student reviews of colleges and universities rated by actual students.","c":["education"]}, +{"t":"StudyAbroadGuide","u":"http://www.studyabroadguide.com/","d":"Studying abroad: rankings, tips and student perspectives.","c":["education"]}, +{"t":"The Princeton Review","u":"https://www.princetonreview.com/","d":"College search and rankings, with test prep resources and financial aid guidance.","c":["education"]}, +{"t":"The Scholarship Guide","u":"http://www.scholarshipguide.com/","d":"Higher education portal exploring Scholarship and campus decisions.","c":["education"]}, +{"t":"The Tuition Central","u":"http://www.tuitioncentral.com/","d":"A guide to tuition, with rankings, tips and student perspectives.","c":["education"]}, +{"t":"TransferCreditGuide","u":"http://www.transfercreditguide.com/","d":"Reference helping students navigate Transfer Credit at colleges nationwide.","c":["education"]}, +{"t":"TransferStudents.com","u":"http://www.transferstudents.com/","d":"College transfer guide with credit evaluation tools and transfer-friendly schools.","c":["education"]}, +{"t":"Unigo.com","u":"http://www.unigo.com/","d":"Student-written college reviews: academics, campus life, value for money.","c":["education"]}, +{"t":"UniversityRankings.com","u":"http://www.universityrankings.com/","d":"Global university ranking comparisons with methodology explanations.","c":["education"]}, +{"t":"US News College Rankings","u":"http://www.usnews.com/colleges/","d":"Annual college rankings, with university profiles and comparison tools.","c":["education"]}, +{"t":"US News College Rankings","u":"https://www.usnews.com/best-colleges","d":"The definitive annual ranking of American colleges and universities.","c":["education"]}, +{"t":"Yale University","u":"https://www.yale.edu/","d":"An Ivy League university. Academic programs and campus life.","c":["education"]}, +{"t":"Athabasca University","u":"https://www.athabascau.ca/","d":"Canada's open university specializing in distance education and online learning.","c":["distance-learning"]}, +{"t":"Blackboard","u":"https://www.blackboard.com/","d":"Course management system used by universities worldwide for online learning and assignments.","c":["distance-learning"]}, +{"t":"Capella University","u":"https://www.capella.edu/","d":"An online university with accredited bachelor's, master's and doctoral programs for working adults.","c":["distance-learning"]}, +{"t":"ClassesUSA","u":"http://www.classesusa.com/","d":"Compare online colleges and universities with program directories and school profiles.","c":["distance-learning"]}, +{"t":"ContinuingEd.org","u":"http://www.continuinged.org/","d":"Continuing education course directory for professional development and licensing.","c":["distance-learning"]}, +{"t":"CookingClass.com","u":"http://www.cookingclass.com/","d":"Online cooking classes with video demonstrations and chef instruction.","c":["distance-learning"]}, +{"t":"eCollege","u":"https://www.ecollege.com/","d":"Platform for online course delivery and distance learning program management.","c":["distance-learning"]}, +{"t":"eLearners.com","u":"http://www.elearners.com/","d":"Directory of accredited online degree programs and distance learning courses.","c":["distance-learning"]}, +{"t":"eLearningMag.com","u":"http://www.elearningmag.com/","d":"E-learning industry magazine with technology reviews and instructional design articles.","c":["distance-learning"]}, +{"t":"EngineeringEd.com","u":"http://www.engineeringed.com/","d":"Engineering education resource with design challenges and career guides.","c":["distance-learning"]}, +{"t":"GetEducated.com","u":"http://www.geteducated.com/","d":"Reviews and rankings of accredited online degree programs with cost comparisons.","c":["distance-learning"]}, +{"t":"JonesKnowledge","u":"http://www.jonesknowledge.com/","d":"E-learning and knowledge management solutions for corporations and universities.","c":["distance-learning"]}, +{"t":"LanguageImmersion.com","u":"http://www.languageimmersion.com/","d":"Language immersion program directory with study abroad options.","c":["distance-learning"]}, +{"t":"LanguageLearning.com","u":"http://www.languagelearning.com/","d":"Foreign language courses and resources for self-study in 30+ languages.","c":["distance-learning"]}, +{"t":"MOOCList.com","u":"http://www.mooclist.com/","d":"Directory of massive open online courses from universities worldwide.","c":["distance-learning"]}, +{"t":"MusicLessons.com","u":"http://www.musiclessons.com/","d":"Online music lessons: piano, guitar, voice and more.","c":["distance-learning"]}, +{"t":"OnlineCertification.com","u":"http://www.onlinecertification.com/","d":"Professional certification programs available through online study.","c":["distance-learning"]}, +{"t":"OnlineDegreeCentral","u":"http://www.onlinedegreecentral.com/","d":"Directory of Online Degree options for working adults and remote learners.","c":["distance-learning"]}, +{"t":"OnlineMBA.com","u":"http://www.onlinemba.com/","d":"Online MBA program rankings with cost comparisons and alumni reviews.","c":["distance-learning"]}, +{"t":"Open University","u":"https://www.open.ac.uk/","d":"UK's largest university for distance learning with hundreds of degree and certificate programs.","c":["distance-learning"]}, +{"t":"PhotoCourseOnline.com","u":"http://www.photocourseonline.com/","d":"Online photography courses from beginner to professional levels.","c":["distance-learning"]}, +{"t":"PhysicsLab.com","u":"http://www.physicslab.com/","d":"Virtual physics laboratory with mechanics and electricity simulations.","c":["distance-learning"]}, +{"t":"SpanishLesson.com","u":"http://www.spanishlesson.com/","d":"Free Spanish lessons: grammar, vocabulary, pronunciation.","c":["distance-learning"]}, +{"t":"StudyAbroad.com","u":"http://www.studyabroad.com/","d":"Study abroad program directory with country guides and student experiences.","c":["distance-learning"]}, +{"t":"TestPrepReview.com","u":"http://www.testprepreview.com/","d":"Standardized test preparation reviews: SAT, GRE, GMAT, LSAT.","c":["distance-learning"]}, +{"t":"The Webinar Central","u":"http://www.webinarcentral.net/","d":"Distance learning resource covering Webinar programs and providers.","c":["distance-learning"]}, +{"t":"University of Phoenix Online","u":"https://www.phoenix.edu/","d":"One of the first accredited online universities offering degrees for working adults.","c":["distance-learning"]}, +{"t":"VirtualClassroom.net","u":"http://www.virtualclassroom.net/","d":"Virtual classroom technology reviews for teachers and distance learning programs.","c":["distance-learning"]}, +{"t":"VocationalTraining.net","u":"http://www.vocationaltraining.net/","d":"Trade school and vocational program directory with career outcome data.","c":["distance-learning"]}, +{"t":"WorldWideLearn","u":"http://www.worldwidelearn.com/","d":"Online education directory with distance learning programs from accredited universities.","c":["distance-learning"]}, +{"t":"WritingCourses.net","u":"http://www.writingcourses.net/","d":"Creative and professional writing courses available online.","c":["distance-learning"]}, +{"t":"Cappex Scholarships","u":"http://www.cappex.com/scholarships/","d":"Free scholarship search and matching tool with thousands of awards worth billions.","c":["financial-aid"]}, +{"t":"College Savings Plans","u":"https://www.savingforcollege.com/","d":"Information on 529 college savings plans, Coverdell ESAs and education tax benefits.","c":["financial-aid"]}, +{"t":"College Scholarship Service","u":"http://www.collegeboard.com/css/","d":"CSS Profile financial aid application used by 400+ colleges for institutional aid decisions.","c":["financial-aid"]}, +{"t":"EducationTaxCredit.com","u":"http://www.educationtaxcredit.com/","d":"Education tax credit and deduction guide for students and parents.","c":["financial-aid"]}, +{"t":"Fellowship Finder Online","u":"http://www.fellowshipfinder.com/","d":"Resource explaining Fellowship for students paying for college.","c":["financial-aid"]}, +{"t":"FinAid","u":"https://www.finaid.org/","d":"Student financial aid information: scholarships, loans, savings plans.","c":["financial-aid"]}, +{"t":"MathHelp.com","u":"http://www.mathhelp.com/","d":"Online math tutoring with video lessons from pre-algebra through calculus.","c":["financial-aid"]}, +{"t":"MeritAid.com","u":"http://www.meritaid.com/","d":"Directory of merit scholarships and academic awards from colleges and private organizations.","c":["financial-aid"]}, +{"t":"Nelnet","u":"http://www.nelnet.com/","d":"Federal student loan servicer, handling billing, repayment plans and loan consolidation.","c":["financial-aid"]}, +{"t":"Sallie Mae","u":"https://www.salliemae.com/","d":"Student loan servicer: repayment tools, consolidation options, savings plans.","c":["financial-aid"]}, +{"t":"Scholarships.com","u":"https://www.scholarships.com/","d":"Free scholarship search matching students with thousands of college scholarship opportunities.","c":["financial-aid"]}, +{"t":"StudentAid.gov","u":"http://www.studentaid.gov/","d":"The Federal Student Aid office: grants, loans, work-study programs.","c":["financial-aid"]}, +{"t":"StudentLoanCalc.com","u":"http://www.studentloancalc.com/","d":"Student loan repayment calculator with income-based plan comparisons.","c":["financial-aid"]}, +{"t":"WomenInSTEM.org","u":"http://www.womeninstem.org/","d":"Scholarships and resources for women pursuing STEM education.","c":["financial-aid"]}, +{"t":"AcademicJobSearch.com","u":"http://www.academicjobsearch.com/","d":"Faculty job board for tenure-track and adjunct positions in higher education.","c":["graduate-schools"]}, +{"t":"ApplyYourself","u":"http://www.applyyourself.com/","d":"Online graduate school application management system used by top programs.","c":["graduate-schools"]}, +{"t":"DentalSchool.com","u":"http://www.dentalschool.com/","d":"Dental school application guide with DAT prep and program rankings.","c":["graduate-schools"]}, +{"t":"DissertationHelp.com","u":"http://www.dissertationhelp.com/","d":"Dissertation writing resource with methodology guides and editor services.","c":["graduate-schools"]}, +{"t":"ETS GRE","u":"http://www.ets.org/gre/","d":"Official GRE test information: practice tests, preparation materials, score reporting.","c":["graduate-schools"]}, +{"t":"Grad School Shopper","u":"http://www.gradschoolshopper.com/","d":"American Institute of Physics guide to graduate programs in physics and astronomy.","c":["graduate-schools"]}, +{"t":"GradSchoolAdvice.com","u":"http://www.gradschooladvice.com/","d":"Graduate school application tips with personal statement examples and interview prep.","c":["graduate-schools"]}, +{"t":"GradSchools.com","u":"https://www.gradschools.com/","d":"A searchable directory of graduate programs, PhD programs and master's degrees.","c":["graduate-schools"]}, +{"t":"GradStar","u":"https://www.gradstar.com/","d":"Graduate school rankings, admissions tips and program comparisons.","c":["graduate-schools"]}, +{"t":"GRE Online","u":"https://www.gre.org/","d":"Graduate Record Examinations: registration, preparation materials, score reporting.","c":["graduate-schools"]}, +{"t":"Kaplan Test Prep","u":"http://www.kaptest.com/","d":"GRE, GMAT, LSAT, MCAT test preparation courses and practice materials.","c":["graduate-schools"]}, +{"t":"Kaplan Test Prep","u":"https://www.kaplan.com/","d":"Test preparation courses and practice exams: SAT, GRE, LSAT, MCAT, GMAT.","c":["graduate-schools"]}, +{"t":"LabNotebook.org","u":"http://www.labnotebook.org/","d":"Research laboratory notebook best practices and electronic lab notebook reviews.","c":["graduate-schools"]}, +{"t":"Law School Guide Online","u":"http://www.lawschoolguide.com/","c":["graduate-schools"]}, +{"t":"LSAT Prep","u":"https://www.lsac.org/","d":"The Law School Admission Council: LSAT registration, law school search, application tools.","c":["graduate-schools"]}, +{"t":"MastersDegree.net","u":"http://www.mastersdegree.net/","d":"Master's degree program directory with online and on-campus options.","c":["graduate-schools"]}, +{"t":"MBA.com","u":"https://www.mba.com/","d":"GMAT preparation, MBA program search, business school application resources.","c":["graduate-schools"]}, +{"t":"MBA.com Alternative","u":"http://www.mbaalternative.com/","d":"MBA program comparison with ROI calculations and alumni outcomes.","c":["graduate-schools"]}, +{"t":"Medical School Admissions","u":"https://www.aamc.org/","d":"Association of American Medical Colleges with MCAT information and medical school data.","c":["graduate-schools"]}, +{"t":"Medical School Central Online","u":"http://www.medicalschoolcentral.com/","d":"Portal helping students navigate Medical School and graduate study.","c":["graduate-schools"]}, +{"t":"MedicalSchool.com","u":"http://www.medicalschool.com/","d":"Medical school application guide with MCAT prep and school profiles.","c":["graduate-schools"]}, +{"t":"NursingSchool.com","u":"http://www.nursingschool.com/","d":"Nursing program directory with NCLEX prep and career resources.","c":["graduate-schools"]}, +{"t":"PhDPrograms.com","u":"http://www.phdprograms.com/","d":"PhD program directory with application requirements and funding data.","c":["graduate-schools"]}, +{"t":"PostdocPositions.com","u":"http://www.postdocpositions.com/","d":"Postdoctoral research position listings across all academic disciplines.","c":["graduate-schools"]}, +{"t":"ProFellow","u":"http://www.profellow.com/","d":"Fellowship search engine and community for graduate students seeking funded opportunities.","c":["graduate-schools"]}, +{"t":"ResearchFunding.org","u":"http://www.researchfunding.org/","d":"Research grant and fellowship database for graduate students and postdocs.","c":["graduate-schools"]}, +{"t":"The Dental School Guide","u":"http://www.dentalschoolguide.com/","d":"Portal helping students navigate Dental School and graduate study.","c":["graduate-schools"]}, +{"t":"The Law School Central","u":"http://www.lawschoolcentral.com/","c":["graduate-schools"]}, +{"t":"US News Grad Rankings","u":"http://www.usnews.com/grad/","d":"Rankings of graduate schools in business, law, medicine, engineering and education.","c":["graduate-schools"]}, +{"t":"VetSchool.com","u":"http://www.vetschool.com/","d":"Veterinary school application guide with GRE prep and program profiles.","c":["graduate-schools"]}, +{"t":"ArtForKids.com","u":"http://www.artforkids.com/","d":"Art lesson plans and projects for elementary and middle school students.","c":["k-12"]}, +{"t":"Barbie.com","u":"https://www.barbie.com/","d":"Games, activities and fashion design tools for kids, from the Mattel doll brand.","c":["k-12"]}, +{"t":"BrainPOP","u":"https://www.brainpop.com/","d":"Animated educational movies and quizzes: science, math, English, social studies.","c":["k-12"]}, +{"t":"Cartoon Network","u":"https://www.cartoonnetwork.com/","d":"Games, videos and activities for kids, with Dexter's Lab and the Powerpuff Girls.","c":["k-12","television"]}, +{"t":"ChemistryKit.com","u":"http://www.chemistrykit.com/","d":"Chemistry experiment kits for students with safe hands-on activities.","c":["k-12"]}, +{"t":"CliffsNotes","u":"https://www.cliffsnotes.com/","d":"Study guides, literature summaries and test prep for students.","c":["k-12"]}, +{"t":"Club Penguin (early predecessor sites)","u":"https://www.nick.com","d":"Nickelodeon's site for kids with show pages and video clips from SpongeBob SquarePants, The Loud House and other series.","c":["k-12"]}, +{"t":"CoolMath","u":"https://www.coolmath.com/","d":"Math lessons, games and puzzles, built to make math fun for kids and teens.","c":["k-12"]}, +{"t":"Discovery Education","u":"https://www.discoveryeducation.com/","d":"Digital textbooks, virtual labs and multimedia for K-12 classrooms, from Discovery Channel.","c":["k-12"]}, +{"t":"EdHelper","u":"https://www.edhelper.com/","d":"Teacher worksheet generator with printable activities covering all K-12 subjects.","c":["k-12"]}, +{"t":"Education World","u":"http://www.educationworld.com/","d":"An educator's resource: lesson plans, professional development, technology integration.","c":["k-12"]}, +{"t":"FieldTripCentral","u":"http://www.fieldtripcentral.com/","d":"Educational site helping kids with Field Trip through games and tutorials.","c":["k-12"]}, +{"t":"Funbrain","u":"https://www.funbrain.com/","d":"Educational games, books and videos for kids in grades K-8. Home of Diary of a Wimpy Kid.","c":["k-12"]}, +{"t":"GeographyQuiz.com","u":"http://www.geographyquiz.com/","d":"Geography quiz games: maps, capitals, flag identification.","c":["k-12"]}, +{"t":"GreatSchools.org","u":"http://www.greatschools.org/","d":"School ratings, reviews and test score data, to help parents find the right school.","c":["k-12"]}, +{"t":"HistoryChannel.com","u":"http://www.historychannel.com/","d":"History Channel online: documentaries, timelines, educational content.","c":["k-12"]}, +{"t":"HistoryForKids.org","u":"http://www.historyforkids.org/","d":"Kid-friendly history lessons: timelines, maps, interactive activities.","c":["k-12"]}, +{"t":"HomeschoolWorld","u":"http://www.home-school.com/","d":"For homeschool families: curriculum reviews, support groups, teaching tips.","c":["k-12"]}, +{"t":"Homework Helper","u":"http://www.homeworkhelper.com/","d":"Free homework help for K-12 students in math, science, English and social studies.","c":["k-12"]}, +{"t":"Math.com","u":"https://www.math.com/","d":"Free math tutoring, lessons and practice problems, from elementary through college.","c":["k-12"]}, +{"t":"MathPlayground","u":"http://www.mathplayground.com/","d":"Interactive math games, logic puzzles and practice activities for elementary students.","c":["k-12"]}, +{"t":"MathTutor.com","u":"http://www.mathtutor.com/","d":"Interactive math tutorial software for grades 6 to 12, covering pre-algebra through calculus plus ACT and SAT prep.","c":["k-12"]}, +{"t":"Mathway","u":"https://www.mathway.com/","d":"Online math problem solver for algebra, calculus and statistics, with step-by-step solutions.","c":["k-12"]}, +{"t":"National Geographic Kids","u":"https://kids.nationalgeographic.com/","d":"Educational games, animal facts and science activities for young explorers.","c":["k-12"]}, +{"t":"PBS Kids","u":"https://pbskids.org/","d":"Educational games, videos and activities for children, featuring PBS characters.","c":["k-12"]}, +{"t":"PBS Teachers","u":"http://www.pbs.org/teachers/","d":"Free lesson plans, classroom activities and educational videos from PBS programs.","c":["k-12"]}, +{"t":"PEGames.com","u":"http://www.pegames.com/","d":"Physical education game ideas and activity plans for K-12 teachers.","c":["k-12"]}, +{"t":"ReadingCentral","u":"http://www.readingcentral.com/","c":["k-12"]}, +{"t":"ReadingLevel.com","u":"http://www.readinglevel.com/","d":"Reading level assessment tool with book recommendations by grade.","c":["k-12"]}, +{"t":"ReadingRockets.org","u":"http://www.readingrockets.org/","d":"Reading instruction strategies and resources for parents, teachers and young readers.","c":["k-12"]}, +{"t":"Scholastic","u":"https://www.scholastic.com/","d":"Children's book publisher. Reading resources, teacher tools, book fair information.","c":["k-12"]}, +{"t":"SchoolNotes.com","u":"http://www.schoolnotes.com/","d":"Free web pages for teachers to post homework assignments and class information.","c":["k-12"]}, +{"t":"ScienceFair.com","u":"http://www.sciencefair.com/","d":"Science fair project guide with experiment ideas and display templates.","c":["k-12"]}, +{"t":"ScienceOlympiad.org","u":"http://www.scienceolympiad.org/","d":"National science competition for middle and high school students.","c":["k-12"]}, +{"t":"Smithsonian Education","u":"https://www.si.edu/educators/","d":"Educational resources and lesson plans using the Smithsonian's museums and research.","c":["k-12"]}, +{"t":"SparkNotes","u":"https://www.sparknotes.com/","d":"Free study guides, literature summaries and test prep, for high school and college.","c":["k-12"]}, +{"t":"Starfall","u":"https://www.starfall.com/","d":"Free reading instruction site for early learners using phonics-based animated activities.","c":["k-12"]}, +{"t":"TeacherPlanBook.com","u":"http://www.teacherplanbook.com/","d":"Digital lesson planner for teachers with standards alignment tools.","c":["k-12"]}, +{"t":"Teachers.net","u":"http://www.teachers.net/","d":"A teacher community: lesson plans, chatboards, job listings, classroom resources.","c":["k-12"]}, +{"t":"TeacherTube","u":"https://www.teachertube.com/","d":"Educational video sharing site designed for teachers to share instructional content.","c":["k-12"]}, +{"t":"TeacherVision.com","u":"http://www.teachervision.com/","d":"Lesson plans, printables and teaching resources for K-12 educators.","c":["k-12"]}, +{"t":"The Geography Resource","u":"http://www.geographyresource.com/","d":"Free Geography activities and printables for elementary and middle school.","c":["k-12"]}, +{"t":"The Reading Zone","u":"http://www.readingzone.com/","c":["k-12"]}, +{"t":"WritingCenter.org","u":"http://www.writingcenter.org/","d":"Literacy teaching software for reading and spelling instruction, with a word database and prebuilt phonics wordlists.","c":["k-12"]}, +{"t":"ALA - American Library Assoc.","u":"http://www.ala.org/","d":"The professional association for librarians: advocacy, standards, continuing education.","c":["libraries"]}, +{"t":"AtlasObscura.com","u":"http://www.atlasobscura.com/","d":"Guide to the world's hidden wonders with unusual places and curious attractions.","c":["libraries"]}, +{"t":"Bartleby Reference","u":"https://www.bartleby.com/reference/","d":"Free online reference works: Strunk & White, Roget's Thesaurus, the Columbia Encyclopedia.","c":["libraries","encyclopedias"]}, +{"t":"BartlebyLibrary.com","u":"http://www.bartleby.com/library/","d":"A free online reference library: literature, verse, nonfiction.","c":["libraries"]}, +{"t":"BookFinder","u":"https://www.bookfinder.com/","d":"Textbook price comparison engine searching dozens of bookstores for the cheapest copies.","c":["libraries","shopping"]}, +{"t":"Digital Public Library of America","u":"http://www.dp.la/","d":"A portal to millions of digital items from libraries, archives and museums across the US.","c":["libraries"]}, +{"t":"DigitalArchives.org","u":"http://www.digitalarchives.org/","d":"Digital preservation resource with archiving best practices and tools.","c":["libraries"]}, +{"t":"Europeana","u":"http://www.europeana.eu/","d":"Europe's digital platform for cultural heritage with millions of items.","c":["libraries"]}, +{"t":"HathiTrust Digital Library","u":"http://www.hathitrust.org/","d":"Partnership of research libraries preserving and providing access to digitized collections.","c":["libraries"]}, +{"t":"HighBeam Research","u":"https://www.highbeam.com/","d":"An online research library. Full-text articles from newspapers, magazines and academic journals.","c":["libraries"]}, +{"t":"Internet Archive","u":"https://archive.org/","d":"Preserving the history of the internet via the Wayback Machine, plus digital media archive.","c":["libraries"]}, +{"t":"Internet Public Library","u":"https://www.ipl.org/","d":"An online public library. Reference resources, subject collections, a reading room.","c":["libraries"]}, +{"t":"JSTOR","u":"https://www.jstor.org/","d":"A digital library of academic journals across the humanities, social sciences and sciences.","c":["libraries","social-sciences"]}, +{"t":"Library of Congress","u":"https://www.loc.gov/","d":"The nation's oldest federal cultural institution and the world's largest library.","c":["libraries","us-federal"]}, +{"t":"LibraryTechnology.org","u":"http://www.librarytechnology.org/","d":"Library automation and technology resource with software reviews.","c":["libraries"]}, +{"t":"LibraryThing","u":"http://www.librarything.com/","d":"Social cataloging site where book lovers catalog their personal libraries and share reviews.","c":["libraries"]}, +{"t":"New York Public Library","u":"https://www.nypl.org/","d":"Digital collections, research guides and online databases from one of the world's great libraries.","c":["libraries"]}, +{"t":"OpenLibrary.org","u":"http://www.openlibrary.org/","d":"Internet Archive's open catalog of every book ever published with borrowing options.","c":["libraries"]}, +{"t":"OverDrive Digital Library","u":"http://www.overdrive.com/","d":"Digital distribution of eBooks and audiobooks to public libraries and schools.","c":["libraries"]}, +{"t":"Project MUSE","u":"https://muse.jhu.edu/","d":"Johns Hopkins digital library of scholarly journals in the humanities and social sciences.","c":["libraries","social-sciences"]}, +{"t":"ProQuest","u":"https://www.proquest.com/","d":"A research library for academic institutions: dissertations, newspapers, scholarly databases.","c":["libraries"]}, +{"t":"RareBooksNetwork","u":"http://www.rarebooksnetwork.com/","d":"Library resource covering Rare Books for patrons and librarians.","c":["libraries"]}, +{"t":"ResearchGuides.org","u":"http://www.researchguides.org/","d":"Portal of research resources for academics and business professionals, including a strategic management experiments guidebook and checklist.","c":["libraries"]}, +{"t":"School Library Network Online","u":"http://www.schoollibrarynetwork.com/","d":"Directory of libraries highlighting School Library services and collections.","c":["libraries"]}, +{"t":"WorldCat","u":"https://www.worldcat.org/","d":"Find items in libraries near you.","c":["libraries","reference"]}, +{"t":"AllKindsOfMinds","u":"http://www.allkindsofminds.org/","d":"Understanding learning differences in children with strategies for parents and educators.","c":["special-education"]}, +{"t":"Autism Society","u":"https://www.autism-society.org/","d":"Autism information, resources and advocacy for individuals and families.","c":["special-education"]}, +{"t":"AutismSpeaks","u":"http://www.autismspeaks.org/","d":"Autism awareness and research organization with family resources and community services.","c":["special-education"]}, +{"t":"CAST - Universal Design","u":"http://www.cast.org/","d":"Center for Applied Special Technology developing universal design for learning frameworks.","c":["special-education"]}, +{"t":"Council for Exceptional Children","u":"https://www.cec.sped.org/","d":"Professional association for educators working with students who have disabilities or gifts.","c":["special-education"]}, +{"t":"DO-IT Program","u":"http://www.washington.edu/doit/","d":"University of Washington program promoting technology access for people with disabilities.","c":["special-education"]}, +{"t":"DyslexiaHelp.org","u":"http://www.dyslexiahelp.org/","d":"Dyslexia information with screening tools and intervention strategies.","c":["special-education"]}, +{"t":"GiftedEducation.org","u":"http://www.giftededucation.org/","d":"Gifted and talented education resource with program directories.","c":["special-education"]}, +{"t":"LD Online","u":"https://www.ldonline.org/","d":"Learning disabilities and ADHD, for parents, teachers and professionals.","c":["special-education"]}, +{"t":"National Down Syndrome Society","u":"http://www.ndss.org/","d":"Advocacy and support for people with Down syndrome and their families.","c":["special-education"]}, +{"t":"Recording for the Blind","u":"https://www.rfbd.org/","d":"Audio textbooks and learning resources for students with visual impairments or reading disabilities.","c":["special-education"]}, +{"t":"SpeechTherapy.net","u":"http://www.speechtherapy.net/","d":"Speech-language pathology resource with therapy activities and materials.","c":["special-education"]}, +{"t":"The Dyslexia Resource","u":"http://www.dyslexiaresource.net/","d":"Directory of Dyslexia services and advocacy organizations.","c":["special-education"]}, +{"t":"The Learning Disability Resource","u":"http://www.learningdisabilityresource.com/","d":"Learning disabilities: strategies, services, legal information.","c":["special-education"]}, +{"t":"TransitionPlanning.com","u":"http://www.transitionplanning.com/","d":"Transition planning resource for special needs students entering adulthood.","c":["special-education"]}, +{"t":"Wrightslaw","u":"https://www.wrightslaw.com/","d":"Special education law and advocacy resources for parents of children with disabilities.","c":["special-education"]}, +{"t":"8-Bit Theater","u":"https://www.nuklearpower.com/","d":"Sprite-based webcomic parodying Final Fantasy and classic RPG tropes.","c":["entertainment"]}, +{"t":"Ain't It Cool News","u":"https://www.aintitcool.com/","d":"Harry Knowles' movie news and reviews site: the original insider film geek destination.","c":["entertainment","celebrities"]}, +{"t":"Albino Blacksheep","u":"https://www.albinoblacksheep.com/","d":"Flash animations, games and audio files, curated from the golden age of web humor.","c":["entertainment"]}, +{"t":"AnimationWorld.net","u":"http://www.animationworld.net/","d":"Animation industry news with festival reports and career resources.","c":["entertainment"]}, +{"t":"AnimeForum.com","u":"http://www.animeforum.com/","d":"Anime and manga discussion community with episode reviews and art galleries.","c":["entertainment"]}, +{"t":"AnimeNewsNet.com","u":"http://www.animenewsnetwork.com/","d":"Anime and manga news: reviews, an encyclopedic database, streaming guides.","c":["entertainment"]}, +{"t":"Apple Movie Trailers","u":"https://trailers.apple.com/","d":"Movie and TV show trailers on Apple TV, including upcoming films and series.","c":["entertainment"]}, +{"t":"Badmovies.org","u":"https://www.badmovies.org/","d":"Reviews of the worst B-movies ever made, celebrating the art of terrible filmmaking.","c":["entertainment"]}, +{"t":"Bash.org","u":"http://www.bash.org/","d":"IRC quote database collecting the funniest and most absurd conversations from chat.","c":["entertainment"]}, +{"t":"Box Office Mojo","u":"https://www.boxofficemojo.com/","d":"Box office tracking and film financial performance data, going back decades.","c":["entertainment"]}, +{"t":"BoxOfficeZone","u":"http://www.boxofficezone.com/","d":"Box office coverage, with reviews, news and trailers.","c":["entertainment"]}, +{"t":"CartoonBrew.com","u":"http://www.cartoonbrew.com/","d":"Animation industry news: feature films, shorts, TV animation.","c":["entertainment"]}, +{"t":"CinemaGeeks.com","u":"http://www.cinemageeks.com/","d":"Film tracking community with a personal film diary, watchlists, recommendations and discussion among movie fans.","c":["entertainment"]}, +{"t":"CinemaHistory.com","u":"http://www.cinemahistory.com/","d":"Cinema history resource with era overviews and landmark film analysis.","c":["entertainment"]}, +{"t":"ClassicFilmGuide.com","u":"http://www.classicfilmguide.com/","d":"Classic Hollywood film guide with reviews from the golden age of cinema.","c":["entertainment"]}, +{"t":"CollegeHumor.com","u":"http://www.collegehumor.com/","d":"Subscription streaming service for original unscripted comedy, with apps on iOS, Android and other platforms.","c":["entertainment"]}, +{"t":"Comedy Central","u":"https://www.comedycentral.com/","d":"South Park, The Daily Show and stand-up clips from the cable comedy network.","c":["entertainment"]}, +{"t":"ComedyPodcasts.com","u":"http://www.comedypodcasts.com/","d":"Comedy podcast directory with reviews and episode recommendations.","c":["entertainment"]}, +{"t":"ComicConventions.com","u":"http://www.comicconventions.com/","d":"A comic convention directory: dates, guests, ticket information.","c":["entertainment"]}, +{"t":"Comics.com","u":"http://www.comics.com/","d":"Daily newspaper comics online: Dilbert, Garfield, hundreds more.","c":["entertainment"]}, +{"t":"CrackedMag.com","u":"http://www.cracked.com/","d":"Internet humor publication evolving from the classic Cracked magazine.","c":["entertainment"]}, +{"t":"Ctrl+Alt+Del","u":"https://www.cad-comic.com/","d":"Gaming webcomic following the misadventures of an obsessive gamer and his friends.","c":["entertainment"]}, +{"t":"DailyHaHa.com","u":"http://www.dailyhaha.com/","d":"Humor site updated daily: funny pictures, videos, jokes, pranks.","c":["entertainment"]}, +{"t":"DailyJoke.com","u":"http://www.dailyjoke.com/","d":"A daily joke publication: clean, family and office humor.","c":["entertainment"]}, +{"t":"Dark Horizons","u":"https://www.darkhorizons.com/","d":"Australian movie news site. Scoops, reviews, box office coverage.","c":["entertainment"]}, +{"t":"Diesel Sweeties","u":"https://www.dieselsweeties.com/","d":"Pixel art webcomic about robots and humans navigating relationships and indie culture.","c":["entertainment"]}, +{"t":"Dinosaur Comics","u":"http://www.qwantz.com/","d":"Constrained webcomic using the same clip art dinosaur panels daily with different dialogue.","c":["entertainment"]}, +{"t":"DocumentaryZone","u":"http://www.documentaryzone.com/","d":"An archive of documentary: previews, interviews, box office data.","c":["entertainment"]}, +{"t":"DriveinTheater.com","u":"http://www.driveintheater.com/","d":"A drive-in movie theater directory: locations, schedules, preservation history.","c":["entertainment"]}, +{"t":"DriveinTheaters.com","u":"http://www.driveintheatersusa.com/","d":"Drive-in movie theater directory with current screenings and nostalgia.","c":["entertainment"]}, +{"t":"eBaum's World","u":"https://www.ebaumsworld.com/","d":"Viral humor aggregated daily: funny videos, flash animations, pictures, jokes.","c":["entertainment"]}, +{"t":"Film Threat","u":"https://www.filmthreat.com/","d":"Independent film reviews, interviews and news, covering cinema outside the Hollywood mainstream.","c":["entertainment"]}, +{"t":"FilmFestivals.com","u":"http://www.filmfestivals.com/","d":"Film festival directory with submission deadlines and screening schedules.","c":["entertainment"]}, +{"t":"Funny or Die","u":"http://www.funnyordie.com/","d":"Comedy video website co-founded by Will Ferrell and Adam McKay.","c":["entertainment"]}, +{"t":"GameFAQs","u":"https://www.gamefaqs.com","d":"Walkthroughs, cheats and FAQs for thousands of games, across all platforms.","c":["entertainment","games"]}, +{"t":"GoComics","u":"http://www.gocomics.com/","d":"Official hub for editorial and syndicated comic strips updated daily.","c":["entertainment"]}, +{"t":"HorrorMovieFan.com","u":"http://www.horrormovie.com/","d":"Horror movie reviews, news and community, for genre fans.","c":["entertainment"]}, +{"t":"IFC.com","u":"https://www.ifctv.com/","d":"Full episodes and video extras from IFC shows such as Portlandia, Brockmire and Sherman's Showcase.","c":["entertainment"]}, +{"t":"iFilm","u":"https://www.ifilm.com/","d":"AI film festival site for watching and sharing short films generated with AI.","c":["entertainment"]}, +{"t":"IMDb","u":"https://www.imdb.com/","d":"The internet's database of films, TV shows, actors and box office data.","c":["entertainment"]}, +{"t":"ImprovGames.com","u":"http://www.improvgames.com/","d":"Improv comedy games and exercises for classes and team building.","c":["entertainment"]}, +{"t":"IndieComics.org","u":"http://www.indiecomics.org/","d":"Independent comic book publisher directory with creator-owned spotlights.","c":["entertainment"]}, +{"t":"IndieFilmPage.com","u":"http://www.indiefilmpage.com/","d":"Independent film news: festival reports, distribution advice, filmmaker interviews.","c":["entertainment"]}, +{"t":"IndieWire.com","u":"http://www.indiewire.com/","d":"Independent film news, reviews and festival coverage for art house cinema.","c":["entertainment"]}, +{"t":"JoBlo","u":"https://www.joblo.com/","d":"Movie reviews, news and trailers, with a community of film fans.","c":["entertainment"]}, +{"t":"JoeCartoon.com","u":"http://www.joecartoon.com/","d":"Flash animated cartoons featuring Frog in a Blender and other irreverent characters.","c":["entertainment"]}, +{"t":"JokesClean.com","u":"http://www.jokesclean.com/","d":"Family-friendly clean joke collection for kids and office environments.","c":["entertainment"]}, +{"t":"Keenspot Comics","u":"http://www.keenspot.com/","d":"Webcomics network hosting popular independent comic strips and graphic stories.","c":["entertainment"]}, +{"t":"Maddox - Best Page in Universe","u":"http://www.maddox.xmission.com/","d":"Self-proclaimed best page in the universe with provocative humor essays.","c":["entertainment"]}, +{"t":"MagicTricks.com","u":"http://www.magictricks.com/","d":"Magic trick tutorials, equipment reviews, magician community forums.","c":["entertainment"]}, +{"t":"MangaReader.com","u":"http://www.mangareader.com/","d":"Online manga reading platform with translations of popular Japanese series.","c":["entertainment"]}, +{"t":"Megatokyo","u":"https://www.megatokyo.com/","d":"Manga-influenced webcomic about two Americans stranded in Tokyo. Huge online following.","c":["entertainment"]}, +{"t":"MovieFone","u":"https://www.moviefone.com/","d":"Movie showtimes, ticket purchasing and theater listings for your local area.","c":["entertainment"]}, +{"t":"MoviePosterDB.com","u":"http://www.movieposterdb.com/","d":"Movie poster database and marketplace with original and reproduction prints.","c":["entertainment"]}, +{"t":"MovieWeb.com","u":"http://www.movieweb.com/","d":"Movie news, trailers, photos and reviews, with upcoming release schedules.","c":["entertainment"]}, +{"t":"Mr. Cranky","u":"https://www.mrcranky.com/","d":"Hilariously negative movie reviews rating films on a scale from dynamite sticks.","c":["entertainment"]}, +{"t":"Old Man Murray","u":"https://www.oldmanmurray.com/","d":"Sardonic PC game criticism and industry commentary. Hugely influential gaming humor site.","c":["entertainment","games"]}, +{"t":"Parody Zone Online","u":"http://www.parodyzone.com/","d":"Entertainment, with parody, jokes and lighthearted fun.","c":["entertainment"]}, +{"t":"ParodyCentral","u":"http://www.parodycentral.com/","d":"Parody, jokes and lighthearted fun.","c":["entertainment"]}, +{"t":"Penny Arcade","u":"https://www.penny-arcade.com/","d":"The original gaming webcomic. Gamer culture commentary with attitude, since 1998.","c":["entertainment","games"]}, +{"t":"PointlessWasteOfTime","u":"http://www.pointlesswasteoftime.com/","d":"Comedy articles and editorials that became the foundation for Cracked.com.","c":["entertainment"]}, +{"t":"PrankVideos.com","u":"http://www.prankvideos.com/","d":"Prank video collection with hidden camera and practical joke compilations.","c":["entertainment"]}, +{"t":"PunnyWorld.com","u":"http://www.punnyworld.com/","d":"Pun collection and wordplay resource with daily pun challenges.","c":["entertainment"]}, +{"t":"PunsCentral","u":"http://www.punscentral.com/","d":"Humor site featuring Puns to brighten your day.","c":["entertainment"]}, +{"t":"PuppetShow.net","u":"http://www.puppetshow.net/","d":"Puppet show listings, a performer directory, puppet-making tutorials.","c":["entertainment"]}, +{"t":"Questionable Content","u":"https://www.questionablecontent.net/","d":"Indie rock slice-of-life webcomic about twenty-somethings and their robot companions.","c":["entertainment"]}, +{"t":"Real Ultimate Power","u":"https://www.realultimatepower.net/","d":"The official ninja webpage. Viral humor site about ninjas that spawned countless imitators.","c":["entertainment"]}, +{"t":"RetroJunk","u":"https://www.retrojunk.com/","d":"A nostalgia site: retro TV commercials, show intros, pop culture memories from the 80s and 90s.","c":["entertainment"]}, +{"t":"Rotten Tomatoes","u":"https://www.rottentomatoes.com/","d":"Film criticism aggregator with Tomatometer scores for new releases and classic films.","c":["entertainment"]}, +{"t":"Satire Central Online","u":"http://www.satirecentral.com/","d":"Funny content hub specializing in Satire and laughs.","c":["entertainment"]}, +{"t":"ScreenIt","u":"https://www.screenit.com/","d":"Movie content reviews for parents, rating violence, language and mature themes.","c":["entertainment"]}, +{"t":"ScreenwritingTips.com","u":"http://www.screenwritingtips.com/","d":"Screenwriting education: format guides, contest listings, script analysis.","c":["entertainment"]}, +{"t":"Seanbaby","u":"https://www.seanbaby.com/","d":"Irreverent humor: retro video game mockery, bad comic reviews, NES nostalgia.","c":["entertainment"]}, +{"t":"Sinfest","u":"https://www.sinfest.net/","d":"Manga-styled daily webcomic on philosophy, religion and pop culture, with sharp wit.","c":["entertainment"]}, +{"t":"Sluggy Freelance","u":"https://www.sluggy.com/","d":"A daily webcomic mixing sci-fi, fantasy and absurd humor. Running since 1997.","c":["entertainment"]}, +{"t":"Something Awful","u":"https://www.somethingawful.com/","d":"Comedy site. Satirical articles, Photoshop Phriday, the notorious SA forums.","c":["entertainment"]}, +{"t":"Spotify","u":"https://www.spotify.com/","d":"Music and podcast streaming.","c":["entertainment"]}, +{"t":"Stop-Motion Central Online","u":"http://www.stopmotioncentral.com/","d":"A portal devoted to stop-motion: previews, ratings, forums.","c":["entertainment"]}, +{"t":"Sundance Film Festival","u":"https://www.sundance.org/","d":"Robert Redford's independent film festival. Submissions, schedule, filmmaker resources.","c":["entertainment"]}, +{"t":"SuperheroDatabase.com","u":"http://www.superherodatabase.com/","d":"A superhero encyclopedia: power stats, team affiliations, comic appearances.","c":["entertainment"]}, +{"t":"TextsFromLastNight","u":"http://www.textsfromlastnight.com/","d":"Anonymous text messages submitted from nights of questionable decision-making.","c":["entertainment"]}, +{"t":"The Box Office Central","u":"http://www.boxofficecentral.com/","d":"A movie site covering the box office: reviews, news, trailers.","c":["entertainment"]}, +{"t":"The Hollywood Reporter","u":"http://www.hollywoodreporter.com/","d":"Entertainment news and features on film, TV and the business behind them.","c":["entertainment"]}, +{"t":"The Onion","u":"https://www.theonion.com/","d":"America's finest news source. The definitive satirical newspaper of the internet age.","c":["entertainment"]}, +{"t":"The Order of the Stick","u":"http://www.giantitp.com/","d":"Stick-figure webcomic parodying Dungeons & Dragons and fantasy RPG tropes.","c":["entertainment"]}, +{"t":"The Pranks Central","u":"http://www.prankscentral.com/","d":"An entertainment site: pranks, jokes, lighthearted fun.","c":["entertainment"]}, +{"t":"The Sarcasm Zone","u":"http://www.sarcasmzone.com/","d":"Comedy portal collecting the best Sarcasm on the web.","c":["entertainment"]}, +{"t":"The Smoking Gun","u":"https://www.thesmokinggun.com/","d":"Original documents, mugshots and government files on the secret world of public figures.","c":["entertainment","online-media"]}, +{"t":"The Spark","u":"https://www.thespark.com/","d":"Humor site: personality quizzes, satirical dating tools, the stinky meat project.","c":["entertainment"]}, +{"t":"TheChive.com","u":"http://www.thechive.com/","d":"Photo entertainment: funny pictures, compilations, feel-good stories.","c":["entertainment"]}, +{"t":"TheMovieSpoiler.com","u":"http://www.themoviespoiler.com/","d":"Full plot summaries of current and upcoming movies, for those who want spoilers.","c":["entertainment"]}, +{"t":"Tom Green Show","u":"https://www.tomgreen.com/","d":"Tom Green's early web-streaming show pioneering internet broadcasting before its time.","c":["entertainment"]}, +{"t":"Toonami","u":"https://www.toonami.com/","d":"Cartoon Network's anime and action cartoon block featuring Dragon Ball Z and Gundam Wing.","c":["entertainment"]}, +{"t":"ToonZone.net","u":"http://www.toonzone.net/","d":"Animation news and discussion: cartoons, anime, comic adaptations.","c":["entertainment"]}, +{"t":"Variety.com","u":"http://www.variety.com/","d":"Entertainment industry trade publication: film, television, music, digital media.","c":["entertainment"]}, +{"t":"WebcomicsList.com","u":"http://www.webcomiclist.com/","d":"A directory of webcomics, with daily update tracking and reviews.","c":["entertainment"]}, +{"t":"XKCD","u":"http://www.xkcd.com/","d":"A webcomic of romance, sarcasm, math and language, by Randall Munroe.","c":["entertainment"]}, +{"t":"AwardShows.com","u":"http://www.awardshows.com/","d":"Coverage of the Oscar, Grammy, Emmy and Tony ceremonies, with predictions.","c":["celebrities"]}, +{"t":"CelebNetWorth.com","u":"http://www.celebnetworth.com/","d":"Celebrity net worth estimates with career earnings and endorsement details.","c":["celebrities"]}, +{"t":"Diva Central Online","u":"http://www.divacentral.com/","d":"Fan community devoted to Diva with galleries and biographies.","c":["celebrities"]}, +{"t":"E! Online","u":"https://www.eonline.com/","d":"Celebrity news and gossip, photo galleries, pop culture coverage.","c":["celebrities"]}, +{"t":"FamousQuotes.com","u":"http://www.famousquotes.com/","d":"Celebrity and historical figure quotation database with attribution and context.","c":["celebrities"]}, +{"t":"Hollywood.com","u":"http://www.hollywood.com/","d":"Entertainment portal: movie news, celebrity profiles, showtimes, ticket purchasing.","c":["celebrities"]}, +{"t":"IMDB Star Meter","u":"http://www.imdb.com/starmeter/","d":"Internet Movie Database's popularity rankings for actors and other entertainment professionals.","c":["celebrities"]}, +{"t":"JustJared.com","u":"http://www.justjared.com/","d":"Celebrity photos and entertainment news updated throughout the day.","c":["celebrities"]}, +{"t":"Mr. Showbiz","u":"https://www.mrshowbiz.com/","d":"Celebrity profiles, entertainment news and show business coverage from ABC News.","c":["celebrities"]}, +{"t":"People.com","u":"https://www.people.com/","d":"Celebrity news, interviews, photo galleries and human interest stories from People magazine.","c":["celebrities"]}, +{"t":"PerezHilton.com","u":"http://www.perezhilton.com/","d":"Celebrity gossip blog famous for hand-drawn annotations on paparazzi photos.","c":["celebrities"]}, +{"t":"StarInterviews.com","u":"http://www.starinterviews.com/","d":"Celebrity interview archive with video clips and transcript highlights.","c":["celebrities"]}, +{"t":"StarMaps.com","u":"http://www.starmaps.com/","d":"Celebrity home location maps for Los Angeles and Hollywood tourism.","c":["celebrities"]}, +{"t":"TheSuperficial.com","u":"http://www.thesuperficial.com/","d":"Celebrity gossip and pop culture commentary with a comedic irreverent tone.","c":["celebrities"]}, +{"t":"TMZ Celebrity","u":"https://www.tmz.com/","d":"Celebrity gossip, photos and breaking entertainment news, updated around the clock.","c":["celebrities"]}, +{"t":"Us Weekly","u":"https://www.usmagazine.com/","d":"Celebrity news, fashion, beauty and exclusive photos from Us Weekly.","c":["celebrities"]}, +{"t":"Daily Show with Jon Stewart","u":"https://www.comedycentral.com/shows/the-daily-show/","d":"Satirical news program clips, interviews, correspondent segments.","c":["television"]}, +{"t":"EpisodeGuides.com","u":"http://www.epguides.com/","d":"Episode listings for TV shows, with air dates, titles and production codes.","c":["television"]}, +{"t":"Hulu.com (preview)","u":"http://www.hulu.com/","d":"Free TV streaming from Fox, NBC and ABC, with full episodes.","c":["television"]}, +{"t":"Jump the Shark","u":"https://www.jumptheshark.com/","d":"Fan-voted database tracking the exact moment TV shows went downhill.","c":["television"]}, +{"t":"NickelodeonOnline.com","u":"http://www.nickelodeon.com/","d":"Nickelodeon shows, games and videos for kids.","c":["television"]}, +{"t":"PilotSeason.com","u":"http://www.pilotseason.com/","d":"TV pilot season coverage with development updates and casting news.","c":["television"]}, +{"t":"Reality TV World","u":"http://www.realitytvworld.com/","d":"Reality television news, recaps and spoilers for every major show.","c":["television"]}, +{"t":"RealityTVCentral","u":"http://www.realitytvcentral.com/","d":"Entertainment portal devoted to Reality TV for dedicated viewers.","c":["television"]}, +{"t":"Sci-Fi Channel","u":"https://www.scifi.com/","d":"Science fiction TV network site. Show info, original web content, fan forums.","c":["television"]}, +{"t":"SitcomsOnline","u":"http://www.sitcomsonline.com/","d":"Classic and current sitcom information with episode guides and DVD reviews.","c":["television"]}, +{"t":"SitcomWriters.com","u":"http://www.sitcomwriters.com/","d":"Sitcom writing resource with spec script guides and showrunner interviews.","c":["television"]}, +{"t":"SoapCentral.com","u":"http://www.soapcentral.com/","d":"Daytime soap opera news, recaps, spoilers, character profiles.","c":["television"]}, +{"t":"StreamingGuide.com","u":"http://www.streamingguide.com/","d":"Early streaming video service guide with content catalogs and pricing.","c":["television"]}, +{"t":"The Futon Critic","u":"http://www.thefutoncritic.com/","d":"Television industry news: scheduling, ratings, development updates.","c":["television"]}, +{"t":"TV Guide Online","u":"https://www.tvguide.com/","d":"The TV Guide brand online: television listings, show reviews, news.","c":["television"]}, +{"t":"TVListings.net","u":"http://www.tvlistings.net/","d":"TV schedule listings and episode guides for all networks.","c":["television"]}, +{"t":"TVPilots.com","u":"http://www.tvpilots.com/","d":"Unaired TV pilot archive with summaries and development stories.","c":["television"]}, +{"t":"TVTattle.com","u":"http://www.tvtattle.com/","d":"Television news aggregator collecting entertainment headlines from media sources.","c":["television"]}, +{"t":"TVThemeSongs.com","u":"http://www.tvthemesongs.com/","d":"A television theme song archive: lyrics, clips, trivia.","c":["television"]}, +{"t":"TVTropes.org","u":"http://www.tvtropes.org/","d":"Wiki cataloging storytelling tropes and conventions across all media.","c":["television"]}, +{"t":"TVWritersVault.com","u":"http://www.tvwritersvault.com/","d":"Television script submission platform connecting writers with producers.","c":["television"]}, +{"t":"WB.com","u":"https://www.thewb.com/","d":"The WB Network. Show info and fan community for Buffy, Dawson's Creek and Charmed.","c":["television"]}, +{"t":"WebSeries.com","u":"http://www.webseries.com/","d":"Web series directory with early internet video productions.","c":["television"]}, +{"t":"1UP.com","u":"https://www.1up.com/","d":"Video game news, reviews and podcasts from Ziff Davis's gaming team.","c":["games"]}, +{"t":"3D Gamers","u":"https://www.3dgamers.com/","d":"Fast game file downloads: demos, patches, mods. No waiting.","c":["games"]}, +{"t":"AddictingGames","u":"https://www.addictinggames.com/","d":"Hundreds of free online Flash games: action, puzzle, sports. No download needed.","c":["games"]}, +{"t":"Adrenaline Vault","u":"http://www.avault.com/","d":"PC game reviews with a focus on hardware and gaming performance optimization.","c":["games"]}, +{"t":"AdventureVault","u":"http://www.adventurevault.com/","d":"For adventure gamers: tips, news, discussion boards.","c":["games"]}, +{"t":"ArcadeHistory.com","u":"http://www.arcadehistory.com/","d":"Coin-operated arcade game history database with photos and PCB information.","c":["games"]}, +{"t":"ArcadeTown.com","u":"http://www.arcadetown.com/","d":"Free downloadable and online casual games for Windows users.","c":["games"]}, +{"t":"ArcadeVault","u":"http://www.arcadevault.net/","d":"Arcade gamers: tips, news, discussion boards.","c":["games"]}, +{"t":"Armor Games","u":"https://www.armorgames.com/","d":"Free Flash games in your browser: tower defense, RPGs, puzzles.","c":["games"]}, +{"t":"Battle.net","u":"https://www.battle.net/","d":"Blizzard's multiplayer gaming service: StarCraft, Diablo II, Warcraft III.","c":["games"]}, +{"t":"BigFishGames.com","u":"http://www.bigfishgames.com/","d":"Casual game download service with a new game every day and unlimited free trials.","c":["games"]}, +{"t":"Blue's News","u":"https://www.bluesnews.com/","d":"PC gaming news aggregator. FPS games, hardware and the mod community since 1996.","c":["games"]}, +{"t":"BoardGameGeek.com","u":"http://www.boardgamegeek.com/","d":"A board game database: reviews, ratings, forums for tabletop enthusiasts.","c":["games"]}, +{"t":"BrowserGamesHub.com","u":"http://www.browsergameshub.com/","d":"Free browser-based games directory with Java and Flash titles.","c":["games"]}, +{"t":"Chess.com","u":"https://www.chess.com/","d":"Play and learn chess online.","c":["games"]}, +{"t":"ClassicDOSGames.com","u":"http://www.classicdosgames.com/","d":"Abandonware and classic DOS game archive with DOSBox compatibility guides.","c":["games"]}, +{"t":"Deja.com","u":"https://www.deja.com/","d":"Usenet newsgroup archive and product rating community. Later acquired by Google.","c":["games"]}, +{"t":"EmulatorZone.com","u":"http://www.emulatorzone.com/","d":"Video game emulator downloads and compatibility guides for retro gaming.","c":["games"]}, +{"t":"Emuparadise.org","u":"http://www.emuparadise.org/","d":"Retro game ROM and emulator download archive for classic gaming systems.","c":["games"]}, +{"t":"Epinions","u":"https://www.epinions.com/","d":"Consumer product and entertainment reviews written by real users, covering games and media.","c":["games"]}, +{"t":"FilePlanet","u":"https://www.fileplanet.com/","d":"Game demo, patch and mod downloads from the GameSpy network.","c":["games"]}, +{"t":"FlashGameArchive.com","u":"http://www.flashgamearchive.com/","d":"Archive of classic Flash games organized by genre with player ratings.","c":["games"]}, +{"t":"FreeArcade.com","u":"http://www.freearcade.com/","d":"Collection of free online Flash arcade games and classic remakes.","c":["games"]}, +{"t":"GameCheatCodes.com","u":"http://www.gamecheatcodes.com/","d":"Video game cheat codes and walkthroughs for all platforms.","c":["games"]}, +{"t":"GameDevNet.com","u":"http://www.gamedevnet.com/","d":"Game development community with programming tutorials and asset creation guides.","c":["games"]}, +{"t":"GameHouse.com","u":"http://www.gamehouse.com/","d":"Casual game downloads and online games for family-friendly entertainment.","c":["games"]}, +{"t":"GameOST.com","u":"http://www.gameost.com/","d":"Video game original soundtrack reviews and tracklist databases.","c":["games"]}, +{"t":"GamePreservation.org","u":"http://www.gamepreservation.org/","d":"Video game preservation and archival efforts for digital heritage.","c":["games"]}, +{"t":"GameRankings","u":"https://www.gamerankings.com/","d":"Aggregate review scores for video games across all platforms and publications.","c":["games"]}, +{"t":"GameSoundtrack.com","u":"http://www.gamesoundtrack.com/","d":"Video game music and soundtrack reviews with streaming and download links.","c":["games"]}, +{"t":"GameSpot","u":"https://www.gamespot.com/","d":"Video game reviews, previews, news and cheats, across PC and all major consoles.","c":["games"]}, +{"t":"GameSpy","u":"https://www.gamespy.com/","d":"PC gaming news and reviews, plus the GameSpy multiplayer matchmaking network.","c":["games"]}, +{"t":"GameTrailers.com","u":"http://www.gametrailers.com/","d":"YouTube channel from IGN for video game trailers, including gameplay, cinematics, announcements and reveals.","c":["games"]}, +{"t":"GamingConventions.com","u":"http://www.gamingconventions.com/","d":"A gaming convention directory: E3, PAX, local events.","c":["games"]}, +{"t":"GamingTournaments.com","u":"http://www.gamingtournaments.com/","d":"Gaming tournament listings: registration, rules, prize information.","c":["games"]}, +{"t":"IGN","u":"https://www.ign.com/","d":"Gaming coverage for PC and every major console: reviews, walkthroughs, news.","c":["games"]}, +{"t":"IndieGameDev.com","u":"http://www.indiegamedev.com/","d":"An indie game development resource: tools, tutorials, showcases.","c":["games"]}, +{"t":"IndieGaming.com","u":"http://www.indiegaming.com/","d":"Independent game development news with reviews and developer interviews.","c":["games"]}, +{"t":"Kongregate","u":"https://www.kongregate.com/","d":"Free browser-based games with achievements, chat and a huge community of players.","c":["games"]}, +{"t":"LAN Party Guide","u":"http://www.lanpartyguide.com/","d":"Guide to organizing and attending LAN parties with hardware and networking tips.","c":["games"]}, +{"t":"Miniclip","u":"https://www.miniclip.com/","d":"International digital games company that develops, publishes and distributes multiplayer mobile games.","c":["games"]}, +{"t":"MobyGames","u":"http://www.mobygames.com/","d":"A game documentation database: credits, screenshots and cover art for every platform.","c":["games"]}, +{"t":"ModDB.com","u":"http://www.moddb.com/","d":"A game modification database: downloads, tutorials, developer tools.","c":["games"]}, +{"t":"MPlayer","u":"https://www.mplayer.com/","d":"Free multiplayer gaming with matchmaking for board and card games.","c":["games"]}, +{"t":"Neopets","u":"https://www.neopets.com/","d":"Virtual pet community: games, trading, exploration. Addictive, at any age.","c":["games"]}, +{"t":"Newgrounds Games","u":"https://www.newgrounds.com/games/","d":"User-created Flash games spanning every genre from action to puzzles to RPGs.","c":["games"]}, +{"t":"NintendoZone","u":"http://www.nintendozone.com/","d":"Nintendo's official US site for Switch consoles, games, news and merchandise.","c":["games"]}, +{"t":"PC Gaming Zone Online","u":"http://www.pcgamingzone.com/","d":"Resource celebrating PC Gaming with rankings and retrospectives.","c":["games"]}, +{"t":"PixelArtGallery.com","u":"http://www.pixelartgallery.com/","d":"Pixel art and sprite art gallery with tutorials and artist showcases.","c":["games"]}, +{"t":"Planet Quake","u":"https://www.planetquake.com/","d":"Quake community hub: mods, maps, news, multiplayer server listings.","c":["games"]}, +{"t":"Planet Unreal","u":"https://www.planetunreal.com/","d":"Unreal Tournament community: maps, mods, skins, multiplayer servers.","c":["games"]}, +{"t":"Pogo.com","u":"https://www.pogo.com/","d":"Free online casual games: board games, card games, word puzzles.","c":["games"]}, +{"t":"PopCap Games","u":"https://www.popcap.com/","d":"EA studio page for PopCap, which makes casual games for mobile, console and PC.","c":["games"]}, +{"t":"RetroConsoles.com","u":"http://www.retroconsoles.com/","d":"Retro gaming console guide with collecting tips and hardware maintenance.","c":["games"]}, +{"t":"RolePlayingGames.net","u":"http://www.roleplayinggames.net/","d":"Free message board community for running text-based roleplaying games under any rule system.","c":["games"]}, +{"t":"Runescape","u":"https://www.runescape.com/","d":"Hub for RuneScape, Old School RuneScape and the co-op survival game Dragonwilds, all set in the RuneScape world.","c":["games"]}, +{"t":"Runevillage","u":"https://www.runevillage.com/","d":"Runescape fan site: guides, quest walkthroughs, skill calculators.","c":["games"]}, +{"t":"Shacknews","u":"http://www.shacknews.com/","d":"PC gaming news and active chatty community forum for discussing games and technology.","c":["games"]}, +{"t":"Shockwave.com","u":"https://www.shockwave.com/","d":"Free online Flash and Shockwave games. Browser-based casual gaming.","c":["games"]}, +{"t":"SpeedRunsLive.com","u":"http://www.speedrunslive.com/","d":"A speedrunning community: leaderboards, live races, technique guides.","c":["games"]}, +{"t":"Stealth Vault Online","u":"http://www.stealthvault.net/","c":["games"]}, +{"t":"Steam","u":"https://store.steampowered.com/","d":"The destination for playing PC games.","c":["games"]}, +{"t":"StrategyGamer.com","u":"http://www.strategygamer.com/","d":"Strategy game reviews and guides: RTS, turn-based, grand strategy.","c":["games"]}, +{"t":"SurvivalHorrorVault","u":"http://www.survivalhorrorvault.com/","d":"Survival horror titles, with reviews, cheats and walkthroughs.","c":["games"]}, +{"t":"TextMUD.com","u":"http://www.textmud.com/","d":"MUD and text-based multiplayer game directory with connection guides.","c":["games"]}, +{"t":"The Adventure Network","u":"http://www.adventurenetwork.com/","d":"A resource for adventure gamers: tips, news, boards.","c":["games"]}, +{"t":"The Fighting HQ","u":"http://www.fightinghq.com/","d":"A fighting game community: mods, patches, strategy guides.","c":["games"]}, +{"t":"The Game Cheats Zone","u":"http://www.gamecheatszone.com/","d":"A gaming site covering cheats: reviews, news, screenshots.","c":["games"]}, +{"t":"The Stealth HQ","u":"http://www.stealthhq.com/","c":["games"]}, +{"t":"The Tycoon HQ","u":"http://www.tycoonhq.com/","d":"Tycoon games archived: reviews, screenshots, demos.","c":["games"]}, +{"t":"TriviaQuiz.com","u":"http://www.triviaquiz.com/","d":"Free trivia quizzes and brain teasers: movies, music, history, science.","c":["games"]}, +{"t":"Tycoon Vault Online","u":"http://www.tycoonvault.com/","d":"An archive of tycoon game reviews, screenshots and demos.","c":["games"]}, +{"t":"Warcraft III Realm","u":"https://war3.replays.net/","d":"Warcraft III strategy guides, replay downloads, competitive gaming community.","c":["games"]}, +{"t":"Wild Tangent","u":"http://www.wildtangent.com/","d":"Casual game platform bundled with PCs offering free and premium game downloads.","c":["games"]}, +{"t":"WordGames.com","u":"http://www.wordgames.com/","d":"Free online word games: crosswords, word searches, anagrams.","c":["games"]}, +{"t":"Zone.com","u":"https://zone.msn.com/","d":"Microsoft's free online gaming zone: card games, puzzles, multiplayer matches.","c":["games"]}, +{"t":"BuildingPermits.com","u":"http://www.buildingpermits.com/","d":"Building permit information by jurisdiction with application procedures.","c":["government"]}, +{"t":"California.gov","u":"https://www.ca.gov/","d":"Official portal of the State of California with government services and public information.","c":["government"]}, +{"t":"CityMayors.com","u":"http://www.citymayors.com/","d":"An international resource on city governments, mayors and urban affairs.","c":["government"]}, +{"t":"ConstructionLaw.net","u":"http://www.constructionlaw.net/","d":"Construction law resource with contract disputes and lien information.","c":["government"]}, +{"t":"ContractTemplates.com","u":"http://www.contracttemplates.com/","d":"Free legal contract templates: business, rental, employment.","c":["government"]}, +{"t":"Cornell Legal Information Institute","u":"https://www.law.cornell.edu/","d":"Free access to Supreme Court opinions, the U.S. Code and legal encyclopedias, from Cornell Law School.","c":["government"]}, +{"t":"CountyGovernment.com","u":"http://www.countygovernment.com/","d":"County government service directory with elected official contacts.","c":["government"]}, +{"t":"CourtTV.com","u":"http://www.courttv.com/","d":"Court Television network's online hub with trial coverage and legal commentary.","c":["government"]}, +{"t":"EmploymentLaw.com","u":"http://www.employmentlaw.com/","d":"Employment and labor law resource with workplace rights and regulations.","c":["government"]}, +{"t":"FindLaw","u":"https://www.findlaw.com/","d":"Legal research, case law lookup and an attorney directory, for consumers and professionals.","c":["government"]}, +{"t":"Florida MyFlorida","u":"https://www.myflorida.com/","d":"Official State of Florida portal with government services and visitor information.","c":["government"]}, +{"t":"FreeLegalAdvice.com","u":"http://www.freelegaladvice.com/","d":"AI legal research assistant that answers U.S. legal questions with citations to statutes, regulations and court decisions.","c":["government"]}, +{"t":"Georgia.gov","u":"https://www.georgia.gov/","d":"The State of Georgia's official portal: government services, licensing, business tools.","c":["government"]}, +{"t":"GFOA.org","u":"http://www.gfoa.org/","d":"Government Finance Officers Association promoting excellence in public finance management.","c":["government"]}, +{"t":"Governing.com","u":"http://www.governing.com/","d":"News and analysis for state and local government leaders.","c":["government"]}, +{"t":"ICMA.org","u":"http://www.icma.org/","d":"International City/County Management Association for local government professionals.","c":["government"]}, +{"t":"Illinois.gov","u":"https://www.illinois.gov/","d":"State of Illinois government portal with online services and agency directories.","c":["government"]}, +{"t":"ImmigrationLaw.com","u":"http://www.immigrationlaw.com/","d":"An immigration law resource: visa categories, green cards, citizenship.","c":["government"]}, +{"t":"IntellectualProperty.com","u":"http://www.intellectualproperty.com/","d":"Patent, trademark and copyright information for inventors and creators.","c":["government"]}, +{"t":"JuriSearch","u":"http://www.jurisearch.com/","d":"Legal research portal, linking to statutes, case law and reference materials.","c":["government"]}, +{"t":"JuryDuty.com","u":"http://www.juryduty.com/","d":"Jury duty information with state-specific exemptions and procedures.","c":["government"]}, +{"t":"JusticeNet","u":"https://www.justice.gov/","d":"The U.S. Department of Justice. Federal law enforcement, legal resources, civil rights information.","c":["government"]}, +{"t":"LegalAid.net","u":"http://www.legalaid.net/","d":"Free legal aid directory connecting low-income individuals with pro bono attorneys.","c":["government"]}, +{"t":"LegalAidDirectory","u":"http://www.legalaid.org/","d":"Directory of free legal aid services for low-income individuals by state.","c":["government"]}, +{"t":"LegalZoom","u":"https://www.legalzoom.com/","d":"Online legal documents: wills, incorporations, trademarks.","c":["government"]}, +{"t":"LexisNexis","u":"https://www.lexisnexis.com/","d":"Legal research database for attorneys: case law, statutes, legal news.","c":["government"]}, +{"t":"LocalElections.com","u":"http://www.localelections.com/","d":"Local election information with candidate profiles and ballot measures.","c":["government"]}, +{"t":"Martindale-Hubbell","u":"http://www.martindale.com/","d":"Attorney directory with peer-reviewed ratings and practice area information.","c":["government"]}, +{"t":"Michigan.gov","u":"https://www.michigan.gov/","d":"The State of Michigan services portal: licensing, DMV, public records.","c":["government"]}, +{"t":"NCSL.org","u":"http://www.ncsl.org/","d":"National Conference of State Legislatures with research and resources for lawmakers.","c":["government"]}, +{"t":"NGA.org","u":"http://www.nga.org/","d":"The National Governors Association: policy positions, best practices, state news.","c":["government"]}, +{"t":"NLC.org","u":"http://www.nlc.org/","d":"National League of Cities advocating for municipal interests in Washington.","c":["government"]}, +{"t":"NY.gov","u":"https://www.ny.gov/","d":"New York State's official portal: government services, DMV, public assistance.","c":["government"]}, +{"t":"Ohio.gov","u":"https://ohio.gov/","d":"State of Ohio official portal with online government services and information.","c":["government"]}, +{"t":"Oyez","u":"https://www.oyez.org/","d":"U.S. Supreme Court audio recordings, case summaries, justice biographies.","c":["government"]}, +{"t":"Pennsylvania.gov","u":"https://www.state.pa.us/","d":"Commonwealth of Pennsylvania government portal with services and agency information.","c":["government"]}, +{"t":"PropertyRecords.com","u":"http://www.propertyrecords.com/","d":"Property record search by county with ownership and assessment data.","c":["government"]}, +{"t":"PropertyTaxGuide","u":"http://www.propertytaxguide.org/","d":"Guide to Property Tax with contact information and online services.","c":["government"]}, +{"t":"PublicLibraries.com","u":"http://www.publiclibraries.com/","d":"A public library directory: branch locations, hours, catalog links.","c":["government"]}, +{"t":"Route Fifty","u":"http://www.routefifty.com/","d":"News on state and local government management, technology and finance.","c":["government"]}, +{"t":"SchoolDistricts.com","u":"http://www.schooldistricts.com/","d":"School district directory with superintendent contacts and budget data.","c":["government"]}, +{"t":"SmallClaimsCourt.net","u":"http://www.smallclaimscourt.net/","d":"Small claims court guide with state-specific limits and filing procedures.","c":["government"]}, +{"t":"SmallClaimsHelp.com","u":"http://www.smallclaimshelp.com/","d":"Small claims court filing guide with forms and limits by state.","c":["government"]}, +{"t":"State and Local Govt on Net","u":"http://www.statelocalgov.net/","d":"A directory of state, county and city government websites across all 50 states.","c":["government"]}, +{"t":"StateCapitolReport","u":"http://www.statecapitolreport.com/","d":"Nonpartisan news service covering state legislature sessions and policy trends.","c":["government"]}, +{"t":"StateLaw.com","u":"http://www.statelaw.com/","d":"State law code lookup with searchable statutes by jurisdiction.","c":["government"]}, +{"t":"StateLine.org","u":"http://www.stateline.org/","d":"News site covering state and local policy stories from across the country, with a weekly newsletter.","c":["government"]}, +{"t":"StateParks.com","u":"http://www.stateparks.com/","d":"State park directory with campground reservations and trail information.","c":["government"]}, +{"t":"SupremeCourt.gov","u":"http://www.supremecourt.gov/","d":"The official US Supreme Court website: opinions, schedules, case information.","c":["government"]}, +{"t":"TenantRights.org","u":"http://www.tenantrights.org/","d":"Tenant rights resource with state-specific landlord-tenant law summaries.","c":["government"]}, +{"t":"Texas.gov","u":"https://www.texas.gov/","d":"The official website of the State of Texas. State services, agencies, public records.","c":["government"]}, +{"t":"The DMV Guide","u":"http://www.dmvguide.us/","d":"A DMV reference: forms, schedules, helpful links.","c":["government"]}, +{"t":"TrafficCourt.com","u":"http://www.trafficcourt.com/","d":"Traffic ticket fighting guide with state-specific procedures.","c":["government"]}, +{"t":"TrafficLaw.com","u":"http://www.trafficlaw.com/","d":"Traffic violation and DUI law resource with state-specific information.","c":["government"]}, +{"t":"Virginia.gov","u":"https://www.virginia.gov/","d":"Commonwealth of Virginia official portal with state government services and tourism.","c":["government"]}, +{"t":"Westlaw","u":"http://www.westlaw.com/","d":"Thomson Reuters legal research platform with case law and legal analytics.","c":["government"]}, +{"t":"Amnesty International","u":"https://www.amnesty.org/","d":"Global human rights organization campaigning against injustice and abuse worldwide.","c":["international-organizations"]}, +{"t":"European Union","u":"http://www.europa.eu/","d":"The official EU website: legislation, statistics, information on member institutions.","c":["international-organizations"]}, +{"t":"GlobalGovernance.org","u":"http://www.globalgovernance.org/","d":"International governance research with policy analysis and reform proposals.","c":["international-organizations"]}, +{"t":"ICANN.org","u":"http://www.icann.org/","d":"Internet Corporation for Assigned Names and Numbers managing domain name system.","c":["international-organizations"]}, +{"t":"IMF","u":"https://www.imf.org/","d":"The International Monetary Fund: global economic data, country reports, financial stability analysis.","c":["international-organizations"]}, +{"t":"International Red Cross","u":"https://www.icrc.org/","d":"International humanitarian organization providing protection and aid during armed conflicts.","c":["international-organizations"]}, +{"t":"InternationalLaw.net","u":"http://www.internationallaw.net/","d":"An international law resource: treaties, conventions, legal analysis.","c":["international-organizations"]}, +{"t":"NATO","u":"https://www.nato.int/","d":"The North Atlantic Treaty Organization. Security news, policy documents, member nation info.","c":["international-organizations"]}, +{"t":"OECD","u":"http://www.oecd.org/","d":"Organisation for Economic Co-operation and Development promoting economic progress.","c":["international-organizations"]}, +{"t":"PeaceCorps.gov","u":"http://www.peacecorps.gov/","d":"US Peace Corps volunteer opportunities in 60+ countries worldwide.","c":["international-organizations"]}, +{"t":"UNESCO","u":"http://www.unesco.org/","d":"United Nations Educational, Scientific and Cultural Organization promoting international cooperation.","c":["international-organizations"]}, +{"t":"UNHabitat.org","u":"http://www.unhabitat.org/","d":"United Nations programme working toward sustainable urban development.","c":["international-organizations"]}, +{"t":"UNICEF.org","u":"http://www.unicef.org/","d":"United Nations Children's Fund protecting children's rights worldwide.","c":["international-organizations"]}, +{"t":"United Nations","u":"https://www.un.org/","d":"Official site of the international organization for world peace, cooperation and development.","c":["international-organizations"]}, +{"t":"WHO","u":"https://www.who.int/","d":"The World Health Organization. Global health data, outbreak news, health statistics.","c":["international-organizations"]}, +{"t":"World Bank","u":"https://www.worldbank.org/","d":"International financial institution providing loans and grants to developing countries.","c":["international-organizations"]}, +{"t":"Air Force Link","u":"https://www.af.mil/","d":"The official United States Air Force website. News, career information, heritage.","c":["military"]}, +{"t":"AirForce.com","u":"http://www.airforce.com/","d":"Official US Air Force recruiting website with career information and benefits.","c":["military"]}, +{"t":"AmericanLegion.org","u":"http://www.legion.org/","d":"American Legion veteran service organization with member benefits and advocacy.","c":["military"]}, +{"t":"Army Times","u":"http://www.armytimes.com/","d":"Independent news for US Army soldiers: benefits, policy, operations.","c":["military"]}, +{"t":"Army.mil","u":"https://www.army.mil/","d":"Official website of the United States Army. News, career information, service details.","c":["military"]}, +{"t":"Department of Defense","u":"https://www.defense.gov/","d":"Official website of the United States Department of Defense.","c":["military"]}, +{"t":"GlobalSecurity.org","u":"http://www.globalsecurity.org/","d":"A military and security encyclopedia: weapons systems, bases, intelligence resources.","c":["military"]}, +{"t":"JAG Corps","u":"https://www.goarmy.com/jag/","d":"Judge Advocate General's Corps information and military legal careers.","c":["military"]}, +{"t":"Marines.com","u":"http://www.marines.com/","d":"Official US Marine Corps recruiting site with career opportunities and core values.","c":["military"]}, +{"t":"Military.com","u":"https://www.military.com/","d":"Benefits information, career resources and news for military personnel and veterans.","c":["military"]}, +{"t":"MilitaryBases.com","u":"http://www.militarybases.com/","d":"Military installation directory with base guides and community resources.","c":["military"]}, +{"t":"MilitaryDiscount.com","u":"http://www.militarydiscount.com/","d":"Directory of military discounts from retailers and service providers.","c":["military"]}, +{"t":"MilitaryHistory.org","u":"http://www.militaryhistory.org/","d":"A military history resource: battle accounts, weapon systems, veteran stories.","c":["military"]}, +{"t":"MilitaryOneSource","u":"http://www.militaryonesource.mil/","d":"Free support for military members and families: relocation, education, counseling.","c":["military"]}, +{"t":"MilitaryPay.com","u":"http://www.militarypay.com/","d":"Military pay scales and benefits calculator with retirement planning.","c":["military"]}, +{"t":"MilitarySchools.com","u":"http://www.militaryschools.com/","d":"Military academy and school directory with admission requirements.","c":["military"]}, +{"t":"MilitarySpot.com","u":"http://www.militaryspot.com/","d":"Information portal for military families covering benefits and base locations.","c":["military"]}, +{"t":"NationalGuard.com","u":"http://www.nationalguard.com/","d":"National Guard recruitment with benefits and training information.","c":["military"]}, +{"t":"Navy.mil","u":"https://www.navy.mil/","d":"Official website of the United States Navy with fleet information and service opportunities.","c":["military"]}, +{"t":"VeteranEmployment.com","u":"http://www.veteranemployment.com/","d":"Job board and career transition resource for military veterans.","c":["military"]}, +{"t":"VeteransResource","u":"http://www.veteransresource.org/","d":"A reference on veterans: history, news, support information.","c":["military"]}, +{"t":"VFW.org","u":"http://www.vfw.org/","d":"Veterans of Foreign Wars organization providing advocacy and support for veterans.","c":["military"]}, +{"t":"WarMemorials.org","u":"http://www.warmemorials.org/","d":"A war memorial directory: photos, histories, visitor information.","c":["military"]}, +{"t":"C-SPAN","u":"https://www.c-span.org/","d":"Live and archived coverage of the U.S. Congress and public affairs programming.","c":["politics"]}, +{"t":"CampaignFinance.org","u":"http://www.campaignfinance.org/","d":"Campaign contribution tracking with donor data and spending analysis.","c":["politics"]}, +{"t":"CampaignMoney.org","u":"http://www.campaignmoney.org/","d":"Nonpartisan tracker of campaign finance and political action committee spending.","c":["politics"]}, +{"t":"CivicEducation.org","u":"http://www.civiceducation.org/","d":"Civic education resource with lesson plans and student engagement programs.","c":["politics"]}, +{"t":"Drudge Report","u":"https://www.drudgereport.com/","d":"Matt Drudge's news aggregation site: breaking stories before traditional media since 1998.","c":["politics","online-media"]}, +{"t":"FactCheck.org","u":"https://www.factcheck.org/","d":"Nonpartisan project of the Annenberg Public Policy Center monitoring the accuracy of political claims.","c":["politics"]}, +{"t":"JudicialWatch.org","u":"http://www.judicialwatch.org/","d":"Government transparency organization using FOIA requests to investigate corruption.","c":["politics"]}, +{"t":"League of Women Voters","u":"https://www.lwv.org/","d":"Nonpartisan voter education, registration drives and election information.","c":["politics"]}, +{"t":"LobbyingData.com","u":"http://www.lobbyingdata.com/","d":"Lobbying disclosure database with spending data and lobbyist registrations.","c":["politics"]}, +{"t":"LobbyistDirectory.com","u":"http://www.lobbyistdirectory.com/","d":"Registered lobbyist database with client lists and spending data.","c":["politics"]}, +{"t":"OpenSecrets","u":"https://www.opensecrets.org/","d":"Center for Responsive Politics tracking money in U.S. politics and campaign contributions.","c":["politics"]}, +{"t":"PolicyAnalysis.com","u":"http://www.policyanalysis.com/","d":"Non-partisan policy analysis, with research papers and commentary.","c":["politics"]}, +{"t":"PoliticalMoneyLine","u":"https://www.tray.com/","d":"Campaign finance data, lobbying records, political committee information.","c":["politics"]}, +{"t":"Politico.com","u":"http://www.politico.com/","d":"Political journalism: Congress, the White House, campaigns, policy.","c":["politics"]}, +{"t":"PolitiFact","u":"http://www.politifact.com/","d":"Fact-checking website rating the accuracy of claims by political figures.","c":["politics"]}, +{"t":"PrimaryElections.com","u":"http://www.primaryelections.com/","d":"Presidential primary election calendar with delegate counts.","c":["politics"]}, +{"t":"Project VoteSmart","u":"http://www.votesmart.org/","d":"Non-partisan political information resource with voting records and candidate backgrounds.","c":["politics"]}, +{"t":"RealClearPolitics","u":"http://www.realclearpolitics.com/","d":"Political news and opinion aggregator with polling data and election analysis.","c":["politics"]}, +{"t":"Rock the Vote","u":"https://www.rockthevote.com/","d":"Voter registration, civic engagement and election information, aimed at young Americans.","c":["politics"]}, +{"t":"VoteByMail.com","u":"http://www.votebymail.com/","d":"Absentee and mail-in voting guide with state-specific deadlines.","c":["politics"]}, +{"t":"VoterRegistration.org","u":"http://www.voterregistration.org/","d":"Non-partisan voter registration assistance with state-specific forms and deadlines.","c":["politics"]}, +{"t":"CDC.gov","u":"https://www.cdc.gov/","d":"Centers for Disease Control and Prevention with public health information and disease data.","c":["us-federal"]}, +{"t":"Congress.gov","u":"https://www.congress.gov/","d":"Search and track U.S. legislative information and current bill status.","c":["us-federal"]}, +{"t":"CongressionalRecord.com","u":"http://www.congressionalrecord.com/","d":"Congressional Record search with floor debates and legislative actions.","c":["us-federal"]}, +{"t":"EPA.gov","u":"https://www.epa.gov/","d":"The Environmental Protection Agency: regulations, environmental data, compliance information.","c":["us-federal"]}, +{"t":"FBI.gov","u":"https://www.fbi.gov/","d":"The Federal Bureau of Investigation. Most wanted lists, crime statistics, public safety info.","c":["us-federal"]}, +{"t":"FCC.gov","u":"http://www.fcc.gov/","d":"Federal Communications Commission regulating telecommunications and broadcasting.","c":["us-federal"]}, +{"t":"FederalContracts.com","u":"http://www.federalcontracts.com/","d":"Government contracting resource with bid opportunities and procurement guides.","c":["us-federal"]}, +{"t":"FEMA","u":"https://www.fema.gov/","d":"Federal Emergency Management Agency with disaster preparedness and relief information.","c":["us-federal"]}, +{"t":"FTC.gov","u":"http://www.ftc.gov/","d":"Federal Trade Commission protecting consumers from unfair business practices.","c":["us-federal"]}, +{"t":"GovBenefits.com","u":"http://www.govbenefits.com/","d":"Federal and state benefits eligibility screening tool for citizens.","c":["us-federal"]}, +{"t":"GovGrants.com","u":"http://www.govgrants.com/","d":"A federal and state grant directory for businesses, nonprofits and individuals.","c":["us-federal"]}, +{"t":"Grants.gov","u":"http://www.grants.gov/","d":"Federal grant opportunity search and application portal for organizations.","c":["us-federal"]}, +{"t":"IRS.gov","u":"https://www.irs.gov/","d":"The Internal Revenue Service: tax forms, filing information, refund status tracking.","c":["us-federal"]}, +{"t":"NASA","u":"https://www.nasa.gov/","d":"America's space agency. Mission data, imagery and exploration news.","c":["us-federal","astronomy"]}, +{"t":"RegInfo.gov","u":"http://www.reginfo.gov/","d":"Federal regulatory information with proposed and final rule tracking.","c":["us-federal"]}, +{"t":"Regulations.gov","u":"https://www.regulations.gov/","d":"Public portal for commenting on proposed federal regulations and viewing agency rulemaking.","c":["us-federal"]}, +{"t":"SEC.gov","u":"https://www.sec.gov/","d":"The Securities and Exchange Commission: EDGAR filings, investor alerts, regulatory information.","c":["us-federal"]}, +{"t":"SSA.gov","u":"https://www.ssa.gov/","d":"The Social Security Administration: benefits information, applications, retirement planning.","c":["us-federal"]}, +{"t":"State.gov","u":"http://www.state.gov/","d":"The US Department of State: passports, visas, international travel information.","c":["us-federal"]}, +{"t":"TaxFoundation.org","u":"http://www.taxfoundation.org/","d":"Tax policy research organization with data on federal and state tax systems.","c":["us-federal"]}, +{"t":"USA.gov","u":"https://www.usa.gov/","d":"The official gateway to U.S. government websites, services and public information.","c":["us-federal"]}, +{"t":"WhiteHouse.gov","u":"https://www.whitehouse.gov/","d":"Official website of the President of the United States and the White House.","c":["us-federal"]}, +{"t":"AllergyCentral.com","u":"http://www.allergycentral.com/","d":"Allergy information resource with pollen forecasts and treatment options.","c":["health"]}, +{"t":"AllergyDoctor.com","u":"http://www.allergydoctor.com/","d":"Allergist and immunologist finder with allergy testing information.","c":["health"]}, +{"t":"American Cancer Society","u":"https://www.cancer.org/","d":"Cancer information, prevention guidelines, treatment options, patient support services.","c":["health"]}, +{"t":"American Heart Association","u":"http://www.heart.org/","d":"Heart disease and stroke prevention with risk assessments and healthy living resources.","c":["health"]}, +{"t":"American Heart Association","u":"https://www.americanheart.org/","d":"Heart disease and stroke information, prevention tips and healthy living guidelines.","c":["health"]}, +{"t":"Arthritis Foundation","u":"https://www.arthritis.org/","d":"Arthritis information, joint pain management, exercise tips, patient advocacy.","c":["health"]}, +{"t":"Arthritis Resource Online","u":"http://www.arthritisresource.com/","d":"Reference on Arthritis with prevention tips and doctor-reviewed articles.","c":["health"]}, +{"t":"Asthma and Allergy Foundation","u":"https://www.aafa.org/","d":"Asthma and allergy education, research and advocacy from the patient organization.","c":["health"]}, +{"t":"AsthmaResource","u":"http://www.asthmaresource.com/","d":"Guide to living with Asthma for patients and caregivers.","c":["health"]}, +{"t":"Atkins.com","u":"http://www.atkins.com/","d":"Low-carbohydrate diet program with phase-based eating plans and product lines.","c":["health"]}, +{"t":"BackPainRelief.com","u":"http://www.backpainrelief.com/","d":"Product site for the Back Bubble, a back pain relief device supplied with an instruction guide and DVD.","c":["health"]}, +{"t":"CalorieKing","u":"https://www.calorieking.com/","d":"Nutrition data and calorie counting tools for diet tracking.","c":["health"]}, +{"t":"CDC Health Topics A-Z","u":"https://www.cdc.gov/health/","d":"An alphabetical listing of diseases, conditions and health topics from the CDC.","c":["health"]}, +{"t":"ChildHealth.org","u":"http://www.childhealth.org/","d":"Pediatric health resource for parents with growth charts and vaccination schedules.","c":["health"]}, +{"t":"ChronicPainResource","u":"http://www.chronicpainresource.com/","d":"Information portal explaining Chronic Pain in plain language.","c":["health"]}, +{"t":"ClinicalTrials.gov","u":"http://www.clinicaltrials.gov/","d":"Registry of federally and privately supported clinical trials conducted in the US and abroad.","c":["health"]}, +{"t":"CompoundingPharmacy.com","u":"http://www.compoundingpharmacy.com/","d":"Compounding pharmacy directory with specialty medication services.","c":["health"]}, +{"t":"CVS Pharmacy","u":"https://www.cvs.com/","d":"Pharmacy services, prescription refills and health products from America's largest pharmacy chain.","c":["health"]}, +{"t":"DailyStrength.org","u":"http://www.dailystrength.org/","d":"Online support groups for health conditions with community sharing and tracking.","c":["health"]}, +{"t":"Diabetes.org","u":"https://www.diabetes.org/","d":"The American Diabetes Association: management, nutrition, research.","c":["health"]}, +{"t":"DiabetesDaily.com","u":"http://www.diabetesdaily.com/","d":"Daily diabetes management tips: recipes, monitoring, community.","c":["health"]}, +{"t":"Diet.com","u":"https://www.diet.com/","d":"Diet plans, weight loss tools, calorie calculators, nutrition information.","c":["health"]}, +{"t":"Drugs.com","u":"https://www.drugs.com/","d":"Drug information and side effects, with an interaction checker and pill identifier.","c":["health","medicine"]}, +{"t":"DrugStore.com","u":"https://www.drugstore.com","d":"Online pharmacy with health and beauty products. Prescription refills and OTC medications.","c":["health"]}, +{"t":"EatRight.org","u":"http://www.eatright.org/","d":"Academy of Nutrition and Dietetics with food and nutrition information for consumers.","c":["health"]}, +{"t":"eDiets","u":"https://www.ediets.com/","d":"Personalized online diet plans, meal delivery and fitness programs for weight loss.","c":["health"]}, +{"t":"Epilepsy Foundation","u":"https://www.epilepsy.com/","d":"Epilepsy and seizure disorder information, treatment options, community resources.","c":["health"]}, +{"t":"FDA.gov Drug Info","u":"http://www.fda.gov/drugs/","d":"FDA drug safety information, approvals, recalls, medication guides.","c":["health"]}, +{"t":"FitDay","u":"https://www.fitday.com","d":"Free online diet journal and calorie counter. Track nutrition and fitness progress.","c":["health","fitness"]}, +{"t":"GenericDrugs.com","u":"http://www.genericdrugs.com/","d":"Generic drug information with savings calculations versus brand-name options.","c":["health"]}, +{"t":"GoodRx.com","u":"http://www.goodrx.com/","d":"Free prescription discount coupons and pharmacy price comparisons.","c":["health"]}, +{"t":"HearingLoss.com","u":"http://www.hearingloss.com/","d":"Hearing loss information with hearing aid reviews and audiologist finder.","c":["health"]}, +{"t":"HeartHealthGuide.com","u":"http://www.hearthealthguide.com/","d":"Heart disease prevention resource with risk calculators and lifestyle guides.","c":["health"]}, +{"t":"JuicingRecipes.com","u":"http://www.juicingrecipes.com/","d":"Juice and smoothie recipes with nutritional benefits and juicer reviews.","c":["health"]}, +{"t":"KidsHealth","u":"https://www.kidshealth.org/","d":"Doctor-reviewed health information for parents, kids and teens, from the Nemours Foundation.","c":["health"]}, +{"t":"Lupus Foundation","u":"https://www.lupus.org/","d":"Lupus disease information, research updates, patient support resources.","c":["health"]}, +{"t":"Mayo Clinic","u":"https://www.mayoclinic.com/","d":"Medical information on diseases, conditions, symptoms and treatments, from Mayo Clinic.","c":["health","medicine"]}, +{"t":"Mayo Clinic","u":"https://www.mayoclinic.org/","d":"Medical and health information.","c":["health"]}, +{"t":"MedlinePlus","u":"https://medlineplus.gov/","d":"National Library of Medicine's consumer health information with drug and supplement data.","c":["health","medicine"]}, +{"t":"MigraineRelief.com","u":"http://www.migrainerelief.com/","d":"Migraine and headache management with trigger identification and treatments.","c":["health"]}, +{"t":"NeedyMeds.org","u":"http://www.needymeds.org/","d":"Information on patient assistance programs for affordable prescription medications.","c":["health"]}, +{"t":"Nutrition.gov","u":"https://www.nutrition.gov/","d":"The USDA gateway to federal information on food, nutrition and healthy eating.","c":["health"]}, +{"t":"NutritionCalculator.com","u":"http://www.nutritioncalculator.com/","d":"Meal nutrition calculator with macro tracking and dietary analysis tools.","c":["health"]}, +{"t":"NutritionData","u":"https://www.nutritiondata.com/","d":"Nutritional analysis of foods: calorie counts, vitamins, minerals.","c":["health"]}, +{"t":"NutritionFacts.org","u":"http://www.nutritionfacts.org/","d":"Non-profit science-based public service providing free nutrition research videos.","c":["health"]}, +{"t":"OrganicFoodGuide.com","u":"http://www.organicfoodguide.com/","d":"Organic food information with certification standards and shopping guides.","c":["health"]}, +{"t":"PatientLikeMe.com","u":"http://www.patientslikeme.com/","d":"Online health community where patients share experiences and track health outcomes.","c":["health"]}, +{"t":"PDR.net","u":"https://www.pdr.net/","d":"Online Physicians' Desk Reference with FDA-approved drug labeling and safety information.","c":["health"]}, +{"t":"PharmacyChecker","u":"https://www.pharmacychecker.com/","d":"Compare prescription drug prices at pharmacies and verify online pharmacy legitimacy.","c":["health"]}, +{"t":"PharmacySchools.com","u":"http://www.pharmacyschools.com/","d":"Pharmacy education directory with program rankings and admission requirements.","c":["health"]}, +{"t":"PharmaLive.com","u":"http://www.pharmalive.com/","d":"Pharmaceutical industry news: drug development, regulatory affairs, marketing.","c":["health"]}, +{"t":"PillIdentifier.com","u":"http://www.pillidentifier.com/","d":"A pill identification tool, matching color, shape and imprint to medications.","c":["health"]}, +{"t":"PregnancyGuide.com","u":"http://www.pregnancyguide.com/","d":"Pregnancy week-by-week guide with prenatal care and birth planning.","c":["health"]}, +{"t":"ProteinGuide.com","u":"http://www.proteinguide.com/","d":"A protein nutrition guide: sources, requirements, supplement reviews.","c":["health"]}, +{"t":"RareDiseases.org","u":"http://www.rarediseases.org/","d":"National Organization for Rare Disorders with disease information and patient resources.","c":["health"]}, +{"t":"RxList","u":"https://www.rxlist.com/","d":"The internet drug index: pill identification, drug interactions, prescription information.","c":["health","medicine"]}, +{"t":"SkinConditions.com","u":"http://www.skinconditions.com/","d":"Dermatology resource with skin condition photos and treatment guides.","c":["health"]}, +{"t":"SleepDisorders.com","u":"http://www.sleepdisorders.com/","d":"Sleep disorder information with diagnostic tools and treatment options.","c":["health"]}, +{"t":"SleepFoundation.org","u":"http://www.sleepfoundation.org/","d":"Sleep health research with mattress reviews and insomnia management tips.","c":["health"]}, +{"t":"SouthBeachDiet.com","u":"http://www.southbeachdiet.com/","d":"Low-carb diet plan from Dr. Arthur Agatston with meal plans and recipes.","c":["health"]}, +{"t":"SparkPeople","u":"https://www.sparkpeople.com/","d":"Free diet and fitness community: calorie tracking, meal plans, exercise demos.","c":["health","fitness"]}, +{"t":"SparkRecipes","u":"http://www.sparkrecipes.com/","d":"Healthy recipe database with nutritional analysis and meal planning tools.","c":["health"]}, +{"t":"VeganHealth.org","u":"http://www.veganhealth.org/","d":"Evidence-based nutrition information for vegans with supplement recommendations.","c":["health"]}, +{"t":"VeterinaryDrugs.com","u":"http://www.veterinarydrugs.com/","d":"Animal medication reference with dosage guides and safety information.","c":["health"]}, +{"t":"VisionHealth.com","u":"http://www.visionhealth.com/","d":"Eye disease information with vision screening tools and optometrist finder.","c":["health"]}, +{"t":"VitaminGuide.com","u":"http://www.vitaminguide.com/","d":"Vitamin and mineral supplement guide with dosage recommendations and food sources.","c":["health"]}, +{"t":"Walgreens","u":"https://www.walgreens.com/","d":"Pharmacy, health and wellness products, with online prescription management.","c":["health"]}, +{"t":"WebMD","u":"https://www.webmd.com/","d":"Health information, a symptom checker, a drug interaction database and medical news.","c":["health","medicine"]}, +{"t":"WeightLossPlans.com","u":"http://www.weightlossplans.com/","d":"Weight loss program comparisons with diet plan reviews and tools.","c":["health"]}, +{"t":"WeightWatchers.com","u":"https://www.weightwatchers.com/","d":"Online weight management: points tracking, recipes, community support.","c":["health"]}, +{"t":"WomensWellness.com","u":"http://www.womenswellness.com/","d":"Practice of a Los Angeles area transformational therapist and clinical hypnotherapist offering healing sessions.","c":["health"]}, +{"t":"Acupuncture.com","u":"https://www.acupuncture.com/","d":"Traditional Chinese medicine and acupuncture treatments, with a practitioner directory.","c":["alternative-medicine"]}, +{"t":"AcupunctureToday","u":"http://www.acupuncturetoday.com/","d":"Acupuncture and Oriental medicine news and research, with a practitioner directory.","c":["alternative-medicine"]}, +{"t":"ChiroWeb.com","u":"http://www.chiroweb.com/","d":"Chiropractic news and education resource for practitioners and patients.","c":["alternative-medicine"]}, +{"t":"Dr. Andrew Weil","u":"https://www.drweil.com/","d":"Integrative medicine information from America's best-known holistic health advocate.","c":["alternative-medicine"]}, +{"t":"HerbalGram.org","u":"http://www.herbalgram.org/","d":"American Botanical Council's peer-reviewed journal of herbal medicine research.","c":["alternative-medicine"]}, +{"t":"HerbMed","u":"https://www.herbmed.org/","d":"Evidence-based herbal medicine database with clinical trial data and traditional uses.","c":["alternative-medicine"]}, +{"t":"Homeopathy Home","u":"https://www.homeopathyhome.com/","d":"Introduction to homeopathic medicine with remedy guides and practitioner directory.","c":["alternative-medicine"]}, +{"t":"National Center for Complementary Medicine","u":"https://nccam.nih.gov/","d":"NIH center researching complementary and alternative medicine treatments and therapies.","c":["alternative-medicine"]}, +{"t":"NaturalNews","u":"https://www.naturalnews.com/","d":"Natural health news, herbal remedies, alternative nutrition information.","c":["alternative-medicine"]}, +{"t":"Reflexology Association","u":"https://www.reflexology-usa.org/","d":"Professional association for reflexologists, with member standards and ethics and a directory of practitioners and reflexology schools.","c":["alternative-medicine"]}, +{"t":"ReflexologyChart.com","u":"http://www.reflexologychart.com/","d":"Foot reflexology chart with zone explanations and self-massage techniques.","c":["alternative-medicine"]}, +{"t":"The Acupuncture Resource","u":"http://www.acupunctureresource.com/","d":"Guide to Acupuncture with practitioner directories and how-to articles.","c":["alternative-medicine"]}, +{"t":"The Hypnotherapy Resource","u":"http://www.hypnotherapyresource.com/","d":"Community exploring Hypnotherapy and holistic approaches to wellness.","c":["alternative-medicine"]}, +{"t":"The Reiki Resource","u":"http://www.reikiresource.com/","d":"Alternative medicine resource covering Reiki for health seekers.","c":["alternative-medicine"]}, +{"t":"Wellness Resource Online","u":"http://www.wellnessresource.com/","d":"Alternative medicine resource covering Wellness for health seekers.","c":["alternative-medicine"]}, +{"t":"WellnessCenter.com","u":"http://www.wellnesscenter.com/","d":"Holistic wellness resource covering mind-body health and preventive care.","c":["alternative-medicine"]}, +{"t":"Yoga Journal","u":"https://www.yogajournal.com/","d":"Yoga poses, sequences, meditation guides, holistic health articles.","c":["alternative-medicine","fitness"]}, +{"t":"AMA Online","u":"http://www.ama-assn.org/","d":"The American Medical Association: medical ethics, policy, physician resources.","c":["medicine"]}, +{"t":"American Red Cross","u":"https://www.redcross.org/","d":"Disaster relief, blood donation, health and safety training from the humanitarian organization.","c":["medicine"]}, +{"t":"BMJ.com","u":"http://www.bmj.com/","d":"The British Medical Journal: research, education, news for healthcare professionals.","c":["medicine"]}, +{"t":"Clinical Trials Resource Online","u":"http://www.clinicaltrialsresource.com/","d":"A guide to clinical trials: FAQs, illustrations, current research.","c":["medicine"]}, +{"t":"DentalHealth.com","u":"http://www.dentalhealth.com/","d":"A dental care resource: oral hygiene tips, a dentist finder, procedure guides.","c":["medicine"]}, +{"t":"Dermatology.com","u":"http://www.dermatology.com/","d":"Dermatology resource with skin care advice and dermatologist finder.","c":["medicine"]}, +{"t":"DermatologyCentral","u":"http://www.dermatologycentral.com/","d":"Information site exploring Dermatology and modern healthcare.","c":["medicine"]}, +{"t":"eMedicine","u":"http://www.emedicine.com/","d":"Peer-reviewed clinical reference with disease and procedure articles for physicians.","c":["medicine"]}, +{"t":"HealthCentral","u":"https://www.healthcentral.com/","d":"Health information and patient community forums covering conditions from A to Z.","c":["medicine"]}, +{"t":"HealthGrades","u":"https://www.healthgrades.com/","d":"Doctor ratings, hospital reviews and care reports for healthcare consumers.","c":["medicine"]}, +{"t":"Lancet Online","u":"http://www.thelancet.com/","d":"An independent international general medical journal, with clinical research.","c":["medicine"]}, +{"t":"MedicalImagingResource","u":"http://www.medicalimagingresource.com/","d":"Information site exploring Medical Imaging and modern healthcare.","c":["medicine"]}, +{"t":"MedicalTerms.com","u":"http://www.medicalterms.com/","d":"Medical terminology dictionary with pronunciation guides and root word analysis.","c":["medicine"]}, +{"t":"MedicineNet","u":"https://www.medicinenet.com/","d":"Doctor-produced health and medical information written for the everyday patient.","c":["medicine"]}, +{"t":"Merck Manual","u":"http://www.merckmanuals.com/","d":"A medical reference for healthcare professionals and patients, since 1899.","c":["medicine"]}, +{"t":"Neurology.com","u":"http://www.neurology.com/","d":"Neurology resource with brain and nervous system disorder information.","c":["medicine"]}, +{"t":"New England J of Medicine","u":"http://www.nejm.org/","d":"A medical journal publishing research, reviews and clinical practice articles.","c":["medicine"]}, +{"t":"NIH MedlinePlus Magazine","u":"https://medlineplus.gov/magazine/","d":"Free consumer health magazine from the National Institutes of Health and National Library of Medicine.","c":["medicine"]}, +{"t":"PathologyOnline.com","u":"http://www.pathologyonline.com/","d":"Pathology education resource with disease mechanism explanations and lab values.","c":["medicine"]}, +{"t":"Pediatrics.com","u":"http://www.pediatrics.com/","d":"Pediatric medicine resource with child development and vaccination info.","c":["medicine"]}, +{"t":"PharmacologyCentral","u":"http://www.pharmacologycentral.com/","d":"Medical resource covering Pharmacology for patients and students.","c":["medicine"]}, +{"t":"PhysicalTherapy.net","u":"http://www.physicaltherapy.net/","d":"Physical therapy exercises and rehabilitation guides for injury recovery.","c":["medicine"]}, +{"t":"PubMed","u":"http://www.pubmed.ncbi.nlm.nih.gov/","d":"NLM database of biomedical literature with 30+ million citations from MEDLINE and life science journals.","c":["medicine"]}, +{"t":"SurgeryInfo.com","u":"http://www.surgeryinfo.com/","d":"Surgical procedure information with recovery timelines and patient preparation.","c":["medicine"]}, +{"t":"TelemedicineGuide.com","u":"http://www.telemedicineguide.com/","d":"Telemedicine service directory with doctor consultation platforms and pricing.","c":["medicine"]}, +{"t":"UpToDate","u":"http://www.uptodate.com/","d":"Evidence-based clinical decision support resource used by physicians worldwide.","c":["medicine"]}, +{"t":"VaccinationInfo.org","u":"http://www.vaccinationinfo.org/","d":"Immunization information resource with vaccine schedules and safety data.","c":["medicine"]}, +{"t":"AddictionHelp.com","u":"http://www.addictionhelp.com/","d":"Addiction recovery resource with treatment center finder and support.","c":["mental-health"]}, +{"t":"Anxiety Disorders Association","u":"https://www.adaa.org/","d":"Anxiety and depression resources, treatment information, a therapist directory.","c":["mental-health"]}, +{"t":"Anxiety Resource Online","u":"http://www.anxietyresource.com/","d":"Guide to Anxiety with therapist directories and wellness tips.","c":["mental-health"]}, +{"t":"BetterHealth Channel","u":"https://www.betterhealth.vic.gov.au/","d":"Victorian government health information: wellness, conditions, healthy living.","c":["mental-health"]}, +{"t":"BeyondBlue.org","u":"http://www.beyondblue.org.au/","d":"Australian mental health organization supporting people with anxiety and depression.","c":["mental-health"]}, +{"t":"CouplesCounseling.com","u":"https://www.couplescounseling.com/","d":"Couples and marriage therapy practice in Cary, North Carolina.","c":["mental-health"]}, +{"t":"EatingDisorderHelp.com","u":"http://www.eatingdisorderhelp.com/","d":"Eating disorder information with treatment centers and recovery support.","c":["mental-health"]}, +{"t":"GriefNet","u":"https://www.griefnet.org/","d":"An online support community for people dealing with grief, death and major loss.","c":["mental-health"]}, +{"t":"HelpGuide.org","u":"http://www.helpguide.org/","d":"Mental health and wellness guides on anxiety, depression and relationships.","c":["mental-health"]}, +{"t":"Mental Health America","u":"https://www.mentalhealthamerica.net/","d":"Community-based mental health advocacy, screening tools, policy resources.","c":["mental-health"]}, +{"t":"MentalHealthNet","u":"http://www.mentalhealth.net/","d":"A mental health resource, with disorder information and treatment options.","c":["mental-health"]}, +{"t":"MindWellness.org","u":"http://www.mindwellness.org/","d":"Neuro wellness practice in Pennington, New Jersey, operating as the Center for Mind Wellness.","c":["mental-health"]}, +{"t":"MoodJuice.scot","u":"http://www.moodjuice.scot.nhs.uk/","d":"Self-help resources for common mental health problems from Scotland's NHS.","c":["mental-health"]}, +{"t":"NIMH","u":"http://www.nimh.nih.gov/","d":"The National Institute of Mental Health: research, statistics, treatment information.","c":["mental-health"]}, +{"t":"PanicAttacks.com","u":"http://www.panicattacks.com/","d":"Panic disorder information with coping techniques and therapy options.","c":["mental-health"]}, +{"t":"Psych Central","u":"https://psychcentral.com/","d":"Mental health information, news, quizzes and peer-support communities, online since 1995.","c":["mental-health"]}, +{"t":"SoberRecovery.com","u":"http://www.soberrecovery.com/","d":"Addiction recovery support community with forums and treatment center directory.","c":["mental-health"]}, +{"t":"StressManagement.com","u":"http://www.stressmanagement.com/","d":"Stress reduction techniques with relaxation exercises and time management.","c":["mental-health"]}, +{"t":"The Depression Resource","u":"http://www.depressionresource.com/","d":"Support community for Depression with forums and coping strategies.","c":["mental-health"]}, +{"t":"The National Alliance on Mental Illness","u":"https://www.nami.org","d":"Support and advocacy for individuals and families affected by mental illness.","c":["mental-health"]}, +{"t":"The Therapy Resource","u":"http://www.therapyresource.net/","d":"Free therapy worksheets and clinical assessments for therapists, with paid practice paperwork tools.","c":["mental-health"]}, +{"t":"Associated Press","u":"https://apnews.com/","d":"Breaking news and independent journalism.","c":["news-media"]}, +{"t":"BBC","u":"https://www.bbc.com/","d":"News, sport and weather.","c":["news-media"]}, +{"t":"Reuters","u":"https://www.reuters.com/","d":"International news and world coverage.","c":["news-media"]}, +{"t":"FastCompany.com","u":"http://www.fastcompany.com/","d":"Business magazine on innovation, technology and leadership.","c":["magazines"]}, +{"t":"FoodMagazines.com","u":"http://www.foodmagazines.com/","d":"Food and cooking magazine directory with subscription comparisons.","c":["magazines"]}, +{"t":"Harper's Magazine","u":"http://www.harpers.org/","d":"America's oldest monthly magazine: essays, fiction, the Harper's Index.","c":["magazines"]}, +{"t":"HomeMagazineOnline","u":"http://www.homemagazineonline.com/","d":"Online magazine covering Home Magazine content with articles and features.","c":["magazines"]}, +{"t":"Lifestyle Magazine Online Online","u":"http://www.lifestylemagazineonline.com/","d":"Magazine portal featuring Lifestyle Magazine stories updated weekly.","c":["magazines"]}, +{"t":"MagazineAgent.com","u":"http://www.magazineagent.com/","d":"Magazine subscription service with thousands of titles and discount rates.","c":["magazines"]}, +{"t":"Mother Jones","u":"http://www.motherjones.com/","d":"Nonprofit investigative journalism: politics, environment, social justice.","c":["magazines"]}, +{"t":"National Geographic Mag","u":"http://www.nationalgeographic.com/magazine/","d":"National Geographic magazine: photography, maps, nature and culture.","c":["magazines"]}, +{"t":"National Review","u":"https://www.nationalreview.com/","d":"Conservative political commentary and analysis from William F. Buckley's flagship magazine.","c":["magazines"]}, +{"t":"New Yorker Online","u":"http://www.newyorker.com/","d":"The New Yorker: reporting, commentary, criticism, fiction.","c":["magazines"]}, +{"t":"Newsweek","u":"https://www.newsweek.com/","d":"National and international news, analysis and opinion from Newsweek magazine.","c":["magazines"]}, +{"t":"OutdoorMagazines.com","u":"http://www.outdoormagazines.com/","d":"An outdoor recreation magazine directory: hiking, fishing, camping.","c":["magazines"]}, +{"t":"ParentingMags.com","u":"http://www.parentingmags.com/","d":"Parenting magazine comparison with age-specific content recommendations.","c":["magazines"]}, +{"t":"Popular Mechanics","u":"http://www.popularmechanics.com/","d":"Technology, science and DIY magazine, with gear reviews and project plans.","c":["magazines"]}, +{"t":"Popular Science","u":"https://www.popsci.com/","d":"Science and technology news for the curious mind, with gadget reviews and futurism.","c":["magazines"]}, +{"t":"Rolling Stone Online","u":"https://www.rollingstone.com/news/","d":"Music, politics and pop culture, news and features from Rolling Stone.","c":["magazines"]}, +{"t":"Scientific American","u":"https://www.scientificamerican.com","d":"Authoritative scientific coverage since 1845. Articles explaining complex science for general audiences.","c":["magazines","science","physics"]}, +{"t":"Spin.com","u":"https://www.spin.com/news/","d":"Music industry news, album announcements and tour dates from Spin magazine.","c":["magazines"]}, +{"t":"The Atlantic","u":"https://www.theatlantic.com/","d":"Long-form journalism on politics, business, culture and ideas, since 1857.","c":["magazines"]}, +{"t":"The Auto Magazine Online","u":"http://www.automagazineonline.com/","d":"Directory of Auto Magazine titles with subscription info and previews.","c":["magazines"]}, +{"t":"The Economist","u":"https://www.economist.com/","d":"International affairs, politics, business, finance and science, from the British weekly.","c":["magazines"]}, +{"t":"The New Republic","u":"https://www.tnr.com/","d":"Liberal political opinion and cultural criticism from the century-old magazine.","c":["magazines"]}, +{"t":"The Teen Magazine Online","u":"http://www.teenmagazineonline.com/","d":"Online magazine covering Teen Magazine content with articles and features.","c":["magazines"]}, +{"t":"Time Magazine","u":"https://www.time.com/","d":"Global news and analysis from America's weekly news magazine.","c":["magazines"]}, +{"t":"TravelMagazines.com","u":"http://www.travelmagazines.com/","d":"Travel magazine directory with subscription comparisons and best-of lists.","c":["magazines"]}, +{"t":"Utne Reader","u":"http://www.utne.com/","d":"Alternative press magazine curating the best of independent and alternative media.","c":["magazines"]}, +{"t":"Wired Magazine","u":"http://www.wired.com/magazine/","d":"Technology and culture magazine exploring how emerging technologies affect our lives.","c":["magazines"]}, +{"t":"WoodworkingNetwork","u":"https://www.woodworkingnetwork.com/","d":"Trade news for the professional woodworking industry.","c":["magazines"]}, +{"t":"AlternativePress.org","u":"http://www.alternativepress.org/","d":"Directory of alternative and independent news publications.","c":["newspapers"]}, +{"t":"BBC News","u":"https://news.bbc.co.uk/","d":"Global news and current affairs from the British Broadcasting Corporation.","c":["newspapers"]}, +{"t":"CampusNewspapers.com","u":"http://www.campusnewspapers.com/","d":"College newspaper directory with links to student publications nationwide.","c":["newspapers"]}, +{"t":"Der Spiegel Intl","u":"http://www.spiegel.de/international/","d":"Germany's news magazine in English, with European political analysis.","c":["newspapers"]}, +{"t":"EditorialCentral","u":"http://www.editorialcentral.com/","c":["newspapers"]}, +{"t":"EthnicPress.com","u":"http://www.ethnicpress.com/","d":"Ethnic and multicultural newspaper directory for diaspora communities.","c":["newspapers"]}, +{"t":"ForeignPress.com","u":"http://www.foreignpress.com/","d":"International press aggregator translating news from non-English publications.","c":["newspapers"]}, +{"t":"GlobalNewsWire.com","u":"http://www.globalnewswire.com/","d":"International news wire service aggregating stories from global reporters.","c":["newspapers"]}, +{"t":"HeadlinesOnline","u":"http://www.headlinesonline.com/","d":"Online newspaper resource covering Headlines from around the country.","c":["newspapers"]}, +{"t":"HighSchoolPapers.com","u":"http://www.highschoolpapers.com/","d":"High school newspaper directory with student journalism resources.","c":["newspapers"]}, +{"t":"India Times","u":"http://www.timesofindia.indiatimes.com/","d":"India's most-read English newspaper with national news and business coverage.","c":["newspapers"]}, +{"t":"InternationalPress.com","u":"http://www.internationalpress.com/","d":"Foreign newspaper directory with English-language editions worldwide.","c":["newspapers"]}, +{"t":"InvestigativePost","u":"http://www.investigativepost.com/","d":"Nonprofit investigative journalism covering government and corporate accountability.","c":["newspapers"]}, +{"t":"Japan Times","u":"http://www.japantimes.co.jp/","d":"Japan's largest and oldest English-language newspaper covering Japanese affairs.","c":["newspapers"]}, +{"t":"Le Monde Diplomatique","u":"http://www.mondediplo.com/","d":"French monthly newspaper with international analysis and investigative journalism.","c":["newspapers"]}, +{"t":"LegalNewspaper.com","u":"http://www.legalnewspaper.com/","d":"Legal newspaper directory with court reporting and case coverage.","c":["newspapers"]}, +{"t":"Los Angeles Times","u":"https://www.latimes.com/","d":"Southern California's newspaper of record. News, entertainment, culture.","c":["newspapers"]}, +{"t":"MilitaryNewspapers.com","u":"http://www.militarynewspapers.com/","d":"Military base newspaper directory with Stars and Stripes archives.","c":["newspapers"]}, +{"t":"Milwaukee Journal Sentinel","u":"https://www.jsonline.com/","d":"Wisconsin's largest newspaper. Local news, national news, Packers coverage.","c":["newspapers"]}, +{"t":"News Wire Online Online","u":"http://www.newswireonline.com/","d":"Online newspaper resource covering News Wire from around the country.","c":["newspapers"]}, +{"t":"NewspaperLinks.com","u":"http://www.newspaperlinks.com/","d":"Directory of newspaper websites organized by country and state.","c":["newspapers"]}, +{"t":"RealEstateNews.com","u":"http://www.realestatenews.com/","d":"Real estate industry newspaper with market data and deal coverage.","c":["newspapers"]}, +{"t":"Sydney Morning Herald","u":"https://www.smh.com.au/","d":"An Australian newspaper covering national and international news.","c":["newspapers","asia"]}, +{"t":"TechNewspaper.com","u":"http://www.technewspaper.com/","d":"Technology industry news publications covering startups and innovation.","c":["newspapers"]}, +{"t":"The Boston Globe","u":"https://www.boston.com/globe/","d":"New England's newspaper. Regional, national and investigative news.","c":["newspapers"]}, +{"t":"The Chicago Tribune","u":"https://www.chicagotribune.com/","d":"Chicago's newspaper. Metro, national and international news.","c":["newspapers"]}, +{"t":"The Dallas Morning News","u":"https://www.dallasnews.com/","d":"The Dallas-Fort Worth newspaper, covering Texas news, politics and sports.","c":["newspapers"]}, +{"t":"The Editorial Online","u":"http://www.editorialonline.net/","c":["newspapers"]}, +{"t":"The Globe and Mail","u":"https://www.theglobeandmail.com/","d":"Canada's national newspaper. Business, politics, culture.","c":["newspapers","canada"]}, +{"t":"The Guardian","u":"https://www.guardian.co.uk/","d":"British daily newspaper. UK and world news, comment, analysis.","c":["newspapers","europe"]}, +{"t":"The Local News Online","u":"http://www.localnewsonline.com/","d":"Directory of newspapers offering Local News coverage and archives.","c":["newspapers"]}, +{"t":"The Miami Herald","u":"https://www.miamiherald.com/","d":"South Florida's newspaper, covering Latin American affairs and local news.","c":["newspapers"]}, +{"t":"The New York Times","u":"https://www.nytimes.com/","d":"The paper of record online. World and national news, analysis, opinion.","c":["newspapers"]}, +{"t":"The San Jose Mercury News","u":"https://www.mercurynews.com/","d":"Silicon Valley's newspaper covering technology industry news and Bay Area happenings.","c":["newspapers"]}, +{"t":"The Seattle Times","u":"https://www.seattletimes.com/","d":"Pacific Northwest news and coverage of Seattle's booming technology industry.","c":["newspapers"]}, +{"t":"The Times UK","u":"http://www.timesonline.co.uk/","d":"London's daily newspaper: news, comment, business.","c":["newspapers"]}, +{"t":"The Wall Street Journal","u":"https://www.wsj.com/","d":"America's business newspaper. Financial news, market analysis, opinion.","c":["newspapers"]}, +{"t":"The Washington Post","u":"https://www.washingtonpost.com/","d":"Award-winning political and national news coverage from the US capital.","c":["newspapers"]}, +{"t":"USA Today","u":"https://www.usatoday.com/","d":"America's most widely read newspaper with national and world news coverage.","c":["newspapers"]}, +{"t":"World News Central Online","u":"http://www.worldnewscentral.com/","c":["newspapers"]}, +{"t":"WorldNewsOnline","u":"http://www.worldnewsonline.com/","c":["newspapers"]}, +{"t":"AlterNet","u":"https://www.alternet.org/","d":"Progressive news site with investigative journalism and alternative media perspectives.","c":["online-media"]}, +{"t":"AutoNews.com","u":"http://www.autonews.com/","d":"Automotive industry news with new model reveals and sales data.","c":["online-media"]}, +{"t":"BlogReview.com","u":"http://www.blogreview.com/","d":"Blog review and rating service highlighting the best blogs by topic.","c":["online-media"]}, +{"t":"Blogs Network Online","u":"http://www.blogsnetwork.com/","d":"Reference on Blogs with directories and how-to guides.","c":["online-media"]}, +{"t":"Boing Boing","u":"https://www.boingboing.net/","d":"A directory of wonderful things: tech, science and culture, from a group blog.","c":["online-media"]}, +{"t":"BusinessNewsDaily.com","u":"http://www.businessnewsdaily.com/","d":"Daily business news for small business owners and entrepreneurs.","c":["online-media"]}, +{"t":"BuzzFeed","u":"http://www.buzzfeed.com/","d":"Viral content and news: pop culture, politics, everyday life.","c":["online-media"]}, +{"t":"CitizenJournalism.org","u":"http://www.citizenjournalism.org/","d":"Citizen journalism resource with reporting guides and platform tools.","c":["online-media"]}, +{"t":"CitizenJournalismNetwork","u":"http://www.citizenjournalismnetwork.com/","d":"Portal exploring Citizen Journalism with reviews and recommendations.","c":["online-media"]}, +{"t":"CNN.com","u":"https://www.cnn.com/","d":"CNN online: breaking news, video and developing stories, 24 hours a day.","c":["online-media","television-news"]}, +{"t":"Daypop","u":"https://www.daypop.com/","d":"Current events search engine tracking what's popular across news sites and weblogs.","c":["online-media"]}, +{"t":"EducationNews.com","u":"http://www.educationnews.com/","d":"Education policy and school news covering K-12 and higher education.","c":["online-media"]}, +{"t":"Engadget.com","u":"http://www.engadget.com/","d":"Technology news and gadget reviews covering consumer electronics and mobile.","c":["online-media"]}, +{"t":"EntertainmentWire.com","u":"http://www.entertainmentwire.com/","d":"An entertainment industry news wire: casting, production, release updates.","c":["online-media"]}, +{"t":"FoodNewsWeekly.com","u":"http://www.foodnewsweekly.com/","d":"Food industry news: restaurants, recalls, trends.","c":["online-media"]}, +{"t":"Gawker","u":"https://www.gawker.com/","d":"Manhattan media gossip blog tracking the scandals and absurdities of New York's media elite.","c":["online-media"]}, +{"t":"Gizmodo.com","u":"http://www.gizmodo.com/","d":"Technology, science and design blog from the Gawker Media network.","c":["online-media"]}, +{"t":"Huffington Post","u":"http://www.huffingtonpost.com/","d":"Online news aggregator and blog: politics, business, entertainment.","c":["online-media"]}, +{"t":"IndyMedia","u":"https://www.indymedia.org/","d":"Independent Media Center providing grassroots news coverage and citizen journalism worldwide.","c":["online-media"]}, +{"t":"Kottke.org","u":"https://kottke.org/","d":"Jason Kottke's weblog on design, technology, science and internet culture. One of the first.","c":["online-media"]}, +{"t":"Lifehacker.com","u":"http://www.lifehacker.com/","d":"Tips, tricks and downloads for getting things done faster.","c":["online-media"]}, +{"t":"Mashable.com","u":"http://www.mashable.com/","d":"Digital culture and technology news covering social media and emerging platforms.","c":["online-media"]}, +{"t":"MediaBiasChart.com","u":"http://www.mediabiaschart.com/","d":"Analysis of media bias with source ratings and reliability assessments.","c":["online-media"]}, +{"t":"MSNBC","u":"https://www.msnbc.com/","d":"Microsoft and NBC joint venture for 24-hour cable news and online journalism.","c":["online-media","television-news"]}, +{"t":"NewsDirectory.com","u":"http://www.newsdirectory.com/","d":"A directory of online newspapers and news sites worldwide.","c":["online-media"]}, +{"t":"NewsTrust.net","u":"http://www.newstrust.net/","d":"Community ratings of how well news stories are reported.","c":["online-media"]}, +{"t":"PodcastCentral","u":"http://www.podcastcentral.com/","c":["online-media"]}, +{"t":"PodcastNetwork.com","u":"http://www.podcastnetwork.com/","d":"An early podcast network: technology, culture, comedy.","c":["online-media"]}, +{"t":"PressRelease.com","u":"http://www.pressrelease.com/","d":"Press release distribution service for businesses seeking media coverage.","c":["online-media"]}, +{"t":"RealEstateNewsDaily.com","u":"http://www.realestatenewsdaily.com/","d":"Real estate market news with housing data and mortgage rate updates.","c":["online-media"]}, +{"t":"Salon Technology","u":"https://www.salon.com/tech/","d":"Technology news and analysis from Salon's dedicated tech coverage vertical.","c":["online-media"]}, +{"t":"Salon.com","u":"https://www.salon.com/","d":"Independent online journalism on politics, culture, technology and entertainment.","c":["online-media"]}, +{"t":"ScienceNewsDaily.com","u":"http://www.sciencenewsdaily.com/","d":"Daily science news digest from research institutions worldwide.","c":["online-media"]}, +{"t":"Slate.com","u":"http://www.slate.com/","d":"Online magazine covering news, politics, business, technology, culture and the arts, with analysis and commentary.","c":["online-media"]}, +{"t":"SportsNewswire.com","u":"http://www.sportsnewswire.com/","d":"Sports news wire service with press releases from teams and leagues.","c":["online-media"]}, +{"t":"TechCrunch","u":"http://www.techcrunch.com/","d":"Technology startup and venture capital news covering the Silicon Valley ecosystem.","c":["online-media"]}, +{"t":"TechNewsDaily.com","u":"http://www.technewsdaily.com/","d":"Daily technology news digest with product launches and industry analysis.","c":["online-media"]}, +{"t":"The Daily Beast","u":"http://www.thedailybeast.com/","d":"Online news publication mixing original reporting and opinion with aggregated content.","c":["online-media"]}, +{"t":"The Podcast Network","u":"http://www.podcastnetwork.net/","c":["online-media"]}, +{"t":"The Verge","u":"http://www.theverge.com/","d":"Technology, science, art and culture news from Vox Media.","c":["online-media"]}, +{"t":"Vice.com","u":"http://www.vice.com/","d":"Global youth media company with immersive documentaries and bold reporting.","c":["online-media"]}, +{"t":"Vox.com","u":"http://www.vox.com/","d":"Explanatory journalism platform that explains the news with context and clarity.","c":["online-media"]}, +{"t":"Waxy.org","u":"https://waxy.org/","d":"Andy Baio's weblog on technology, media and internet culture.","c":["online-media"]}, +{"t":"Wonkette","u":"https://www.wonkette.com/","d":"Political gossip blog covering Washington DC with sharp humor and insider commentary.","c":["online-media"]}, +{"t":"BBC Radio","u":"http://www.bbc.co.uk/radio/","d":"The BBC's radio services: live streaming, program schedules, listen-again.","c":["radio"]}, +{"t":"BBC World Service","u":"https://www.bbc.co.uk/worldservice/","d":"International radio broadcasting from the BBC with streaming audio in dozens of languages.","c":["radio"]}, +{"t":"ChristianRadio.com","u":"http://www.christianradio.com/","d":"Christian and gospel radio station directory with streaming.","c":["radio"]}, +{"t":"ClassicalRadio.com","u":"http://www.classicalradio.com/","d":"Classical music radio station directory with streaming and playlists.","c":["radio"]}, +{"t":"CollegeRadio.org","u":"http://www.collegeradio.org/","d":"College radio station directory with streaming links and music charts.","c":["radio"]}, +{"t":"IndieRadio.com","u":"http://www.indieradio.com/","d":"Independent and alternative music radio station directory.","c":["radio"]}, +{"t":"JazzRadio.com","u":"http://www.jazzradio.com/","d":"Jazz radio station directory with online streaming and program guides.","c":["radio"]}, +{"t":"Live365","u":"https://www.live365.com/","d":"Internet radio network with thousands of user-created stations spanning every genre.","c":["radio"]}, +{"t":"NPR Online","u":"https://www.npr.org/","d":"News, programs and a streaming audio archive from National Public Radio.","c":["radio"]}, +{"t":"PodcastReview.com","u":"http://www.podcastreview.com/","d":"Early podcast reviews and ratings with genre-based discovery tools.","c":["radio"]}, +{"t":"PublicRadioFan.com","u":"http://www.publicradiofan.com/","d":"A guide to public radio stations, schedules and streaming links.","c":["radio"]}, +{"t":"Radio-Locator","u":"http://www.radio-locator.com/","d":"A radio station search engine covering over 10,000 stations.","c":["radio"]}, +{"t":"RadioTower.com","u":"http://www.radiotower.com/","d":"A directory of internet radio stations, searchable by genre, format or location.","c":["radio"]}, +{"t":"SHOUTcast","u":"https://www.shoutcast.com/","d":"Free internet radio streaming technology from Nullsoft with a directory of thousands of stations.","c":["radio"]}, +{"t":"SportsRadio.com","u":"http://www.sportsradio.com/","d":"Sports talk radio station directory with streaming and schedule info.","c":["radio"]}, +{"t":"SportsRadioGuide","u":"http://www.sportsradioguide.com/","d":"Directory of Sports Radio stations searchable by city and format.","c":["radio"]}, +{"t":"StreamFinder.com","u":"http://www.streamfinder.com/","d":"Guide to streaming audio on the internet with station reviews and links.","c":["radio"]}, +{"t":"StreamRadio.com","u":"http://www.streamradio.com/","d":"Radio station search engine with links to live internet streaming audio.","c":["radio"]}, +{"t":"TuneIn Radio","u":"http://www.tunein.com/","d":"Live radio stations, podcasts and music streams from around the world.","c":["radio"]}, +{"t":"WNYC","u":"https://www.wnyc.org/","d":"New York public radio: streaming audio, podcasts, cultural programming.","c":["radio"]}, +{"t":"ABC News","u":"https://abcnews.go.com/","d":"Breaking news, video and investigative reporting from the ABC television network.","c":["television-news"]}, +{"t":"Al Jazeera English","u":"http://www.aljazeera.com/","d":"Qatar-based international English news channel with global perspective.","c":["television-news"]}, +{"t":"BreakingNewsCentral","u":"http://www.breakingnewscentral.com/","d":"Guide to Breaking News with channel listings and program schedules.","c":["television-news"]}, +{"t":"CBS News","u":"https://www.cbsnews.com/","d":"National and world news from the CBS television network with video and analysis.","c":["television-news"]}, +{"t":"Fox News","u":"https://www.foxnews.com/","d":"Breaking news, political commentary and video from the Fox News Channel.","c":["television-news"]}, +{"t":"NBC News","u":"https://www.nbcnews.com/","d":"Breaking news, investigations and features from NBC's news division.","c":["television-news"]}, +{"t":"PBS NewsHour","u":"https://www.pbs.org/newshour/","d":"News analysis, interviews and reporting from the public television news program.","c":["television-news"]}, +{"t":"Reuters TV","u":"http://www.reuters.com/tv/","d":"International news agency's video and television news service.","c":["television-news"]}, +{"t":"AccuWeather","u":"https://www.accuweather.com/","d":"Superior accuracy in weather forecasting with minute-by-minute local predictions.","c":["weather"]}, +{"t":"AllergyForecast.com","u":"http://www.allergyforecast.com/","d":"Pollen and allergy forecasts by location with medication reminders.","c":["weather"]}, +{"t":"ClimateWatch.com","u":"http://www.climatewatch.com/","d":"Climate data visualization and historical weather records for researchers.","c":["weather"]}, +{"t":"CycloneCenter.org","u":"http://www.cyclonecenter.org/","d":"Citizen science project classifying tropical cyclones using satellite imagery.","c":["weather"]}, +{"t":"HurricaneTracker.com","u":"http://www.hurricanetracker.com/","d":"Hurricane tracking: satellite imagery, forecast models, preparedness.","c":["weather"]}, +{"t":"Intellicast","u":"https://www.intellicast.com/","d":"Weather data, radar, satellite imagery and forecasts, for weather enthusiasts.","c":["weather"]}, +{"t":"Intellicast Radar","u":"https://www.intellicast.com/National/Radar/","d":"National radar mosaic with precipitation tracking and storm analysis.","c":["weather"]}, +{"t":"MyForecast.com","u":"http://www.myforecast.com/","d":"Local weather forecasts, hourly and 10-day.","c":["weather"]}, +{"t":"National Weather Service","u":"https://www.weather.gov/","d":"Official weather forecasts, severe weather warnings and climate data from the NWS.","c":["weather","science"]}, +{"t":"NOAA Weather","u":"https://www.noaa.gov/","d":"National Oceanic and Atmospheric Administration with official weather forecasts and climate data.","c":["weather"]}, +{"t":"SnowReport.com","u":"http://www.snowreport.com/","d":"Snow conditions and accumulation reports for ski resorts and mountain passes.","c":["weather"]}, +{"t":"StormChasing.com","u":"http://www.stormchasing.com/","d":"A storm chasing community: live reports, gear reviews, safety.","c":["weather"]}, +{"t":"StormPredator.com","u":"http://www.stormpredator.com/","d":"Severe weather forecasting and storm chasing resource with reports and photos.","c":["weather"]}, +{"t":"TornadoAlley.com","u":"http://www.tornadoalley.com/","d":"Tulsa company selling and installing tornado safe rooms nationwide, with a showroom and a map of tornado occurrences.","c":["weather"]}, +{"t":"Weather Underground","u":"https://www.wunderground.com/","d":"Hyperlocal weather forecasts from personal weather station networks and radar imagery.","c":["weather"]}, +{"t":"Weather.com","u":"https://www.weather.com/","d":"Local and national weather forecasts, radar, satellite imagery, storm alerts.","c":["weather"]}, +{"t":"WeatherBug.com","u":"http://www.weatherbug.com/","d":"Live local weather from school weather stations, with alerts and forecasts.","c":["weather"]}, +{"t":"WeatherChannel.com","u":"http://www.weatherchannel.com/","d":"A Weather Channel alternative, with forecasts and severe weather alerts.","c":["weather"]}, +{"t":"WeatherHistory.com","u":"http://www.weatherhistory.com/","d":"Historical weather data lookup by date and location with climate records.","c":["weather"]}, +{"t":"Windguru.com","u":"http://www.windguru.com/","d":"Wind and weather forecasts for surfers, sailors and wind sports enthusiasts.","c":["weather"]}, +{"t":"MLB.com","u":"https://www.mlb.com","d":"Official site of Major League Baseball. Scores, standings, stats, fantasy baseball.","c":["recreation-sports","sports-recreation-sports"]}, +{"t":"AutoBlog.com","u":"http://www.autoblog.com/","d":"Automotive news blog: car reviews, buying guides, industry coverage.","c":["automotive"]}, +{"t":"AutoPartsCentral","u":"http://www.autopartscentral.com/","d":"Automotive site covering Auto Parts with how-to guides and forums.","c":["automotive"]}, +{"t":"AutoPartsFinder.com","u":"http://www.autopartsfinder.com/","d":"Auto parts search engine comparing prices across online retailers.","c":["automotive"]}, +{"t":"AutoRepairGuide.com","u":"http://www.autorepairguide.com/","d":"DIY auto repair guides with diagnostic tips and maintenance schedules.","c":["automotive"]}, +{"t":"AutoTrader","u":"https://www.autotrader.com/","d":"Search millions of new and used car listings from dealers and private sellers nationwide.","c":["automotive"]}, +{"t":"AutoTrader Classifieds","u":"https://www.autotraderclassics.com/","d":"Classic and collectible car listings from dealers and private sellers nationwide.","c":["automotive"]}, +{"t":"Autoweek","u":"https://www.autoweek.com/","d":"Auto racing coverage, car news and reviews for the enthusiast driver.","c":["automotive"]}, +{"t":"Car and Driver","u":"https://www.caranddriver.com/","d":"Car reviews, comparisons, specifications, buying advice.","c":["automotive"]}, +{"t":"CarAudioNetwork","u":"http://www.caraudionetwork.com/","c":["automotive"]}, +{"t":"CarDetailingCentral","u":"http://www.cardetailingcentral.com/","d":"Reference on Car Detailing with maintenance advice and project ideas.","c":["automotive"]}, +{"t":"CarRestoration.com","u":"http://www.carrestoration.com/","d":"Classic car restoration guides with parts sourcing and paint matching.","c":["automotive"]}, +{"t":"Cars.com","u":"https://www.cars.com/","d":"New and used car listings, dealer reviews, research tools.","c":["automotive"]}, +{"t":"CarsDirect","u":"https://www.carsdirect.com/","d":"Buy a car online at a guaranteed price with free delivery. Pioneer of internet car buying.","c":["automotive"]}, +{"t":"CarWashWorld.com","u":"http://www.carwashworld.com/","d":"Car detailing and car wash guide with product reviews and technique tips.","c":["automotive"]}, +{"t":"ClassicCars.com","u":"http://www.classiccars.com/","d":"Classic and antique car marketplace with valuations and restoration resources.","c":["automotive"]}, +{"t":"CorvetteForums.com","u":"http://www.corvetteforums.com/","d":"Corvette enthusiast community: technical info, forums, classified ads.","c":["automotive"]}, +{"t":"DragTimes.com","u":"http://www.dragtimes.com/","d":"Drag racing performance database with quarter-mile times for cars and motorcycles.","c":["automotive"]}, +{"t":"DrivingSchools.com","u":"http://www.drivingschools.com/","d":"Driving school directory with student reviews and license exam preparation.","c":["automotive"]}, +{"t":"Edmunds","u":"https://www.edmunds.com/","d":"New and used car prices, dealer quotes, reviews, buying guides.","c":["automotive"]}, +{"t":"ElectricVehicle.net","u":"http://www.electricvehicle.net/","d":"Early electric vehicle news with EV reviews and charging station maps.","c":["automotive"]}, +{"t":"EmissionsTest.com","u":"http://www.emissionstest.com/","d":"Vehicle emissions testing guide with state requirements and preparation tips.","c":["automotive"]}, +{"t":"Hagerty Classic Cars","u":"https://www.hagerty.com/","d":"Classic car insurance, valuations, collector market insight.","c":["automotive"]}, +{"t":"HondaTech.com","u":"http://www.honda-tech.com/","d":"Honda and Acura enthusiast forum with technical help and performance modifications.","c":["automotive"]}, +{"t":"ImportTuner.com","u":"http://www.importtuner.com/","d":"Import car tuning magazine: feature cars, tech articles, event coverage.","c":["automotive"]}, +{"t":"J.D. Power","u":"https://www.jdpower.com/","d":"Vehicle quality ratings, customer satisfaction surveys, new car reviews.","c":["automotive"]}, +{"t":"JalopnikAuto.com","u":"http://www.jalopnik.com/","d":"Car culture blog. Automotive news, reviews, the joy of driving.","c":["automotive"]}, +{"t":"Kelley Blue Book","u":"https://www.kbb.com/","d":"New and used car pricing and reviews, with the Blue Book value guide. Published since 1926.","c":["automotive"]}, +{"t":"Motor Trend","u":"https://www.motortrend.com/","d":"Automotive reviews, comparisons and the Car of the Year awards.","c":["automotive"]}, +{"t":"MotorcycleDaily.com","u":"http://www.motorcycledaily.com/","d":"Motorcycle news, reviews and a rider community, with touring and racing coverage.","c":["automotive"]}, +{"t":"MustangForums.com","u":"http://www.mustangforums.com/","d":"Ford Mustang community: technical articles, forums, project car features.","c":["automotive"]}, +{"t":"OffRoad.com","u":"http://www.offroad.com/","d":"Off-road vehicle magazine: trail reviews, tech articles, event coverage.","c":["automotive"]}, +{"t":"The Car Audio Central","u":"http://www.caraudiocentral.net/","c":["automotive"]}, +{"t":"The Restoration Central","u":"http://www.restorationcentral.com/","d":"Community of car fans devoted to Restoration and the open road.","c":["automotive"]}, +{"t":"TireReviews.com","u":"http://www.tirereviews.com/","d":"Tire reviews and comparisons with seasonal recommendations and pricing.","c":["automotive"]}, +{"t":"TruckTrend.com","u":"http://www.trucktrend.com/","d":"Truck and SUV magazine: reviews, towing tests, aftermarket accessories.","c":["automotive"]}, +{"t":"Volkswagen USA","u":"https://www.vw.com/","d":"Volkswagen car models, a dealer locator, build-your-own configuration tools.","c":["automotive"]}, +{"t":"Bicycling Magazine Online","u":"https://www.bicycling.com","d":"Road and mountain biking tips, reviews and training advice.","c":["fitness","sports-recreation-sports"]}, +{"t":"Bodybuilding.com","u":"https://www.bodybuilding.com/","d":"Workout guides, an exercise database, nutrition plans and supplements for fitness enthusiasts.","c":["fitness"]}, +{"t":"CardioCentral","u":"http://www.cardiocentral.com/","d":"Reference on Cardio with technique guides and training schedules.","c":["fitness"]}, +{"t":"CrossFit","u":"https://www.crossfit.com/","d":"High-intensity functional fitness program with workout of the day and affiliate gym finder.","c":["fitness"]}, +{"t":"eFitness.com","u":"http://www.efitness.com/","d":"A fitness portal: workout routines, exercise guides, a gym locator.","c":["fitness"]}, +{"t":"ExRx.net","u":"http://www.exrx.net/","d":"Exercise prescription database with animated exercise demonstrations and fitness calculators.","c":["fitness"]}, +{"t":"HomeWorkoutPlan.com","u":"http://www.homeworkoutplan.com/","d":"Bodyweight exercise programs for home fitness without gym equipment.","c":["fitness"]}, +{"t":"MartialArtsTraining.com","u":"http://www.martialartstraining.com/","d":"Martial arts style comparison with school directories and training resources.","c":["fitness"]}, +{"t":"Men's Health","u":"https://www.menshealth.com/","d":"Fitness, nutrition, health and lifestyle advice for men, from the magazine.","c":["fitness"]}, +{"t":"Pilates Central Online","u":"http://www.pilatescentral.net/","d":"A Pilates guide: routines, videos, gear advice.","c":["fitness"]}, +{"t":"Pilates.com","u":"https://www.pilates.com/","d":"Pilates exercise information, an instructor directory, equipment for core strength training.","c":["fitness"]}, +{"t":"PreNatalExercise.com","u":"http://www.prenatalexercise.com/","d":"Pregnancy exercise guide with safe workouts for each trimester.","c":["fitness"]}, +{"t":"Runner's World","u":"https://www.runnersworld.com/","d":"Training plans, gear reviews, nutrition advice and race information, for runners at any level.","c":["fitness"]}, +{"t":"Running Resource Online","u":"http://www.runningresource.com/","d":"Companion site to the Run Arno Run YouTube channel, with running training videos, tips and gear reviews.","c":["fitness"]}, +{"t":"RunningCentral","u":"http://www.runningcentral.com/","d":"Running and active lifestyle outfitter with a store and an athletic club app that rewards logged miles.","c":["fitness"]}, +{"t":"Shape Magazine","u":"https://www.shape.com/","d":"Women's fitness: workout routines, healthy eating tips, wellness advice.","c":["fitness"]}, +{"t":"ShapeUp.org","u":"http://www.shapeup.org/","d":"Shape Up America campaign promoting healthy weight through exercise and nutrition.","c":["fitness"]}, +{"t":"SportsInjury.com","u":"http://www.sportsinjury.com/","d":"Sports injury treatment guide with rehabilitation exercises.","c":["fitness"]}, +{"t":"StretchingGuide.com","u":"http://www.stretchingguide.com/","d":"Flexibility and stretching routines for injury prevention and mobility.","c":["fitness"]}, +{"t":"SwimmingWorkouts.com","u":"http://www.swimmingworkouts.com/","d":"Swimming training plans for beginners through competitive swimmers.","c":["fitness"]}, +{"t":"T-Nation.com","u":"http://www.t-nation.com/","d":"Testosterone Nation. Strength training, bodybuilding, sports performance.","c":["fitness"]}, +{"t":"The Yoga Central","u":"http://www.yogacentral.net/","c":["fitness"]}, +{"t":"WalkingCentral","u":"http://www.walkingcentral.com/","d":"Fitness resource covering Walking with workout plans and tips.","c":["fitness"]}, +{"t":"WalkingForHealth.com","u":"http://www.walkingforhealth.com/","d":"Walking fitness programs with pedometer tracking and trail suggestions.","c":["fitness"]}, +{"t":"WebMD Fitness","u":"https://www.webmd.com/fitness-exercise/","d":"Exercise guides, workout plans and fitness tips from medical professionals.","c":["fitness"]}, +{"t":"WeightLossCentral","u":"http://www.weightlosscentral.org/","d":"Community of fitness fans sharing Weight Loss progress and motivation.","c":["fitness"]}, +{"t":"Women's Health Mag","u":"http://www.womenshealthmag.com/","d":"Women's fitness magazine: workouts, healthy recipes, wellness tips.","c":["fitness"]}, +{"t":"YogaResource","u":"http://www.yogaresource.com/","c":["fitness"]}, +{"t":"BeadingDaily.com","u":"http://www.beadingdaily.com/","d":"Bead weaving and jewelry making: tutorials, patterns, technique videos.","c":["hobbies-crafts"]}, +{"t":"CandelMaking.com","u":"http://www.candlemaking.com/","d":"Candle making supplies and tutorials for hobbyists and small business owners.","c":["hobbies-crafts"]}, +{"t":"CoinGrading.com","u":"https://www.coingrading.com/","d":"A guide to grading U.S. coins by condition, from the author of How to Grade U.S. Coins.","c":["hobbies-crafts"]}, +{"t":"CoinWorld.com","u":"http://www.coinworld.com/","d":"Coin collecting news, values, grading information, numismatic resources.","c":["hobbies-crafts"]}, +{"t":"Craftster","u":"https://www.craftster.org/","d":"An online community where crafters share projects, swap patterns and find inspiration.","c":["hobbies-crafts"]}, +{"t":"Cross Stitch World","u":"https://www.crossstitchworld.com/","d":"Free cross stitch patterns, needlework supplies, stitching community forums.","c":["hobbies-crafts"]}, +{"t":"Embroidery Central Online","u":"http://www.embroiderycentral.com/","d":"Machine and hand embroidery designs, supplies and materials.","c":["hobbies-crafts"]}, +{"t":"GardenRailway.com","u":"http://www.gardenrailway.com/","d":"YouTube channel of garden train videos, including product reviews, new products, Thomas the Tank Engine and seminars.","c":["hobbies-crafts"]}, +{"t":"Instructables.com","u":"http://www.instructables.com/","d":"Step-by-step instructions for making anything from electronics to food to furniture.","c":["hobbies-crafts"]}, +{"t":"Knitting Universe","u":"https://www.knittinguniverse.com/","d":"Knitting patterns, yarn reviews and technique tutorials, at any level.","c":["hobbies-crafts"]}, +{"t":"Model Railroad Hobbyist","u":"https://www.mrhmag.com/","d":"Model railroading magazine. Layout plans, reviews, how-to articles.","c":["hobbies-crafts"]}, +{"t":"Model Rockets","u":"https://www.modelrockets.com/","d":"A model rocketry reference: motor classes, kit specs and safety rules.","c":["hobbies-crafts"]}, +{"t":"ModelAircraft.org","u":"http://www.modelaircraft.org/","d":"Academy of Model Aeronautics with flying site locator and safety regulations.","c":["hobbies-crafts"]}, +{"t":"ModelRailroader.com","u":"http://www.modelrailroader.com/","d":"Model railroad hobby magazine: layouts, projects, product reviews.","c":["hobbies-crafts"]}, +{"t":"OrigamiFun.com","u":"http://www.origamifun.com/","d":"Origami paper folding instructions with diagrams for beginners through advanced.","c":["hobbies-crafts"]}, +{"t":"PotteryCentral","u":"https://www.potterycentral.com/","d":"Charlotte, North Carolina pottery studio offering wheel-throwing workshops, classes and private events for beginners.","c":["hobbies-crafts"]}, +{"t":"QuiltingDaily.com","u":"http://www.quiltingdaily.com/","d":"Quilting patterns, fabric guides and a community, plus online classes.","c":["hobbies-crafts"]}, +{"t":"Ravelry.com","u":"http://www.ravelry.com/","d":"Knitting and crochet community: patterns, projects, yarn databases.","c":["hobbies-crafts"]}, +{"t":"RCGroups.com","u":"http://www.rcgroups.com/","d":"Radio control hobby community: airplanes, helicopters, cars, boats.","c":["hobbies-crafts"]}, +{"t":"Scrapbooking.com","u":"https://www.scrapbooking.com/","d":"Scrapbooking ideas, supplies and page layouts, plus a community for memory-keeping crafters.","c":["hobbies-crafts"]}, +{"t":"SewingPatternReview","u":"http://www.patternreview.com/","d":"Sewing pattern reviews and community with fit guidance and technique forums.","c":["hobbies-crafts"]}, +{"t":"StainedGlassHobby.com","u":"http://www.stainedglasshobby.com/","d":"Stained glass hobby tutorials with pattern designs and supply sources.","c":["hobbies-crafts"]}, +{"t":"StampWorld.com","u":"http://www.stampworld.com/","d":"Stamp collecting community: catalog listings, values, trading forums.","c":["hobbies-crafts"]}, +{"t":"TheMiniatureGarden","u":"http://www.miniaturegarden.com/","d":"Miniature and fairy garden hobby resource with plant guides and accessories.","c":["hobbies-crafts"]}, +{"t":"Woodturning.net","u":"http://www.woodturning.net/","d":"Wood turning lathe hobby resource with project plans and tool reviews.","c":["hobbies-crafts"]}, +{"t":"Woodworking Magazine","u":"https://www.woodmagazine.com/","d":"Woodworking plans, project ideas, tool reviews and technique tutorials from Wood magazine.","c":["hobbies-crafts"]}, +{"t":"AllTrails","u":"https://www.alltrails.com/","d":"Hiking and trail running guide: GPS maps, photos, difficulty ratings.","c":["outdoors-nature"]}, +{"t":"Appalachian Trail","u":"https://www.appalachiantrail.org/","d":"The Appalachian Trail Conservancy: thru-hiking guides, trail conditions, conservation news.","c":["outdoors-nature"]}, +{"t":"Audubon Society","u":"http://www.audubon.org/","d":"National Audubon Society protecting birds and their habitats through conservation.","c":["outdoors-nature"]}, +{"t":"Backpacker Magazine","u":"https://www.backpacker.com/","d":"Backpacking and wilderness hiking magazine. Gear reviews, trip reports, survival skills.","c":["outdoors-nature"]}, +{"t":"Bird Watcher's Digest","u":"https://www.birdwatchersdigest.com/","d":"Birding magazine: identification guides, feeders, backyard habitat tips.","c":["outdoors-nature"]}, +{"t":"BirdWatchingDaily","u":"http://www.birdwatchingdaily.com/","d":"A birding community: species identification, hotspot maps, trip reports.","c":["outdoors-nature"]}, +{"t":"Climbing Magazine","u":"http://www.climbing.com/","d":"Rock climbing magazine: route information, technique articles, gear reviews.","c":["outdoors-nature"]}, +{"t":"FlyFisherman.com","u":"http://www.flyfisherman.com/","d":"Fly fishing magazine: techniques, gear reviews, destination guides.","c":["outdoors-nature"]}, +{"t":"Geocaching.com","u":"https://www.geocaching.com/","d":"GPS-based outdoor treasure hunting game with hidden caches worldwide.","c":["outdoors-nature"]}, +{"t":"HikingTrails.net","u":"http://www.hikingtrails.net/","d":"Hiking trail reviews: difficulty ratings, photos, GPS waypoints.","c":["outdoors-nature"]}, +{"t":"National Park Service","u":"https://www.nps.gov/","d":"The official guide to America's national parks: trail maps, camping info, park alerts.","c":["outdoors-nature"]}, +{"t":"NationalParks.org","u":"http://www.nationalparks.org/","d":"National Parks Conservation Association protecting and enhancing America's national parks.","c":["outdoors-nature"]}, +{"t":"ParkAdvisor.com","u":"http://www.parkadvisor.com/","d":"National and state park reviews with campsite photos and trail conditions.","c":["outdoors-nature"]}, +{"t":"SummitPost.org","u":"http://www.summitpost.org/","d":"Collaborative climbing and mountaineering resource with route descriptions and photos.","c":["outdoors-nature"]}, +{"t":"The Geocaching Central","u":"https://www.geocachingcentral.com/","d":"A large directory of geocaching links.","c":["outdoors-nature"]}, +{"t":"TrailLink.com","u":"http://www.traillink.com/","d":"Rails-to-Trails Conservancy's trail finder with maps for biking and walking paths.","c":["outdoors-nature"]}, +{"t":"Trails.com","u":"https://www.trails.com/","d":"Hiking and biking trail guides across North America, with maps, photos and difficulty ratings.","c":["outdoors-nature"]}, +{"t":"WildflowerGuide.com","u":"http://www.wildflowerguide.com/","d":"Wildflower identification guide with photos organized by color and region.","c":["outdoors-nature"]}, +{"t":"AdoptAPet.net","u":"http://www.adoptapet.net/","d":"Searchable database of adoptable pets from shelters and rescue organizations.","c":["pets"]}, +{"t":"AKC","u":"https://www.akc.org/","d":"The American Kennel Club: dog breed information, event listings, breeder referrals.","c":["pets"]}, +{"t":"American Kennel Club Dog Shows","u":"https://www.akc.org/events/","d":"Dog show schedules, results and obedience trial information from the AKC.","c":["pets"]}, +{"t":"AnimalRescue.org","u":"http://www.animalrescue.org/","d":"Animal rescue organization directory with adoption events and volunteer opportunities.","c":["pets"]}, +{"t":"ASPCA","u":"https://www.aspca.org/","d":"The American Society for the Prevention of Cruelty to Animals. Pet care, adoption, advocacy.","c":["pets"]}, +{"t":"BirdChannel.com","u":"http://www.birdchannel.com/","d":"Bird care: species profiles, training tips, health information.","c":["pets"]}, +{"t":"BirdsCentral","u":"http://www.birdscentral.com/","d":"A bird guide: breed info, training tips, how-to articles.","c":["pets"]}, +{"t":"Cat Fanciers' Association","u":"https://www.cfainc.org/","d":"Cat breed profiles, show results and breeder referrals from the world's largest cat registry.","c":["pets"]}, +{"t":"Cats Central Online","u":"http://www.catscentral.com/","d":"A community devoted to cats: photos, stories, support.","c":["pets"]}, +{"t":"CatSter.com","u":"http://www.catster.com/","d":"Online cat magazine: breed profiles, health tips, community photos.","c":["pets"]}, +{"t":"DogBreedInfo.com","u":"http://www.dogbreedinfo.com/","d":"Dog breed information: photos, temperament descriptions, care requirements.","c":["pets"]}, +{"t":"Dogster","u":"https://www.dogster.com/","d":"Dog breed information, training tips and health advice, plus a community of dog lovers.","c":["pets"]}, +{"t":"DogTrainingNetwork","u":"http://www.dogtrainingnetwork.com/","c":["pets"]}, +{"t":"ExoticPetCare.com","u":"http://www.exoticpetcare.com/","d":"Care guides for exotic pets: reptiles, birds, small mammals.","c":["pets"]}, +{"t":"GuineaPigCare.com","u":"http://www.guineapigcare.com/","d":"A guinea pig care guide: housing, diet, health.","c":["pets"]}, +{"t":"HorseBreeds.net","u":"http://www.horsebreeds.net/","d":"A horse breed encyclopedia: photos, temperament, care requirements.","c":["pets"]}, +{"t":"HorseChannel.com","u":"http://www.horsechannel.com/","d":"An equine resource: horse breeds, care, training, equestrian sports.","c":["pets"]}, +{"t":"IVillage Pets","u":"http://www.ivillage.com/pets/","d":"A pet care community: health tips, training advice, photo galleries.","c":["pets"]}, +{"t":"ParrotForum.com","u":"http://www.parrotforum.com/","d":"A parrot and bird community: species guides, training, health tips.","c":["pets"]}, +{"t":"Pet Health Network Online","u":"http://www.pethealthnetwork.com/","c":["pets"]}, +{"t":"Pet Planet","u":"https://www.petplanet.co.uk/","d":"Pet supplies, care guides and breed information, for dogs, cats and small animals.","c":["pets"]}, +{"t":"Petfinder","u":"https://www.petfinder.com/","d":"Search adoptable pets from shelters across the US by breed, age, size or location.","c":["pets"]}, +{"t":"PetInsurance.com","u":"http://www.petinsurance.com/","d":"Pet insurance plan comparison with coverage reviews and claim data.","c":["pets"]}, +{"t":"PetMD.com","u":"http://www.petmd.com/","d":"Veterinary medical reference with symptom checkers and health information for pets.","c":["pets"]}, +{"t":"PetNutrition.com","u":"http://www.petnutrition.com/","d":"Family-owned U.S. maker of pet foods and treats sold under Sunshine brands and private labels.","c":["pets"]}, +{"t":"PetPlace.com","u":"http://www.petplace.com/","d":"Veterinary-reviewed pet health articles: nutrition, behavior, disease.","c":["pets"]}, +{"t":"PuppyTraining101.com","u":"http://www.puppytraining101.com/","d":"A new puppy training guide: house training, socialization, basic commands.","c":["pets"]}, +{"t":"RabbitCare.com","u":"http://www.rabbitcare.com/","d":"A pet rabbit care guide: housing, diet, health.","c":["pets"]}, +{"t":"Reptile Magazine","u":"https://www.reptilesmagazine.com/","d":"Reptile and amphibian care guides, species profiles, a herpetoculture community.","c":["pets"]}, +{"t":"ReptileChannel.com","u":"http://www.reptilechannel.com/","d":"Reptile and amphibian care: species profiles, habitats, feeding guides.","c":["pets"]}, +{"t":"SaltwaterReef.com","u":"http://www.saltwaterreef.com/","d":"Saltwater reef aquarium resource with coral care and equipment guides.","c":["pets"]}, +{"t":"The Dog Training Central","u":"http://www.dogtrainingcentral.com/","c":["pets"]}, +{"t":"The Dogs Central","u":"http://www.dogscentral.com/","d":"Resource for Dogs lovers with health tips and discussion forums.","c":["pets"]}, +{"t":"TurtleForum.com","u":"http://www.turtleforum.com/","d":"Pet turtle and tortoise community with care sheets and habitat setup.","c":["pets"]}, +{"t":"ArcheryEquipment.com","u":"http://www.archeryequipment.com/","d":"Archery bow and arrow equipment reviews with technique instruction.","c":["sports-recreation-sports"]}, +{"t":"ArcheryWorld.com","u":"http://www.archeryworld.com/","d":"Archery equipment reviews and competition schedules with technique guides.","c":["sports-recreation-sports"]}, +{"t":"ATP Tennis","u":"http://www.atpworldtour.com/","d":"The Association of Tennis Professionals: rankings, results, player profiles.","c":["sports-recreation-sports"]}, +{"t":"ATP Tour","u":"https://www.atptennis.com/","d":"The Association of Tennis Professionals: tournament schedules, rankings, player profiles.","c":["sports-recreation-sports"]}, +{"t":"Badminton.com","u":"http://www.badminton.com/","d":"Badminton sport resource with racket reviews and technique tutorials.","c":["sports-recreation-sports"]}, +{"t":"BaseballNetwork","u":"http://www.baseballnetwork.com/","d":"Baseball scores, schedules and analysis.","c":["sports-recreation-sports"]}, +{"t":"Basketball Central Online","u":"http://www.basketballcentral.com/","c":["sports-recreation-sports"]}, +{"t":"Bowling Central Online","u":"http://www.bowlingcentral.com/","d":"Sports hub tracking Bowling for dedicated followers.","c":["sports-recreation-sports"]}, +{"t":"BoxingNews24.com","u":"http://www.boxingnews24.com/","d":"Boxing news: fight schedules, results, pound-for-pound rankings.","c":["sports-recreation-sports"]}, +{"t":"CBS SportsLine","u":"http://www.cbs.sportsline.com/","d":"CBS Sports fantasy games, scores and news: NFL, NBA, MLB, college.","c":["sports-recreation-sports"]}, +{"t":"CBS SportsLine","u":"https://www.sportsline.com/","d":"Sports betting information with Las Vegas odds, expert picks, computer projections and daily fantasy lineups.","c":["sports-recreation-sports"]}, +{"t":"CricketWorld.com","u":"http://www.cricketworld.com/","d":"Cricket news: match coverage, player stats, rule guides.","c":["sports-recreation-sports"]}, +{"t":"CurlingZone.com","u":"http://www.curlingzone.com/","d":"A curling resource: club directory, rules, competition.","c":["sports-recreation-sports"]}, +{"t":"Cycling News","u":"http://www.cyclingnews.com/","d":"Professional cycling race coverage, results, rider interviews.","c":["sports-recreation-sports"]}, +{"t":"CyclingTips.com","u":"http://www.cyclingtips.com/","d":"Road cycling news, reviews and training tips, for recreational and competitive riders.","c":["sports-recreation-sports"]}, +{"t":"ESPN","u":"https://www.espn.com/","d":"The worldwide leader in sports. News, scores, standings and analysis across all major sports.","c":["sports-recreation-sports"]}, +{"t":"Fantasy Sports","u":"https://games.espn.go.com/frontpage/","d":"ESPN's fantasy football, baseball and basketball leagues, with live scoring.","c":["sports-recreation-sports"]}, +{"t":"FencingGuide.com","u":"http://www.fencingguide.com/","d":"A fencing resource: club directory, equipment, competition.","c":["sports-recreation-sports"]}, +{"t":"FieldHockey.com","u":"http://www.fieldhockey.com/","d":"Field hockey news with tournament coverage and coaching resources.","c":["sports-recreation-sports"]}, +{"t":"FIFA.com","u":"https://www.fifa.com/","d":"Football's international governing body. World Cup news, rankings, match schedules.","c":["sports-recreation-sports"]}, +{"t":"Football Network Online","u":"http://www.footballnetwork.com/","d":"A guide to football: coaching tips, rules, player profiles.","c":["sports-recreation-sports"]}, +{"t":"FootballCentral","u":"http://www.footballcentral.com/","d":"Articles on modern football tactics, influential coaches and emerging young players.","c":["sports-recreation-sports"]}, +{"t":"FOX Sports","u":"https://www.foxsports.com/","d":"Sports news, scores and highlights from the Fox Sports television network.","c":["sports-recreation-sports"]}, +{"t":"Golf Network Online","u":"http://www.golfnetwork.net/","d":"A golf resource for fans: scores, schedules, analysis.","c":["sports-recreation-sports"]}, +{"t":"GolfCentral","u":"http://www.golfcentral.com/","d":"A golf resource, with scores, schedules and analysis.","c":["sports-recreation-sports"]}, +{"t":"GolfDigest.com","u":"http://www.golfdigest.com/","d":"Golf magazine: instruction, equipment reviews, course rankings.","c":["sports-recreation-sports"]}, +{"t":"Hockey Central Online","u":"http://www.hockeycentral.org/","d":"A hockey site: news, stats, discussion.","c":["sports-recreation-sports"]}, +{"t":"HockeyGuide","u":"http://www.hockeyguide.com/","d":"Hockey, with news, stats and discussion.","c":["sports-recreation-sports"]}, +{"t":"LacrosseWorld.com","u":"http://www.lacrosseworld.com/","d":"Lacrosse news and equipment reviews with training drills.","c":["sports-recreation-sports"]}, +{"t":"LittleLeague.com","u":"http://www.littleleague.com/","d":"Little League Baseball with tournament schedules and coaching resources.","c":["sports-recreation-sports"]}, +{"t":"MapMyRun.com","u":"http://www.mapmyrun.com/","d":"Running route mapping tool with GPS tracking and training log.","c":["sports-recreation-sports"]}, +{"t":"MarathonGuide.com","u":"http://www.marathonguide.com/","d":"Marathon and half-marathon race directory with training plans and results.","c":["sports-recreation-sports"]}, +{"t":"MarchMadness.com","u":"http://www.marchmadness.com/","d":"NCAA page for live streams of every March Madness tournament game, from the First Four to the Final Four.","c":["sports-recreation-sports"]}, +{"t":"MLS Soccer","u":"https://www.mlssoccer.com/","d":"Major League Soccer: game schedules, standings, player statistics.","c":["sports-recreation-sports"]}, +{"t":"NASCAR.com","u":"https://www.nascar.com/","d":"Official site of NASCAR racing: live timing, standings, driver info, race schedules.","c":["sports-recreation-sports"]}, +{"t":"NBA.com","u":"https://www.nba.com/","d":"Official site of the National Basketball Association. Scores, stats, highlights.","c":["sports-recreation-sports"]}, +{"t":"NFL.com","u":"https://www.nfl.com","d":"Official site of the National Football League. Scores, stats, video, fantasy football.","c":["sports-recreation-sports"]}, +{"t":"NHL.com","u":"https://www.nhl.com/","d":"Official site of the National Hockey League. Scores, standings, player statistics.","c":["sports-recreation-sports"]}, +{"t":"Olympics.com","u":"https://www.olympic.org/","d":"The International Olympic Committee: results, athlete profiles, Games history.","c":["sports-recreation-sports"]}, +{"t":"PGA.com","u":"https://www.pga.com/","d":"The Professional Golfers' Association: tournament schedules, scores, player statistics.","c":["sports-recreation-sports"]}, +{"t":"RowingNews.com","u":"http://www.rowingnews.com/","d":"Rowing and crew sport news with regatta results and training plans.","c":["sports-recreation-sports"]}, +{"t":"Ski Magazine","u":"https://www.skimag.com/","d":"Ski resort reviews, gear guides and technique tips for downhill skiers.","c":["sports-recreation-sports"]}, +{"t":"SkiResortGuide.com","u":"http://www.skiresortguide.com/","d":"Ski resort reviews: snow reports, trail maps, lodging.","c":["sports-recreation-sports"]}, +{"t":"Soccer America","u":"https://www.socceramerica.com/","d":"American soccer news, MLS coverage and international football reporting.","c":["sports-recreation-sports"]}, +{"t":"Soccer Guide Online","u":"http://www.soccerguide.com/","c":["sports-recreation-sports"]}, +{"t":"SoccerNetwork","u":"http://www.soccernetwork.com/","c":["sports-recreation-sports"]}, +{"t":"SoftballCentral","u":"http://www.softballcentral.com/","d":"Softball coaching tips, rules and player profiles.","c":["sports-recreation-sports"]}, +{"t":"Sports Illustrated","u":"https://www.si.com/","d":"America's sports magazine, with long-form features, photography and analysis.","c":["sports-recreation-sports"]}, +{"t":"SportsCards.com","u":"http://www.sportscards.com/","d":"Store selling sports trading cards, autographs, memorabilia, supplies, mystery boxes and vintage set breaks.","c":["sports-recreation-sports"]}, +{"t":"Strava.com","u":"http://www.strava.com/","d":"Social fitness network for cyclists and runners with GPS tracking and segments.","c":["sports-recreation-sports"]}, +{"t":"Surfline","u":"https://www.surfline.com/","d":"Surf forecasts, live beach cams and wave reports, worldwide.","c":["sports-recreation-sports"]}, +{"t":"Swim Swam","u":"http://www.swimswam.com/","d":"Competitive swimming news: meet results, recruiting, training articles.","c":["sports-recreation-sports"]}, +{"t":"SwimmingNetwork","u":"http://www.swimmingnetwork.com/","d":"Swimming news, stats and discussion.","c":["sports-recreation-sports"]}, +{"t":"TableTennis.com","u":"http://www.tabletennis.com/","d":"Table tennis equipment reviews and technique guides for competitive play.","c":["sports-recreation-sports"]}, +{"t":"Tennis Guide Online","u":"http://www.tennisguide.net/","c":["sports-recreation-sports"]}, +{"t":"TennisNetwork","u":"http://www.tennisnetwork.org/","c":["sports-recreation-sports"]}, +{"t":"TennisWorld.com","u":"http://www.tennisworld.com/","d":"Tennis news, player rankings, tournament coverage worldwide.","c":["sports-recreation-sports"]}, +{"t":"The Baseball Central","u":"http://www.baseballcentral.com/","d":"For baseball fans: scores, schedules, analysis.","c":["sports-recreation-sports"]}, +{"t":"The Basketball Network","u":"http://www.basketballnetwork.net/","c":["sports-recreation-sports"]}, +{"t":"The Golf Guide","u":"http://www.golfguide.com/","d":"Golf articles covering equipment such as launch monitors and industry events like the PGA Show.","c":["sports-recreation-sports"]}, +{"t":"The Gymnastics Network","u":"http://www.gymnasticsnetwork.com/","d":"Community devoted to Gymnastics at the amateur and pro levels.","c":["sports-recreation-sports"]}, +{"t":"The Lacrosse Central","u":"http://www.lacrossecentral.com/","d":"For lacrosse fans: scores, schedules, analysis.","c":["sports-recreation-sports"]}, +{"t":"The Soccer Central","u":"http://www.soccercentral.net/","c":["sports-recreation-sports"]}, +{"t":"The Sporting News","u":"https://www.sportingnews.com/","d":"America's oldest sports publication. Analysis and award-winning coverage.","c":["sports-recreation-sports"]}, +{"t":"The Tennis Central","u":"http://www.tenniscentral.net/","c":["sports-recreation-sports"]}, +{"t":"The Track and Field Network","u":"http://www.trackandfieldnetwork.com/","d":"Track and field: coaching tips, rules, player profiles.","c":["sports-recreation-sports"]}, +{"t":"Track and Field Central Online","u":"http://www.trackandfieldcentral.com/","d":"Track and field meet schedules, results, records and streaming information from competitions worldwide.","c":["sports-recreation-sports"]}, +{"t":"TrackAndField.com","u":"http://www.trackandfield.com/","d":"Track and field athletics: records, event guides, training.","c":["sports-recreation-sports"]}, +{"t":"TriathlonTraining.com","u":"http://www.triathlontraining.com/","d":"Family-run company offering custom self-guided and van-supported bike tours in Italy, France, Spain and Portugal.","c":["sports-recreation-sports"]}, +{"t":"UFC.com","u":"http://www.ufc.com/","d":"Ultimate Fighting Championship: fighter profiles, event schedules, pay-per-view.","c":["sports-recreation-sports"]}, +{"t":"US Soccer","u":"https://www.ussoccer.com/","d":"The United States Soccer Federation. National team news, schedules, youth soccer resources.","c":["sports-recreation-sports"]}, +{"t":"VolleyballMag.com","u":"http://www.volleyballmag.com/","d":"Volleyball magazine with indoor and beach volleyball coverage.","c":["sports-recreation-sports"]}, +{"t":"Wrestling Network Online","u":"http://www.wrestlingnetwork.net/","c":["sports-recreation-sports"]}, +{"t":"WrestlingCentral","u":"http://www.wrestlingcentral.com/","c":["sports-recreation-sports"]}, +{"t":"WrestlingForum.com","u":"http://www.wrestlingforum.com/","d":"A professional wrestling community: news, results, fantasy booking.","c":["sports-recreation-sports"]}, +{"t":"WWE.com","u":"https://www.wwe.com/","d":"World Wrestling Entertainment: event schedules, wrestler profiles, pay-per-view info.","c":["sports-recreation-sports"]}, +{"t":"YouthSportsCoach.com","u":"http://www.youthsportscoach.com/","d":"Youth sports coaching resource with drill libraries and player development.","c":["sports-recreation-sports"]}, +{"t":"AdventureTravel.com","u":"https://adventuretravel.com/","d":"Full-service adventure travel agency based in Birmingham.","c":["travel-tourism"]}, +{"t":"AirportGuide.com","u":"http://www.airportguide.com/","d":"Aviation information for pilots and travelers: airport data, airlines, airmen search, FBO and fuel listings, flight status.","c":["travel-tourism"]}, +{"t":"BudgetTravel.com","u":"http://www.budgettravel.com/","d":"Budget travel tips: cheap flight alerts, hostel reviews, money-saving guides.","c":["travel-tourism"]}, +{"t":"CheapTickets","u":"https://www.cheaptickets.com/","d":"Discount airfare, hotel deals and vacation packages at rock-bottom prices.","c":["travel-tourism"]}, +{"t":"CouchSurfing.com","u":"http://www.couchsurfing.com/","d":"Hospitality exchange network connecting travelers with local hosts worldwide.","c":["travel-tourism"]}, +{"t":"CruiseReviews.com","u":"http://www.cruisereviews.com/","d":"Cruise ship reviews: cabin photos, dining guides, port excursion tips.","c":["travel-tourism"]}, +{"t":"EcoTourism.org","u":"http://www.ecotourism.org/","d":"Site of The International Ecotourism Society, with ecotourism news and sustainable tourism conference announcements.","c":["travel-tourism"]}, +{"t":"Expedia","u":"https://www.expedia.com/","d":"Book flights, hotels and rental cars, with a price match guarantee.","c":["travel-tourism"]}, +{"t":"FamilyVacation.com","u":"http://www.familyvacation.com/","d":"Family vacation planning with kid-friendly destinations and resort reviews.","c":["travel-tourism"]}, +{"t":"FlyerTalk.com","u":"http://www.flyertalk.com/","d":"A frequent flyer community, with forums on airline miles, credit cards and travel deals.","c":["travel-tourism"]}, +{"t":"Fodor's Travel","u":"https://www.fodors.com/","d":"Travel guides, hotel reviews and destination planning tools from Fodor's.","c":["travel-tourism"]}, +{"t":"Frommer's","u":"https://www.frommers.com/","d":"Budget travel guides with honest reviews, trip ideas and money-saving tips.","c":["travel-tourism"]}, +{"t":"HistoricHotels.com","u":"http://www.historichotels.com/","d":"Historic and heritage hotel directory with architectural tours.","c":["travel-tourism"]}, +{"t":"HostelWorld.com","u":"http://www.hostelworld.com/","d":"Hostel booking service with reviews and ratings for budget travelers.","c":["travel-tourism"]}, +{"t":"Hotels.com","u":"https://www.hotels.com/","d":"Hotel reservations worldwide with price guarantees and loyalty rewards.","c":["travel-tourism"]}, +{"t":"Hotwire","u":"https://www.hotwire.com/","d":"Opaque booking service offering deep discounts on hotels when you don't know the brand until after.","c":["travel-tourism"]}, +{"t":"Kayak.com","u":"http://www.kayak.com/","d":"A travel search engine comparing prices for flights, hotels and rental cars.","c":["travel-tourism"]}, +{"t":"Lonely Planet","u":"https://www.lonelyplanet.com/","d":"Independent travel guides and destination information, with community forums for travelers.","c":["travel-tourism"]}, +{"t":"Orbitz","u":"https://www.orbitz.com/","d":"Airline-owned travel booking, with competitive rates on flights, hotels and car rentals.","c":["travel-tourism"]}, +{"t":"Rick Steves' Europe","u":"https://www.ricksteves.com/","d":"European travel tips, itineraries and guidebook excerpts from the PBS travel host.","c":["travel-tourism"]}, +{"t":"RoadTripAmerica.com","u":"http://www.roadtripamerica.com/","d":"Cross-country road trip planning: routes, diaries, scenic byways.","c":["travel-tourism"]}, +{"t":"Rough Guides","u":"https://www.roughguides.com/","d":"Independent travel guides: destination advice, itineraries, budget travel tips.","c":["travel-tourism"]}, +{"t":"RVLifeMag.com","u":"http://www.rvlifemag.com/","d":"RV lifestyle magazine: campground reviews, route planning, maintenance tips.","c":["travel-tourism"]}, +{"t":"SeatGuru.com","u":"http://www.seatguru.com/","d":"Airplane seat maps and reviews helping travelers choose the best seats.","c":["travel-tourism"]}, +{"t":"The Hostels Central","u":"https://www.hostelscentral.com/","d":"Online booking for hostels and budget hotels, including eco-friendly and female-friendly options, with no booking fees.","c":["travel-tourism"]}, +{"t":"Travel Channel","u":"https://www.travelchannel.com/","d":"Destination guides, travel shows and vacation ideas from the Travel Channel network.","c":["travel-tourism"]}, +{"t":"TravelInsurance.com","u":"http://www.travelinsurance.com/","d":"Travel insurance comparison with policy reviews and claims process guides.","c":["travel-tourism"]}, +{"t":"Travelocity","u":"https://www.travelocity.com/","d":"Travel booking with a low price guarantee and 24/7 support. The roaming gnome is guaranteed.","c":["travel-tourism"]}, +{"t":"TripAdvisor","u":"https://www.tripadvisor.com/","d":"Traveler reviews of hotels, restaurants and attractions worldwide, with booking tools.","c":["travel-tourism"]}, +{"t":"VirtualTourist.com","u":"http://www.virtualtourist.com/","d":"A travel community: member tips, photos, reviews for destinations worldwide.","c":["travel-tourism"]}, +{"t":"WildlifeSafari.com","u":"http://www.wildlifesafari.com/","d":"Wildlife safari tour directory with conservation-focused operators.","c":["travel-tourism"]}, +{"t":"WineryTours.com","u":"http://www.winerytours.com/","d":"Wine country tour guide with vineyard visits and tasting experiences.","c":["travel-tourism"]}, +{"t":"YHA Hostels","u":"https://www.yha.org.uk/","d":"Youth Hostel Association with budget accommodation across England and Wales.","c":["travel-tourism"]}, +{"t":"DeLorme Maps","u":"https://www.delorme.com/","d":"Topographic maps, GPS receivers and the Atlas & Gazetteer series, for outdoor navigation.","c":["reference"]}, +{"t":"Encyclopedia Britannica","u":"https://www.britannica.com/","d":"The authoritative online encyclopedia from the world's oldest and most respected reference publisher.","c":["reference","encyclopedias"]}, +{"t":"Expedia Maps","u":"https://maps.expedia.com/","d":"Interactive street maps, satellite views and directions from the Expedia travel network.","c":["reference"]}, +{"t":"Geographic.org","u":"http://www.geographic.org/","d":"A worldwide geographic encyclopedia: country, city and topographic information.","c":["reference"]}, +{"t":"GeoNames.org","u":"http://www.geonames.org/","d":"Geographical database covering all countries with over 10 million place names.","c":["reference"]}, +{"t":"HistoricalAtlas.com","u":"http://www.historicalatlas.com/","d":"Historical map collection showing political boundaries through the centuries.","c":["reference"]}, +{"t":"MapQuest","u":"https://www.mapquest.com/","d":"The internet's original mapping service. Driving directions, maps, local business search.","c":["reference"]}, +{"t":"MarineCharts.com","u":"http://www.marinecharts.com/","d":"Boating news covering yachts, marinas, boat shows, charters, sailing, cruising, fishing and marine equipment.","c":["reference"]}, +{"t":"PostalCodeFinder.com","u":"http://www.postalcodefinder.com/","d":"International postal code lookup tool covering every country.","c":["reference"]}, +{"t":"Rand McNally","u":"https://www.randmcnally.com/","d":"Road maps, driving directions and trip planning, from Rand McNally.","c":["reference"]}, +{"t":"StreetAdvisor.com","u":"http://www.streetadvisor.com/","d":"Neighborhood reviews and ratings helping people find the best places to live.","c":["reference"]}, +{"t":"TerraServer","u":"http://www.terraserver.microsoft.com/","d":"Microsoft's satellite and aerial imagery database covering the United States.","c":["reference"]}, +{"t":"TopoZone","u":"https://www.topozone.com/","d":"Free USGS topographic maps online with elevation data and GPS waypoint marking.","c":["reference"]}, +{"t":"TrailMaps.com","u":"http://www.trailmaps.com/","d":"Hiking and biking trail maps with GPS downloads and elevation profiles.","c":["reference"]}, +{"t":"AlmanacOnline","u":"http://www.almanaconline.com/","d":"A general reference almanac: science, nature, people, places.","c":["almanacs"]}, +{"t":"Bartlett's Quotations","u":"https://www.bartleby.com/100/","d":"The classic collection of familiar quotations, searchable and browsable online.","c":["almanacs"]}, +{"t":"CalendarDate.com","u":"http://www.calendardate.com/","d":"A calendar tool: date calculations, countdowns, holiday schedules.","c":["almanacs"]}, +{"t":"Fact Monster","u":"http://www.factmonster.com/","d":"A kids' reference site: almanac, atlas, encyclopedia, homework help.","c":["almanacs"]}, +{"t":"Farmer's Almanac","u":"https://www.farmersalmanac.com/","d":"Long-range weather forecasts, planting guides and folklore, since 1818.","c":["almanacs"]}, +{"t":"HistoryOrb.com","u":"http://www.historyorb.com/","d":"Historical events database searchable by date with this-day-in-history features.","c":["almanacs"]}, +{"t":"HolidayCalendar.com","u":"http://www.holidaycalendar.com/","d":"International holiday calendar with cultural context and celebration traditions.","c":["almanacs"]}, +{"t":"Infoplease","u":"https://www.infoplease.com/","d":"An online almanac: encyclopedia, dictionary, atlas and statistics in one place.","c":["almanacs"]}, +{"t":"Old Farmer's Almanac","u":"https://www.almanac.com/","d":"Weather forecasts, gardening tips and astronomical data from America's oldest almanac, going since 1792.","c":["almanacs"]}, +{"t":"QuoteDB.com","u":"http://www.quotedb.com/","d":"Quotation database with famous quotes organized by author and topic.","c":["almanacs"]}, +{"t":"ReFDesk.com","u":"http://www.refdesk.com/","d":"A free internet reference desk, linking to encyclopedias, dictionaries and fact checkers.","c":["almanacs"]}, +{"t":"SunriseSunset.com","u":"http://www.sunrisesunset.com/","d":"Sunrise and sunset times for any location with twilight and moon data.","c":["almanacs"]}, +{"t":"ThisDayInHistory.com","u":"http://www.thisdayinhistory.com/","d":"Historical events for every day of the year with photos and context.","c":["almanacs"]}, +{"t":"TimeAndDate.com","u":"http://www.timeanddate.com/","d":"World clock, calendar and countdown tools, with time zone information.","c":["almanacs"]}, +{"t":"World Almanac","u":"https://www.worldalmanac.com/","d":"Facts, statistics and historical reference from the best-selling almanac since 1868.","c":["almanacs"]}, +{"t":"WorldCapitals.com","u":"http://www.worldcapitals.com/","d":"World capitals quiz and reference with country profiles.","c":["almanacs"]}, +{"t":"WorldClock.com","u":"http://www.worldclock.com/","d":"World time zone clock with daylight saving time information.","c":["almanacs"]}, +{"t":"WorldStatistics.org","u":"http://www.worldstatistics.org/","d":"Global statistical data: population, economy, development indicators.","c":["almanacs"]}, +{"t":"Acronym Finder","u":"https://www.acronymfinder.com/","d":"A database of over 750,000 abbreviations, acronyms and initialisms, across all subjects.","c":["dictionaries"]}, +{"t":"Cambridge Dictionary","u":"https://dictionary.cambridge.org/","d":"English definitions, pronunciation and grammar resources from Cambridge University Press.","c":["dictionaries"]}, +{"t":"Dictionary.com","u":"https://www.dictionary.com/","d":"Free online dictionary: over 2 million definitions, audio pronunciation, a thesaurus.","c":["dictionaries"]}, +{"t":"Etymology Online Online","u":"http://www.etymologyonline.com/","d":"Dictionary portal providing Etymology for writers and students.","c":["dictionaries"]}, +{"t":"EtymologyOnline.com","u":"http://www.etymonline.com/","d":"Online etymology dictionary tracing the origins and history of English words.","c":["dictionaries"]}, +{"t":"MedicalDictionary.com","u":"http://www.medicaldictionary.com/","d":"Free veterinary medical dictionary: conditions, symptoms, medications and anatomy for dogs and cats.","c":["dictionaries"]}, +{"t":"MedicalDictionaryOnline","u":"http://www.medicaldictionaryonline.com/","d":"Dictionary portal providing Medical Dictionary for writers and students.","c":["dictionaries"]}, +{"t":"Merriam-Webster Online","u":"https://www.merriam-webster.com/","d":"America's dictionary and thesaurus, online, with audio pronunciation.","c":["dictionaries"]}, +{"t":"OneLook Dictionary","u":"https://www.onelook.com/","d":"Search 900+ online dictionaries at once: definitions, translations, word origins.","c":["dictionaries"]}, +{"t":"RhymeZone.com","u":"http://www.rhymezone.com/","d":"Rhyming dictionary and thesaurus for poets and songwriters.","c":["dictionaries"]}, +{"t":"TechnicalTerms.com","u":"http://www.technicalterms.com/","d":"A technology terminology dictionary: IT, programming, internet.","c":["dictionaries"]}, +{"t":"The English Dictionary Online","u":"http://www.englishdictionaryonline.com/","d":"A reference English dictionary.","c":["dictionaries"]}, +{"t":"The Visual Dictionary Online","u":"http://www.visualdictionaryonline.com/","d":"A visual dictionary, for reference.","c":["dictionaries"]}, +{"t":"Thesaurus Online Online","u":"http://www.thesaurusonline.com/","d":"Word reference site featuring a searchable Thesaurus.","c":["dictionaries"]}, +{"t":"Thesaurus.com","u":"https://www.thesaurus.com/","c":["dictionaries"]}, +{"t":"Urbandictionary.com","u":"https://www.urbandictionary.com/","d":"Slang dictionary written by users defining the latest street language and internet culture terms.","c":["dictionaries"]}, +{"t":"Wordnik.com","u":"http://www.wordnik.com/","d":"The world's biggest online English dictionary with real-world example sentences.","c":["dictionaries"]}, +{"t":"YourDictionary","u":"https://www.yourdictionary.com/","d":"Online dictionary pulling definitions from multiple sources, plus word games and grammar guides.","c":["dictionaries"]}, +{"t":"AnswerBag.com","u":"http://www.answerbag.com/","d":"Question and answer community where users ask and answer on any topic.","c":["encyclopedias"]}, +{"t":"BiographyBase.com","u":"http://www.biographybase.com/","d":"Biographical database of famous people with timelines and achievement summaries.","c":["encyclopedias"]}, +{"t":"Catholic Encyclopedia","u":"http://www.newadvent.org/cathen/","d":"Online edition of the 1913 Catholic Encyclopedia with 11,000+ articles.","c":["encyclopedias"]}, +{"t":"Citizendium","u":"http://www.citizendium.org/","d":"A wiki encyclopedia guided by specialists, started by Wikipedia co-founder Larry Sanger.","c":["encyclopedias"]}, +{"t":"Columbia Encyclopedia","u":"https://www.bartleby.com/65/","d":"The Columbia Encyclopedia online: over 50,000 entries covering all subjects.","c":["encyclopedias"]}, +{"t":"CountryProfiles.com","u":"http://www.countryprofiles.com/","d":"A country information database: demographics, geography, government data.","c":["encyclopedias"]}, +{"t":"Everything2","u":"https://www.everything2.com/","d":"Collaborative writing community and encyclopedic database of user-contributed factual and creative nodes.","c":["encyclopedias"]}, +{"t":"h2g2","u":"https://www.h2g2.com/","d":"The BBC's community-written guide to life, the universe and everything. After Douglas Adams.","c":["encyclopedias"]}, +{"t":"HistoryEncyclopedia.com","u":"http://www.historyencyclopedia.com/","d":"A world history encyclopedia: timelines, maps, primary source documents.","c":["encyclopedias"]}, +{"t":"HowStuffWorks","u":"https://www.howstuffworks.com/","d":"Clear explanations of how everything works: from engines to the internet to volcanoes.","c":["encyclopedias"]}, +{"t":"InventionTimeline.com","u":"http://www.inventiontimeline.com/","d":"Timeline of inventions with descriptions and inventor biographies.","c":["encyclopedias"]}, +{"t":"KnowledgeBaseOnline","u":"http://www.knowledgebaseonline.com/","d":"Knowledge hub providing Knowledge Base with cross-referenced entries.","c":["encyclopedias"]}, +{"t":"MusicEncyclopedia.com","u":"http://www.musicencyclopedia.com/","d":"A music encyclopedia: genres, instruments, theory, artist biographies.","c":["encyclopedias"]}, +{"t":"QuotesDatabase.com","u":"http://www.quotesdatabase.com/","d":"Inspirational and famous quotes database searchable by topic and author.","c":["encyclopedias"]}, +{"t":"Scholarpedia","u":"http://www.scholarpedia.org/","d":"A peer-reviewed open-access encyclopedia, written and maintained by scholars.","c":["encyclopedias"]}, +{"t":"Snopes","u":"https://www.snopes.com/","d":"The definitive urban legends and internet rumors fact-checking reference since 1994.","c":["encyclopedias"]}, +{"t":"StatisticsOnline.com","u":"http://www.statisticsonline.com/","d":"A statistical data archive: country, population and economic indicators.","c":["encyclopedias"]}, +{"t":"The Straight Dope","u":"https://www.straightdope.com/","d":"Cecil Adams answers life's most persistent questions with wit, research and a sharp pen.","c":["encyclopedias"]}, +{"t":"Wikipedia","u":"https://www.wikipedia.org/","d":"The free encyclopedia anyone can edit, launched January 2001. Already tens of thousands of articles.","c":["encyclopedias"]}, +{"t":"Wikipedia Simple English","u":"https://simple.wikipedia.org/","d":"Simplified English Wikipedia for basic English speakers and children learning to read.","c":["encyclopedias"]}, +{"t":"WorldBook Online","u":"http://www.worldbookonline.com/","d":"World Book Encyclopedia online with age-appropriate articles for students.","c":["encyclopedias"]}, +{"t":"411.com","u":"http://www.411.com/","d":"People and business search with phone number lookups and address directories.","c":["phone-books"]}, +{"t":"AnyWho","u":"https://www.anywho.com/","d":"AT&T's online phone directory: white pages, yellow pages, reverse lookup.","c":["phone-books"]}, +{"t":"AreaCodeFinder.com","u":"http://www.areacodefinder.com/","d":"Telephone area code lookup with state and city maps.","c":["phone-books"]}, +{"t":"BusinessFax.com","u":"http://www.businessfax.com/","d":"Business fax number directory with online fax service comparisons.","c":["phone-books"]}, +{"t":"CellPhoneDirectory.com","u":"http://www.cellphonedirectory.com/","d":"Mobile phone number lookup service with carrier identification.","c":["phone-books"]}, +{"t":"GlobalPhoneBook.com","u":"http://www.globalphonebook.com/","d":"International phone directory with residential and business lookups worldwide.","c":["phone-books"]}, +{"t":"InfoSpace","u":"https://www.infospace.com/","d":"White and yellow pages, with people search, a business finder and reverse lookup.","c":["phone-books"]}, +{"t":"InternationalCodes.com","u":"http://www.internationalcodes.com/","d":"International calling code directory with country codes and area codes.","c":["phone-books"]}, +{"t":"PeopleFinders.com","u":"http://www.peoplefinders.com/","d":"People search: background checks, phone lookups, records.","c":["phone-books"]}, +{"t":"Reverse Phone Lookup","u":"https://www.reversephonedirectory.com/","d":"Free reverse phone number lookup identifying callers by phone number.","c":["phone-books"]}, +{"t":"ReversePhoneLookup.com","u":"http://www.reversephonelookup.com/","d":"Reverse phone number lookup identifying unknown callers.","c":["phone-books"]}, +{"t":"SuperPages","u":"https://www.superpages.com/","d":"Local business directory with yellow pages listings and residential white pages for the US.","c":["phone-books","united-states"]}, +{"t":"Switchboard","u":"https://www.switchboard.com/","d":"People finder and business directory with reverse phone lookup and address search.","c":["phone-books"]}, +{"t":"Teldir.com","u":"http://www.teldir.com/","d":"Links to online telephone directories from countries around the world.","c":["phone-books"]}, +{"t":"WhitePages","u":"https://www.whitepages.com/","d":"Online people finder with reverse address lookup and phone number search.","c":["phone-books"]}, +{"t":"Yellow Pages Online","u":"https://www.yellowpages.com/","d":"Business directory: local listings, maps, customer reviews.","c":["phone-books"]}, +{"t":"Zabasearch.com","u":"http://www.zabasearch.com/","d":"A free people search engine: name, address and phone number lookups.","c":["phone-books"]}, +{"t":"Zipcode.com","u":"http://www.zipcode.com/","d":"ZIP code lookup: demographics, maps, distance calculations.","c":["phone-books"]}, +{"t":"Argentina Portal Online","u":"http://www.argentinaportal.com/","c":["regional"]}, +{"t":"ArgentinaGuide","u":"http://www.argentinaguide.com/","c":["regional"]}, +{"t":"BrazilGuide.com","u":"http://www.brazilguide.com/","d":"A Brazil travel and culture guide: Carnival, beaches, Amazon tours.","c":["regional"]}, +{"t":"BuenosAiresHerald","u":"http://www.buenosairesherald.com/","d":"Argentina's English-language newspaper with local and regional news.","c":["regional"]}, +{"t":"CaribbeanNetNews","u":"http://www.caribbeannetnews.com/","d":"Regional news covering the Caribbean islands and territories.","c":["regional"]}, +{"t":"CaribbeanTravel.com","u":"http://www.caribbeantravel.com/","d":"A Caribbean island guide: resort reviews, cruise ports, activity planning.","c":["regional"]}, +{"t":"Cartagena Guide","u":"http://www.cartagenaguide.com/","d":"A Cartagena travel guide: Caribbean beaches, the walled city, colonial history.","c":["regional"]}, +{"t":"Clarin","u":"https://www.clarin.com/","d":"Argentina's largest newspaper covering national and world news in Spanish.","c":["regional"]}, +{"t":"CubaNet.org","u":"http://www.cubanet.org/","d":"Independent news service covering Cuba from an exile media perspective.","c":["regional"]}, +{"t":"Cusco Guide","u":"http://www.cuscoguide.com/","d":"A Cusco travel guide: Inca heritage, the gateway to Machu Picchu, Andean textiles.","c":["regional"]}, +{"t":"El Nuevo Herald","u":"https://www.elnuevoherald.com/","d":"Spanish-language newspaper serving the South Florida Hispanic community.","c":["regional"]}, +{"t":"El Universal Mexico","u":"https://www.eluniversal.com.mx/","d":"One of Mexico's oldest and most respected national newspapers in Spanish.","c":["regional"]}, +{"t":"Folha de Sao Paulo","u":"https://www.folha.uol.com.br/","d":"Brazil's highest-circulation newspaper with news and analysis in Portuguese.","c":["regional"]}, +{"t":"Granma Cuba","u":"https://www.granma.cu/","d":"Official newspaper of the Communist Party of Cuba with government news and policy.","c":["regional"]}, +{"t":"Jamaica Gleaner","u":"https://www.jamaica-gleaner.com/","d":"Jamaica's oldest daily newspaper with Caribbean news and culture.","c":["regional"]}, +{"t":"JamaicaGuide","u":"http://www.jamaicaguide.com/","d":"Jamaica: maps, history, visitor tips.","c":["regional"]}, +{"t":"JamaicaObserver.com","u":"http://www.jamaicaobserver.com/","d":"Jamaica's daily newspaper: island news, sports, entertainment.","c":["regional"]}, +{"t":"Latin Business Chronicle","u":"http://www.latinbusinesschronicle.com/","d":"Business news and analysis covering Latin American economies and industries.","c":["regional"]}, +{"t":"LatinTrade.com","u":"http://www.latintrade.com/","d":"Business magazine covering trade and investment across Latin America.","c":["regional"]}, +{"t":"Lima Guide","u":"http://www.limaguide.com/","d":"A Lima travel guide: ceviche, colonial architecture, pre-Inca ruins.","c":["regional"]}, +{"t":"MercoPress","u":"http://www.mercopress.com/","d":"A South Atlantic news agency covering Mercosur, Latin America and Antarctica.","c":["regional"]}, +{"t":"MexConnect.com","u":"http://www.mexconnect.com/","d":"A Mexico information portal: travel guides, culture, expat resources.","c":["regional"]}, +{"t":"Mexico News Daily","u":"http://www.mexiconewsdaily.com/","d":"English-language news from Mexico: politics, economy, travel.","c":["regional"]}, +{"t":"MexicoCity Guide","u":"http://www.mexicocityguide.com/","d":"A Mexico City travel guide: Aztec ruins, great museums, street tacos.","c":["regional"]}, +{"t":"Peru Guide Online","u":"http://www.peruguide.org/","d":"Peru news, travel and culture.","c":["regional"]}, +{"t":"Quito Guide","u":"http://www.quitoguide.com/","d":"A Quito travel guide: the colonial old town, the equator monument, Andean markets.","c":["regional"]}, +{"t":"Reforma","u":"https://www.reforma.com/","d":"A Mexican newspaper covering news, business and culture, in Spanish.","c":["regional"]}, +{"t":"SaoPaulo Guide","u":"http://www.saopauloguide.com/","d":"A Sao Paulo travel guide: urban art, gastronomy, Paulista avenue nightlife.","c":["regional"]}, +{"t":"SouthAmericaGuide","u":"http://www.southamericaguide.com/","d":"South America, with news, travel and culture.","c":["regional"]}, +{"t":"The Mexico Guide","u":"http://www.mexicoguide.com/","d":"Guide to Mexico with destination information and local insight.","c":["regional"]}, +{"t":"The Puerto Rico Guide","u":"http://www.puertoricoguide.com/","d":"A regional portal for Puerto Rico: news, travel, culture.","c":["regional"]}, +{"t":"Tico Times","u":"https://www.ticotimes.net/","d":"Costa Rica's English-language newspaper covering Central American news and travel.","c":["regional"]}, +{"t":"TicoBeat.com","u":"http://www.ticobeat.com/","d":"Costa Rica news in English: tourism, expat life, current events.","c":["regional"]}, +{"t":"TrinidadExpress","u":"http://www.trinidadexpress.com/","d":"Trinidad and Tobago's daily newspaper with local and Caribbean news.","c":["regional"]}, +{"t":"AfricanCrisis.org","u":"http://www.africancrisis.org/","d":"News and analysis focused on current events and security across Africa.","c":["africa"]}, +{"t":"AfricaNewsWire.com","u":"http://www.africanewswire.com/","d":"African news aggregator compiling stories from over 100 news organizations.","c":["africa"]}, +{"t":"AfricanWildlifeGuide","u":"http://www.africanwildlifeguide.com/","d":"African wildlife, with news, travel and culture.","c":["africa"]}, +{"t":"AfricaOnline.com","u":"http://www.africaonline.com/","d":"Internet services and content portal for African users and the diaspora.","c":["africa"]}, +{"t":"AllAfrica","u":"https://allafrica.com/","d":"News and information aggregated from over 130 African countries and publications.","c":["africa"]}, +{"t":"AllAfrica News","u":"https://allafrica.com/stories/","d":"Latest news stories from across the African continent aggregated from local media sources.","c":["africa"]}, +{"t":"Business Day South Africa","u":"https://www.bday.co.za/","d":"South Africa's financial daily newspaper covering markets and business.","c":["africa"]}, +{"t":"Cairo Travel","u":"http://www.cairotravel.com/","d":"Tour operator offering private, tailor-made Egypt travel packages covering Cairo, Alexandria, Aswan and Luxor.","c":["africa"]}, +{"t":"CapeTown Travel","u":"http://www.capetowntravel.com/","d":"Tailor-made African safari and tour planning service with itineraries, local expertise and trip support.","c":["africa"]}, +{"t":"Daily Nation Kenya","u":"https://www.nationmedia.com/","d":"East Africa's largest media group, with news from Kenya, Uganda and Tanzania.","c":["africa"]}, +{"t":"Dakar Travel","u":"http://www.dakartravel.com/","d":"A Dakar tourism guide: the music scene, the island of Goree, Senegalese cuisine.","c":["africa"]}, +{"t":"Ethiopia Guide Online","u":"http://www.ethiopiaguide.com/","d":"Community hub focused on Ethiopia and its people.","c":["africa"]}, +{"t":"GhanaGuide","u":"http://www.ghanaguide.com/","d":"Guide to Ghana with destination information and local insight.","c":["africa"]}, +{"t":"Kenya Guide Online","u":"http://www.kenyaguide.com/","d":"Resource exploring Kenya for travelers and researchers.","c":["africa"]}, +{"t":"KenyaWeb.com","u":"http://www.kenyaweb.com/","d":"Kenyan accredited domain registrar offering website design and development plus web and email hosting.","c":["africa"]}, +{"t":"Kilimanjaro Travel","u":"http://www.kilimanjarotravel.com/","d":"Arusha-based tour operator offering Tanzania safaris, Kilimanjaro treks, Zanzibar holidays, cultural tours and car hire.","c":["africa"]}, +{"t":"Mail & Guardian","u":"https://www.mg.co.za/","d":"South Africa's independent investigative newspaper covering politics and culture.","c":["africa"]}, +{"t":"Marrakech Travel","u":"http://www.marrakechtravel.com/","d":"A Marrakech tourism guide: souks, riads, Atlas Mountain excursions.","c":["africa"]}, +{"t":"MoroccoTravel.com","u":"http://www.moroccotravel.com/","d":"A Morocco tourism guide: imperial cities, desert excursions, cuisine.","c":["africa"]}, +{"t":"NairaLand Forum","u":"http://www.nairaland.com/","d":"Nigeria's largest online forum: news, politics, culture.","c":["africa"]}, +{"t":"Nairobi Standard","u":"https://www.eastandard.net/","d":"Kenya's Standard newspaper: East African news, politics, business.","c":["africa"]}, +{"t":"Nairobi Travel","u":"http://www.nairobitravel.com/","d":"A Nairobi tourism guide: the safari gateway, Masai Mara, national museums.","c":["africa"]}, +{"t":"News24 South Africa","u":"http://www.news24.com/","d":"A South African news website, with breaking news and analysis.","c":["africa"]}, +{"t":"Nigeria Portal Online","u":"http://www.nigeriaportal.com/","c":["africa"]}, +{"t":"Panapress","u":"http://www.panapress.com/","d":"Pan-African news agency covering stories from all 54 African countries.","c":["africa"]}, +{"t":"SA Tourism","u":"http://www.southafrica.net/","d":"The South African tourism board: travel information, attractions, trip planning.","c":["africa"]}, +{"t":"SafariGuide.com","u":"http://www.safariguide.com/","d":"African safari planning resource with lodge reviews and wildlife guides.","c":["africa"]}, +{"t":"SenegalGuide","u":"http://www.senegalguide.com/","d":"A reference on Senegal, with maps, history and visitor tips.","c":["africa"]}, +{"t":"Serengeti Travel","u":"http://www.serengetitravel.com/","d":"Serengeti tourism guide featuring great migration safari with game drives and lodges.","c":["africa"]}, +{"t":"The East African","u":"https://www.theeastafrican.co.ke/","d":"A regional weekly covering Kenya, Uganda, Tanzania and the East African community.","c":["africa"]}, +{"t":"The Egypt Guide","u":"http://www.egyptguide.net/","d":"Egypt: maps, history, visitor tips.","c":["africa"]}, +{"t":"The South Africa Guide","u":"http://www.southafricaguide.com/","d":"Guide to South Africa with destination information and local insight.","c":["africa"]}, +{"t":"This Day Nigeria","u":"https://www.thisdayonline.com/","d":"Nigerian daily covering politics, business and culture in West Africa.","c":["africa"]}, +{"t":"Victoria Falls Travel","u":"http://www.victoriafallstravel.com/","d":"Victoria Falls tourism guide featuring one of the seven natural wonders with adventure activities.","c":["africa"]}, +{"t":"Asahi Shimbun","u":"https://www.asahi.com/english/","d":"The English edition of one of Japan's national newspapers.","c":["asia"]}, +{"t":"Asia Times Online","u":"http://www.atimes.com/","d":"English-language news on politics, business and culture across Asia.","c":["asia"]}, +{"t":"AsianTravelGuide","u":"http://www.asiantravelguide.org/","d":"Resource exploring Asian Travel for travelers and researchers.","c":["asia"]}, +{"t":"AsiaPortal.com","u":"http://www.asiaportal.com/","d":"A news portal covering business, tech and lifestyle across the Asia-Pacific.","c":["asia"]}, +{"t":"AsiaTravel.com","u":"http://www.asiatravel.com/","d":"Southeast Asia travel guide with hotel bookings and tour packages.","c":["asia"]}, +{"t":"BaliGuide.com","u":"http://www.baliguide.com/","d":"Bali tourism: temples, rice terraces, surf spots.","c":["asia"]}, +{"t":"Bangkok Post","u":"https://www.bangkokpost.com/","d":"Thailand's English-language newspaper, with Thai and Southeast Asian news.","c":["asia"]}, +{"t":"Channel NewsAsia","u":"http://www.channelnewsasia.com/","d":"Singapore-based 24-hour Asian news channel with business and lifestyle coverage.","c":["asia"]}, +{"t":"Dawn.com","u":"http://www.dawn.com/","d":"Pakistan's oldest and most widely read English-language newspaper.","c":["asia"]}, +{"t":"Hanoi Travel Guide","u":"http://www.hanoitravel.com/","d":"A Hanoi visitor guide: the Old Quarter, street food, French colonial architecture.","c":["asia"]}, +{"t":"HoChiMinh Travel Guide","u":"http://www.hochiminhtravel.com/","d":"A Ho Chi Minh City visitor guide: war history, French influence, busy street life.","c":["asia"]}, +{"t":"HongKongGuide.com","u":"http://www.hongkongguide.com/","d":"Hong Kong tourism: dim sum, markets, harbor views.","c":["asia"]}, +{"t":"India Today","u":"http://www.indiatoday.in/","d":"An Indian news magazine: politics, business, culture.","c":["asia"]}, +{"t":"IndiaGuide","u":"http://www.indiaguide.com/","c":["asia"]}, +{"t":"IndonesiaPortal","u":"http://www.indonesiaportal.com/","d":"Resource exploring Indonesia for travelers and researchers.","c":["asia"]}, +{"t":"Jakarta Post","u":"https://www.thejakartapost.com/","d":"Indonesia's English-language daily, covering Southeast Asian affairs.","c":["asia"]}, +{"t":"Japan Guide","u":"https://www.japan-guide.com/","d":"A guide to Japan: culture, travel, living, language.","c":["asia"]}, +{"t":"JapanGuide.com","u":"http://www.japanguide.com/","d":"A Japan travel and culture guide, updated daily.","c":["asia"]}, +{"t":"Kyoto Travel Guide","u":"http://www.kyototravel.com/","d":"A Kyoto visitor guide: ancient temples, geisha districts, traditional tea houses.","c":["asia"]}, +{"t":"MalaysiaPortal","u":"http://www.malaysiaportal.com/","d":"A regional portal for Malaysia: news, travel, culture.","c":["asia"]}, +{"t":"Philippines Portal Online","u":"http://www.philippinesportal.com/","d":"The Philippines, with maps, history and visitor tips.","c":["asia"]}, +{"t":"PhilippinesGuide","u":"http://www.philippinesguide.com/","d":"A reference on the Philippines: maps, history, visitor tips.","c":["asia"]}, +{"t":"SeoulGuide.com","u":"http://www.seoulguide.com/","d":"A Seoul visitor guide: palaces, K-culture, Korean BBQ.","c":["asia"]}, +{"t":"SingaporeGuide.com","u":"http://www.singaporeguide.com/","d":"A Singapore visitor guide: hawker food, gardens, architecture.","c":["asia"]}, +{"t":"South China Morning Post","u":"https://www.scmp.com/","d":"Hong Kong's English-language newspaper covering Greater China and Asian business.","c":["asia"]}, +{"t":"South Korea Guide Online","u":"http://www.southkoreaguide.org/","d":"A regional portal for South Korea: news, travel, culture.","c":["asia"]}, +{"t":"Straits Times","u":"https://www.straitstimes.com/","d":"Singapore's English-language newspaper, covering Southeast Asian and world news.","c":["asia"]}, +{"t":"Taipei Times","u":"http://www.taipeitimes.com/","d":"Taiwan's English-language newspaper with domestic and international news.","c":["asia"]}, +{"t":"Taipei Travel Guide","u":"http://www.taipeitravel.com/","d":"A Taipei visitor guide: night markets, hot springs, modern Buddhist temples.","c":["asia"]}, +{"t":"ThailandPortal","u":"http://www.thailandportal.com/","d":"Thailand: maps, history, visitor tips.","c":["asia"]}, +{"t":"The Hindu","u":"https://www.thehindu.com/","d":"India's national newspaper, covering Indian and international affairs.","c":["asia"]}, +{"t":"The Korea Herald","u":"https://www.koreaherald.com/","d":"South Korea's largest English-language newspaper with Korean news and culture.","c":["asia"]}, +{"t":"The Malaysia Guide","u":"http://www.malaysiaguide.com/","d":"Malaysia news, travel and culture.","c":["asia"]}, +{"t":"The Thailand Guide","u":"http://www.thailandguide.net/","d":"Thailand, with maps, history and visitor tips.","c":["asia"]}, +{"t":"Times of India","u":"https://www.timesofindia.com/","d":"India's largest English-language daily newspaper with national and world news.","c":["asia"]}, +{"t":"TokyoGuide.com","u":"http://www.tokyoguide.com/","d":"A Tokyo travel guide: transport, dining, shopping, culture.","c":["asia"]}, +{"t":"VietnamGuide","u":"http://www.vietnamguide.com/","d":"Guide to Vietnam with destination information and local insight.","c":["asia"]}, +{"t":"BritishColumbiaGuide","u":"http://www.britishcolumbiaguide.com/","d":"Community hub focused on British Columbia and its people.","c":["canada"]}, +{"t":"Calgary Herald","u":"http://www.calgaryherald.com/","d":"Calgary's daily newspaper with Alberta news and energy industry coverage.","c":["canada"]}, +{"t":"Canada.com","u":"https://www.canada.com/","d":"Canadian news and local information, with a regional web directory.","c":["canada"]}, +{"t":"CanadaCool.com","u":"http://www.canadacool.com/","d":"Canadian travel and culture guide with provincial highlights and events.","c":["canada"]}, +{"t":"CanadaTourism.com","u":"http://www.canadatourism.com/","d":"A Canada tourism guide: national parks, cities, cultural attractions.","c":["canada"]}, +{"t":"CanadianLiving.com","u":"http://www.canadianliving.com/","d":"Canadian lifestyle magazine: recipes, health, home decorating.","c":["canada"]}, +{"t":"Canoe.ca","u":"http://www.canoe.ca/","d":"A Canadian portal: news, entertainment, money, lifestyle.","c":["canada"]}, +{"t":"CBC.ca","u":"https://www.cbc.ca/","d":"The Canadian Broadcasting Corporation: news, sports, entertainment, streaming audio.","c":["canada"]}, +{"t":"Edmontonian","u":"https://www.edmonton.ca/","d":"City of Edmonton, Alberta official portal with services and visitor information.","c":["canada"]}, +{"t":"Maclean's","u":"https://www.macleans.ca/","d":"Canada's national news magazine: current affairs, politics, culture.","c":["canada"]}, +{"t":"MapleLeafWeb.com","u":"http://www.mapleleafweb.com/","d":"Canadian politics and government resource with political history and analysis.","c":["canada"]}, +{"t":"Montreal Gazette","u":"http://www.montrealgazette.com/","d":"Montreal's English-language daily newspaper with Quebec and national news.","c":["canada"]}, +{"t":"MontrealGuide","u":"http://www.montrealguide.com/","d":"A reference on Montreal: maps, history, visitor tips.","c":["canada"]}, +{"t":"National Post","u":"https://www.nationalpost.com/","d":"Canadian national newspaper: political analysis, business, arts.","c":["canada"]}, +{"t":"Ottawa Citizen","u":"http://www.ottawacitizen.com/","d":"Ottawa's newspaper: Parliament Hill, local news, national politics.","c":["canada"]}, +{"t":"Quebec Guide Online","u":"http://www.quebecguide.com/","d":"Resource exploring Quebec for travelers and residents.","c":["canada"]}, +{"t":"The Canadian Rockies Guide","u":"http://www.canadianrockiesguide.com/","d":"The Canadian Rockies: news, travel, culture.","c":["canada"]}, +{"t":"Toronto Star","u":"https://www.thestar.com/","d":"Canada's largest daily newspaper with Toronto and national news coverage.","c":["canada"]}, +{"t":"Vancouver Guide Online","u":"http://www.vancouverguide.com/","d":"Community hub focused on Vancouver and its people.","c":["canada"]}, +{"t":"Vancouver Sun","u":"https://www.vancouversun.com/","d":"British Columbia's newspaper, covering Vancouver and western Canada.","c":["canada"]}, +{"t":"Aftenposten Norway","u":"https://www.aftenposten.no/","d":"Norway's largest newspaper covering Scandinavian and European news.","c":["europe"]}, +{"t":"Athens Travel Guide","u":"http://www.athensguide.com/","d":"An Athens visitor guide: the ancient Acropolis, taverna dining, island ferries.","c":["europe"]}, +{"t":"AustriaGuide","u":"http://www.austriaguide.com/","d":"A reference on Austria: maps, history, visitor tips.","c":["europe"]}, +{"t":"BarcelonaGuide.com","u":"http://www.barcelonaguide.com/","d":"A Barcelona visitor guide: Gaudi architecture, beaches, tapas.","c":["europe"]}, +{"t":"BBC UK","u":"https://www.bbc.co.uk/","d":"The British Broadcasting Corporation's homepage. News, entertainment, public services.","c":["europe"]}, +{"t":"Der Spiegel","u":"https://www.spiegel.de/","d":"Germany's news magazine, covering politics, business and culture.","c":["europe"]}, +{"t":"DublinGuide.com","u":"http://www.dublinguide.com/","d":"Dublin tourism: pubs, literary history, Georgian architecture.","c":["europe"]}, +{"t":"DutchNews.nl","u":"http://www.dutchnews.nl/","d":"English-language news about the Netherlands for expats and visitors.","c":["europe"]}, +{"t":"Edinburgh Travel Guide","u":"http://www.edinburghguide.com/","d":"An Edinburgh visitor guide: the medieval Old Town, whisky distilleries, Highland tours.","c":["europe"]}, +{"t":"El Pais","u":"https://www.elpais.com/","d":"Spain's highest-circulation newspaper covering Spanish and international news.","c":["europe"]}, +{"t":"El Pais English","u":"http://www.elpais.com/english/","d":"Spain's most-read newspaper with English-language international edition.","c":["europe"]}, +{"t":"EurActiv.com","u":"http://www.euractiv.com/","d":"EU policy news covering all European Union institutions and member state politics.","c":["europe"]}, +{"t":"EuroTrip","u":"https://www.eurotrip.com/","d":"Budget travel tips for European backpackers: hostel reviews, Eurail guides, itinerary advice.","c":["europe"]}, +{"t":"GreeceGuide","u":"http://www.greeceguide.com/","d":"Guide to Greece with destination information and local insight.","c":["europe"]}, +{"t":"Helsinki Travel Guide","u":"http://www.helsinkiguide.com/","d":"A Helsinki visitor guide: Finnish design, saunas, the Baltic archipelago.","c":["europe"]}, +{"t":"Irish Times","u":"http://www.irishtimes.com/","d":"Ireland's newspaper of record: news, opinion, culture.","c":["europe"]}, +{"t":"Irish Times","u":"https://www.ireland.com/","d":"Tourism Ireland's official holiday and travel guide, with accommodation, activities and events across Ireland.","c":["europe"]}, +{"t":"Italy Portal Online","u":"http://www.italyportal.com/","c":["europe"]}, +{"t":"ItalyGuide","u":"http://www.italyguide.com/","c":["europe"]}, +{"t":"La Repubblica","u":"https://www.repubblica.it/","d":"Italy's daily newspaper, covering news, culture and sport in Italian.","c":["europe"]}, +{"t":"Le Monde","u":"https://www.lemonde.fr/","d":"France's newspaper of record covering national and international news in French.","c":["europe"]}, +{"t":"LondonTown.com","u":"http://www.londontown.com/","d":"A London visitor guide: hotel deals, attractions, theater listings.","c":["europe"]}, +{"t":"NRC Handelsblad","u":"https://www.nrc.nl/","d":"Dutch newspaper covering politics, culture and international affairs.","c":["europe"]}, +{"t":"Oslo Travel Guide","u":"http://www.osloguide.com/","d":"An Oslo visitor guide: Viking museums, fjord excursions, Nordic culture.","c":["europe"]}, +{"t":"ParisInfo.com","u":"http://www.parisinfo.com/","d":"The official Paris tourism guide: museums, restaurants, event calendars.","c":["europe"]}, +{"t":"PortugalPortal","u":"http://www.portugalportal.com/","d":"A regional portal for Portugal: news, travel, culture.","c":["europe"]}, +{"t":"Prague Post","u":"https://www.praguepost.com/","d":"English-language newspaper covering Czech news, business and culture.","c":["europe"]}, +{"t":"Pravda Online","u":"https://english.pravda.ru/","d":"English edition of the Russian newspaper with news and commentary on Russian affairs.","c":["europe"]}, +{"t":"Reykjavik Travel Guide","u":"http://www.reykjavikguide.com/","d":"A Reykjavik visitor guide: Northern Lights, geothermal pools, whale watching.","c":["europe"]}, +{"t":"RomeGuide.com","u":"http://www.romeguide.com/","d":"A Rome tourism guide: ancient ruins, the Vatican, Italian cuisine.","c":["europe"]}, +{"t":"ScandinaviaGuide","u":"http://www.scandinaviaguide.com/","d":"Scandinavia: news, travel, culture.","c":["europe"]}, +{"t":"ScandinviaOnline.com","u":"http://www.scandinaviaonline.com/","d":"A Nordic travel and culture guide: Denmark, Norway, Sweden, Finland.","c":["europe"]}, +{"t":"ScrapbookPages.com","u":"https://www.scrapbookpages.com/","d":"Photos and history of World War II and Holocaust sites in Germany, Austria and the Czech Republic.","c":["europe"]}, +{"t":"SpainPortal","u":"http://www.spainportal.com/","d":"Listings of property for sale in Spain, including villas, new builds and sea-view homes.","c":["europe"]}, +{"t":"StockholmGuide.com","u":"http://www.stockholmguide.com/","d":"A Stockholm visitor guide: the archipelago, museums, Nordic design.","c":["europe"]}, +{"t":"Swissinfo.ch","u":"http://www.swissinfo.ch/","d":"Swiss public service news in 10 languages covering Switzerland and world affairs.","c":["europe"]}, +{"t":"Switzerland Guide Online","u":"http://www.switzerlandguide.com/","d":"Community hub focused on Switzerland and its people.","c":["europe"]}, +{"t":"The Local Germany","u":"http://www.thelocal.de/","d":"Germany's English-language news with daily coverage of German life and politics.","c":["europe"]}, +{"t":"The Portugal Guide","u":"http://www.portugalguide.com/","d":"Portugal news, travel and culture.","c":["europe"]}, +{"t":"The Spain Guide","u":"http://www.spainguide.net/","d":"Spain, with maps, history and visitor tips.","c":["europe"]}, +{"t":"TheLocal.se","u":"http://www.thelocal.se/","d":"Sweden's English-language news service: politics, culture, daily life.","c":["europe"]}, +{"t":"United Kingdom Guide Online","u":"http://www.unitedkingdomguide.org/","d":"A regional portal for the United Kingdom: news, travel, culture.","c":["europe"]}, +{"t":"ViennaGuide.com","u":"http://www.viennaguide.com/","d":"Vienna tourism: classical music, coffee houses, imperial palaces.","c":["europe"]}, +{"t":"VisitBritain","u":"https://www.visitbritain.com","d":"The UK's official tourist board. Travel guides, events, accommodation.","c":["europe"]}, +{"t":"Warsaw Travel Guide","u":"http://www.warsawguide.com/","d":"A Warsaw visitor guide: WWII history, the reconstructed Old Town, modern nightlife.","c":["europe"]}, +{"t":"Zurich Travel Guide","u":"http://www.zurichguide.com/","d":"A Zurich visitor guide: Swiss banking, lakeside promenades, chocolate shops.","c":["europe"]}, +{"t":"Al Jazeera","u":"https://www.aljazeera.net/","d":"Qatar-based satellite news network providing Arabic-language news coverage of the Middle East.","c":["middle-east"]}, +{"t":"Arab News","u":"https://www.arabnews.com/","d":"Saudi Arabia's first English-language daily newspaper covering the Gulf region.","c":["middle-east"]}, +{"t":"Daily Star Lebanon","u":"https://www.dailystar.com.lb/","d":"Lebanon's English-language newspaper: politics, business, regional affairs.","c":["middle-east"]}, +{"t":"Doha Visitor Guide","u":"http://www.dohavisit.com/","d":"A Doha travel guide: the modern skyline, desert adventures, Islamic art.","c":["middle-east"]}, +{"t":"EgyptToday.com","u":"http://www.egypttoday.com/","d":"English-language magazine on Egyptian business, politics and culture.","c":["middle-east"]}, +{"t":"Gulf News","u":"https://www.gulfnews.com/","d":"English-language newspaper covering the United Arab Emirates and the Gulf region.","c":["middle-east"]}, +{"t":"Haaretz","u":"https://www.haaretz.com/","d":"Israel's oldest daily newspaper with English-language news and opinion.","c":["middle-east"]}, +{"t":"Jerusalem Post","u":"http://www.jpost.com/","d":"Israel's English-language daily newspaper with Middle East news and analysis.","c":["middle-east"]}, +{"t":"Jordan Times","u":"https://www.jordantimes.com/","d":"Jordan's English-language daily newspaper covering Levantine and Middle Eastern affairs.","c":["middle-east"]}, +{"t":"Lebanon Guide Online","u":"http://www.lebanonguide.org/","d":"Lebanon news, travel and culture.","c":["middle-east"]}, +{"t":"Middle East Eye","u":"http://www.middleeasteye.net/","d":"Independent news site covering the Middle East and North Africa.","c":["middle-east"]}, +{"t":"MiddleEastOnline.com","u":"http://www.middleeastonline.com/","d":"Middle East news and analysis: politics, economy, society.","c":["middle-east"]}, +{"t":"OmanGuide","u":"http://www.omanguide.com/","d":"A reference on Oman: maps, history, visitor tips.","c":["middle-east"]}, +{"t":"SaudiArabiaGuide","u":"http://www.saudiarabiaguide.com/","d":"Guide to Saudi Arabia with destination information and local insight.","c":["middle-east"]}, +{"t":"Tehran Times","u":"https://www.tehrantimes.com/","d":"Iran's English-language newspaper: politics, economy, culture.","c":["middle-east"]}, +{"t":"The Iran Guide","u":"http://www.iranguide.com/","d":"A regional portal for Iran: news, travel, culture.","c":["middle-east"]}, +{"t":"The Israel Guide","u":"http://www.israelguide.com/","d":"Guide to Israel with destination information and local insight.","c":["middle-east"]}, +{"t":"VisitDubai.com","u":"http://www.visitdubai.com/","d":"A Dubai tourism guide: luxury hotels, desert safaris, shopping festivals.","c":["middle-east"]}, +{"t":"50States.com","u":"http://www.50states.com/","d":"Facts, flags, capitals and information for all 50 US states.","c":["united-states"]}, +{"t":"AlaskaTravel.com","u":"http://www.alaskatravel.com/","d":"An Alaska tourism guide: glacier tours, wildlife viewing, cruise information.","c":["united-states"]}, +{"t":"Appalachia Guide Online","u":"http://www.appalachiaguide.com/","d":"Appalachia: news, travel, local life.","c":["united-states"]}, +{"t":"AreaConnect.com","u":"http://www.areaconnect.com/","d":"A local information portal: news, weather, community resources by city.","c":["united-states"]}, +{"t":"Atlanta City Guide","u":"http://www.atlantacityguide.com/","d":"Atlanta restaurants, events and attractions, neighborhood by neighborhood.","c":["united-states"]}, +{"t":"Austin City Guide","u":"http://www.austincityguide.com/","d":"A local guide to Austin: restaurants, events, attractions, neighborhoods.","c":["united-states"]}, +{"t":"BostonGuide.com","u":"http://www.bostonguide.com/","d":"A Boston visitor guide: the Freedom Trail, seafood, college tours.","c":["united-states"]}, +{"t":"Charlotte City Guide","u":"http://www.charlottecityguide.com/","d":"What to eat, what is on and where to go in Charlotte, by neighborhood.","c":["united-states"]}, +{"t":"Chicago Reader","u":"https://www.chicagoreader.com/","d":"Chicago's alternative weekly: arts, dining, local news.","c":["united-states"]}, +{"t":"ChicagoGuide.com","u":"http://www.chicagoguide.com/","d":"A Chicago tourism resource: architecture tours, restaurants, music venues.","c":["united-states"]}, +{"t":"Cincinnati City Guide","u":"http://www.cincinnaticityguide.com/","d":"Cincinnati, neighborhood by neighborhood: restaurants, events, attractions.","c":["united-states"]}, +{"t":"City-Data.com","u":"http://www.city-data.com/","d":"City profiles: demographics, real estate, quality of life statistics.","c":["united-states"]}, +{"t":"CitySearch","u":"https://www.citysearch.com/","d":"City guides across the US: restaurant reviews, bar listings, local event information.","c":["united-states"]}, +{"t":"Cleveland City Guide","u":"http://www.clevelandcityguide.com/","d":"Cleveland restaurants, events and attractions, neighborhood by neighborhood.","c":["united-states"]}, +{"t":"Craigslist","u":"https://www.craigslist.org/","d":"Community classifieds and forums in cities across America: jobs, housing, for sale, services.","c":["united-states"]}, +{"t":"Dallas City Guide","u":"http://www.dallascityguide.com/","d":"A local guide to Dallas: restaurants, events, attractions, neighborhoods.","c":["united-states"]}, +{"t":"Detroit City Guide","u":"http://www.detroitcityguide.com/","d":"What to eat, what is on and where to go in Detroit, by neighborhood.","c":["united-states"]}, +{"t":"DigitalCity","u":"https://www.digitalcity.com/","d":"AOL's city guide network. Local entertainment, dining, nightlife listings.","c":["united-states"]}, +{"t":"DiningGuide.com","u":"http://www.diningguide.com/","d":"Restaurant reviews and dining guides for major US metropolitan areas.","c":["united-states"]}, +{"t":"Florida Central Online","u":"http://www.floridacentral.com/","c":["united-states"]}, +{"t":"FloridaGuide","u":"http://www.floridaguide.com/","c":["united-states"]}, +{"t":"Georgia Guide Online","u":"http://www.georgiaguide.com/","c":["united-states"]}, +{"t":"GreatLakesPortal","u":"http://www.greatlakesportal.com/","c":["united-states"]}, +{"t":"HawaiiGuide.com","u":"http://www.hawaiiguide.com/","d":"A Hawaii vacation guide: island comparisons, activities, resort reviews.","c":["united-states"]}, +{"t":"Illinois Connect Online","u":"http://www.illinoisconnect.com/","d":"Illinois news, travel and local life.","c":["united-states"]}, +{"t":"LAExplorer.com","u":"http://www.laexplorer.com/","d":"A Los Angeles visitor guide: entertainment, beaches, neighborhoods.","c":["united-states"]}, +{"t":"MiamiBeachGuide.com","u":"http://www.miamibeachguide.com/","d":"Miami Beach visitor information: hotels, nightlife, the Art Deco district.","c":["united-states"]}, +{"t":"MichiganConnect","u":"http://www.michiganconnect.com/","d":"Michigan: maps, attractions, visitor tips.","c":["united-states"]}, +{"t":"MichiganGuide","u":"http://www.michiganguide.com/","d":"A reference on Michigan, with maps, attractions and visitor tips.","c":["united-states"]}, +{"t":"Mid-AtlanticConnect","u":"http://www.midatlanticconnect.com/","c":["united-states"]}, +{"t":"Midwest Network Online","u":"http://www.midwestnetwork.net/","c":["united-states"]}, +{"t":"MidwestCentral","u":"http://www.midwestcentral.com/","c":["united-states"]}, +{"t":"Milwaukee City Guide","u":"http://www.milwaukeecityguide.com/","d":"Milwaukee, neighborhood by neighborhood: restaurants, events, attractions.","c":["united-states"]}, +{"t":"Minneapolis City Guide","u":"http://www.minneapoliscityguide.com/","d":"Minneapolis restaurants, events and attractions, neighborhood by neighborhood.","c":["united-states"]}, +{"t":"New England Connect Online","u":"http://www.newenglandconnect.com/","c":["united-states"]}, +{"t":"New England Guide Online","u":"http://www.newenglandguide.net/","c":["united-states"]}, +{"t":"New York City Official Tourism","u":"https://www.nycgo.com","d":"The official tourism guide for New York City. Attractions, events, visitor information.","c":["united-states"]}, +{"t":"New York Connect Online","u":"http://www.newyorkconnect.com/","c":["united-states"]}, +{"t":"NewEnglandNetwork","u":"http://www.newenglandnetwork.com/","c":["united-states"]}, +{"t":"NewOrleansGuide.com","u":"http://www.neworleansguide.com/","d":"New Orleans tourism: jazz clubs, Cajun cuisine, the French Quarter.","c":["united-states"]}, +{"t":"North Carolina Network Online","u":"http://www.northcarolinanetwork.com/","d":"North Carolina news, travel and local life.","c":["united-states"]}, +{"t":"NorthCarolinaCentral","u":"http://www.northcarolinacentral.com/","d":"A regional portal for North Carolina: news, travel, local life.","c":["united-states"]}, +{"t":"NYCVisit.com","u":"http://www.nycvisit.com/","d":"A New York City tourism guide: Broadway shows, museums, dining.","c":["united-states"]}, +{"t":"Orlando City Guide","u":"http://www.orlandocityguide.com/","d":"A local guide to Orlando: restaurants, events, attractions, neighborhoods.","c":["united-states"]}, +{"t":"PacificNorthwestConnect","u":"http://www.pacificnorthwestconnect.com/","d":"Resource exploring Pacific Northwest for travelers and locals.","c":["united-states"]}, +{"t":"PatchLocalNews.com","u":"http://www.patch.com/","d":"Hyper-local news and community information for towns across the United States.","c":["united-states"]}, +{"t":"PennsylvaniaGuide","u":"http://www.pennsylvaniaguide.com/","d":"Guide to Pennsylvania with destination information and resident resources.","c":["united-states"]}, +{"t":"PhillyGuide.com","u":"http://www.phillyguide.com/","d":"A Philadelphia visitor guide: historic sites, cheesesteaks, art museums.","c":["united-states"]}, +{"t":"PortlandGuide.com","u":"http://www.portlandguide.com/","d":"A Portland visitor guide: food carts, breweries, outdoor recreation.","c":["united-states"]}, +{"t":"RoadTripUSA.com","u":"http://www.roadtripusa.com/","d":"Cross-country driving routes with road trip itineraries and travel tips.","c":["united-states"]}, +{"t":"SanAntonio City Guide","u":"http://www.sanantoniocityguide.com/","d":"What to eat, what is on and where to go in San Antonio, by neighborhood.","c":["united-states"]}, +{"t":"SanDiego City Guide","u":"http://www.sandiegocityguide.com/","d":"San Diego, neighborhood by neighborhood: restaurants, events, attractions.","c":["united-states"]}, +{"t":"SeattleGuide.com","u":"http://www.seattleguide.com/","d":"Seattle tourism: Pike Place Market, coffee culture, the music scene.","c":["united-states"]}, +{"t":"SFGate.com","u":"http://www.sfgate.com/","d":"San Francisco Chronicle's online edition with Bay Area news and entertainment.","c":["united-states"]}, +{"t":"SmallTownUSA.com","u":"http://www.smalltownusa.com/","d":"Guide to small towns across America with visitor information and history.","c":["united-states"]}, +{"t":"StLouis City Guide","u":"http://www.stlouiscityguide.com/","d":"St. Louis restaurants, events and attractions, neighborhood by neighborhood.","c":["united-states"]}, +{"t":"Tampa City Guide","u":"http://www.tampacityguide.com/","d":"A local guide to Tampa: restaurants, events, attractions, neighborhoods.","c":["united-states"]}, +{"t":"Texas Network Online","u":"http://www.texasnetwork.com/","d":"Texas, with maps, attractions and visitor tips.","c":["united-states"]}, +{"t":"TexasPortal","u":"http://www.texasportal.com/","d":"A reference on Texas: maps, attractions, visitor tips.","c":["united-states"]}, +{"t":"TexasTravel.com","u":"http://www.texastravel.com/","d":"A Texas tourism guide: BBQ trails, state parks, city attractions.","c":["united-states"]}, +{"t":"The California Connect","u":"http://www.californiaconnect.org/","c":["united-states"]}, +{"t":"The California Guide","u":"http://www.californiaguide.com/","c":["united-states"]}, +{"t":"The Florida Network","u":"http://www.floridanetwork.com/","c":["united-states"]}, +{"t":"The Mid-Atlantic Network","u":"http://www.midatlanticnetwork.com/","c":["united-states"]}, +{"t":"The Midwest Guide","u":"http://www.midwestguide.com/","c":["united-states"]}, +{"t":"The New York Portal","u":"http://www.newyorkportal.com/","c":["united-states"]}, +{"t":"The Ohio Connect","u":"http://www.ohioconnect.net/","c":["united-states"]}, +{"t":"The Texas Connect","u":"http://www.texasconnect.com/","d":"A Texas reference, with maps, attractions and visitor tips.","c":["united-states"]}, +{"t":"The Texas Guide","u":"http://www.texasguide.net/","d":"Texas: maps, attractions, visitor tips.","c":["united-states"]}, +{"t":"Tucson City Guide","u":"http://www.tucsoncityguide.com/","d":"What to eat, what is on and where to go in Tucson, by neighborhood.","c":["united-states"]}, +{"t":"Tulsa City Guide","u":"http://www.tulsacityguide.com/","d":"Tulsa, neighborhood by neighborhood: restaurants, events, attractions.","c":["united-states"]}, +{"t":"USCityLink.com","u":"http://www.uscitylink.com/","d":"Directory of US city and town websites organized by state.","c":["united-states"]}, +{"t":"VisitAlabama.com","u":"http://www.visitalabama.com/","d":"An Alabama tourism guide: civil rights history, Gulf Shores, Southern cooking.","c":["united-states"]}, +{"t":"VisitArizona.com","u":"http://www.visitarizona.com/","d":"Arizona for visitors: the Grand Canyon, Sedona red rocks, desert landscapes.","c":["united-states"]}, +{"t":"VisitCalifornia.com","u":"http://www.visitcalifornia.com/","d":"Where to go in California: wine country, national parks, the Pacific coastline.","c":["united-states"]}, +{"t":"VisitColorado.com","u":"http://www.visitcolorado.com/","d":"Tourism for Colorado: Rocky Mountains, skiing, outdoor adventure.","c":["united-states"]}, +{"t":"VisitConnecticut.com","u":"http://www.visitconnecticut.com/","d":"Where to go in Connecticut: coastal villages, the Yale campus, fall foliage.","c":["united-states"]}, +{"t":"VisitDelaware.com","u":"http://www.visitdelaware.com/","d":"Where to go in Delaware: tax-free shopping, beaches, DuPont estates.","c":["united-states"]}, +{"t":"VisitFlorida.com","u":"http://www.visitflorida.com/","d":"A Florida tourism guide: beaches, theme parks, tropical getaways.","c":["united-states"]}, +{"t":"VisitGeorgia.com","u":"http://www.visitgeorgia.com/","d":"A Georgia tourism guide: Savannah, coastal islands, Southern cuisine.","c":["united-states"]}, +{"t":"VisitIllinois.com","u":"http://www.visitillinois.com/","d":"Tourism for Illinois: Chicago architecture, Route 66, Lincoln sites.","c":["united-states"]}, +{"t":"VisitIndiana.com","u":"http://www.visitindiana.com/","d":"An Indiana tourism guide: the Indianapolis Motor Speedway, Amish country, caves.","c":["united-states"]}, +{"t":"VisitIowa.com","u":"http://www.visitiowa.com/","d":"Iowa for visitors: covered bridges, the state fair, Field of Dreams.","c":["united-states"]}, +{"t":"VisitKentucky.com","u":"http://www.visitkentucky.com/","d":"Tourism for Kentucky: the bourbon trail, horse country, Mammoth Cave.","c":["united-states"]}, +{"t":"VisitLouisiana.com","u":"http://www.visitlouisiana.com/","d":"Tourism for Louisiana: Cajun Country, bayou tours, jazz festivals.","c":["united-states"]}, +{"t":"VisitMaine.com","u":"http://www.visitmaine.com/","d":"Tourism for Maine: lighthouses, lobster, Acadia National Park.","c":["united-states"]}, +{"t":"VisitMaryland.com","u":"http://www.visitmaryland.com/","d":"A Maryland tourism guide: Chesapeake Bay, crab feasts, maritime heritage.","c":["united-states"]}, +{"t":"VisitMassachusetts.com","u":"http://www.visitmassachusetts.com/","d":"Massachusetts for visitors: Cape Cod, Plymouth, Boston history.","c":["united-states"]}, +{"t":"VisitMichigan.com","u":"http://www.visitmichigan.com/","d":"A Michigan tourism guide: the Great Lakes, Mackinac Island, cherry orchards.","c":["united-states"]}, +{"t":"VisitMinnesota.com","u":"http://www.visitminnesota.com/","d":"Where to go in Minnesota: the Boundary Waters, Mall of America, the Twin Cities.","c":["united-states"]}, +{"t":"VisitMississippi.com","u":"http://www.visitmississippi.com/","d":"Where to go in Mississippi: Delta blues, antebellum homes, the Gulf coast.","c":["united-states"]}, +{"t":"VisitMissouri.com","u":"http://www.visitmissouri.com/","d":"Tourism for Missouri: the Gateway Arch, Branson shows, Ozark trails.","c":["united-states"]}, +{"t":"VisitMontana.com","u":"http://www.visitmontana.com/","d":"A Montana tourism guide: Glacier National Park, big sky country, guest ranches.","c":["united-states"]}, +{"t":"VisitNevada.com","u":"http://www.visitnevada.com/","d":"Nevada for visitors: Las Vegas, Valley of Fire, ghost towns.","c":["united-states"]}, +{"t":"VisitNewHampshire.com","u":"http://www.visitnewhampshire.com/","d":"Tourism for New Hampshire: the White Mountains, covered bridges, skiing.","c":["united-states"]}, +{"t":"VisitNewMexico.com","u":"http://www.visitnewmexico.com/","d":"A New Mexico tourism guide: Santa Fe art, pueblos, Carlsbad Caverns.","c":["united-states"]}, +{"t":"VisitNewYork.com","u":"http://www.visitnewyork.com/","d":"New York for visitors: the Adirondacks, Finger Lakes wine, Niagara Falls.","c":["united-states"]}, +{"t":"VisitNorthCarolina.com","u":"http://www.visitnorthcarolina.com/","d":"Where to go in North Carolina: the Blue Ridge Parkway, the Outer Banks, BBQ.","c":["united-states"]}, +{"t":"VisitOhio.com","u":"http://www.visitohio.com/","d":"Where to go in Ohio: the Rock and Roll Hall of Fame, Amish country, Cedar Point.","c":["united-states"]}, +{"t":"VisitOregon.com","u":"http://www.visitoregon.com/","d":"Where to go in Oregon: Crater Lake, the coast, craft beer.","c":["united-states"]}, +{"t":"VisitPennsylvania.com","u":"http://www.visitpennsylvania.com/","d":"Pennsylvania for visitors: Philadelphia history, Amish country, the Poconos.","c":["united-states"]}, +{"t":"VisitRhodeIsland.com","u":"http://www.visitrhodeisland.com/","d":"A Rhode Island tourism guide: Newport mansions, sailing, coastal towns.","c":["united-states"]}, +{"t":"VisitTennessee.com","u":"http://www.visittennessee.com/","d":"Tennessee for visitors: the Great Smoky Mountains, Nashville music, Memphis BBQ.","c":["united-states"]}, +{"t":"VisitTexas.com","u":"http://www.visittexas.com/","d":"Tourism for Texas: Hill Country, BBQ trails, cowboy culture.","c":["united-states"]}, +{"t":"VisitUtah.com","u":"http://www.visitutah.com/","d":"Where to go in Utah: Zion, Arches, Bryce Canyon.","c":["united-states"]}, +{"t":"VisitVermont.com","u":"http://www.visitvermont.com/","d":"A Vermont tourism guide: fall foliage, maple syrup, covered bridges.","c":["united-states"]}, +{"t":"VisitVirginia.com","u":"http://www.visitvirginia.com/","d":"Tourism for Virginia: historic Williamsburg, the Shenandoah Valley, battlefields.","c":["united-states"]}, +{"t":"VisitWashington.com","u":"http://www.visitwashington.com/","d":"Washington for visitors: Mount Rainier, the San Juan Islands, coffee culture.","c":["united-states"]}, +{"t":"VisitWestVirginia.com","u":"http://www.visitwestvirginia.com/","d":"West Virginia for visitors: New River Gorge, white water rafting, country roads.","c":["united-states"]}, +{"t":"VisitWisconsin.com","u":"http://www.visitwisconsin.com/","d":"Tourism for Wisconsin: cheese trails, lakeshores, Dells waterparks.","c":["united-states"]}, +{"t":"VisitWyoming.com","u":"http://www.visitwyoming.com/","d":"Where to go in Wyoming: Yellowstone, Grand Teton, dude ranches.","c":["united-states"]}, +{"t":"WashingtonDCGuide.com","u":"http://www.washingtondcguide.com/","d":"A Washington DC visitor guide: monuments, museums, dining.","c":["united-states"]}, +{"t":"Yelp.com","u":"http://www.yelp.com/","d":"Local business reviews and recommendations with user photos and ratings.","c":["united-states"]}, +{"t":"CarbonFootprint.com","u":"http://www.carbonfootprint.com/","d":"Carbon footprint calculator with offset options and reduction strategies.","c":["science"]}, +{"t":"ConservationMagazine","u":"http://www.conservationmagazine.org/","d":"Science-based conservation journalism covering wildlife and ecosystem management.","c":["science"]}, +{"t":"Earth Observatory","u":"https://earthobservatory.nasa.gov/","d":"NASA's satellite imagery and data on Earth's climate, weather and natural hazards.","c":["science"]}, +{"t":"EarthDay.org","u":"http://www.earthday.org/","d":"The Earth Day Network: environmental education, campaigns, event coordination.","c":["science"]}, +{"t":"EcologyCenter.org","u":"http://www.ecologycenter.org/","d":"Environmental education center with ecological footprint calculators and resources.","c":["science"]}, +{"t":"EndangeredSpecies.org","u":"http://www.endangeredspecies.org/","d":"Endangered species profiles with conservation status and protection efforts.","c":["science"]}, +{"t":"EnviroLink.org","u":"http://www.envirolink.org/","d":"An environmental resource hub: news, directories, discussion forums.","c":["science"]}, +{"t":"Environmental Defense Fund","u":"https://www.edf.org/","d":"Science-based environmental advocacy: climate, oceans, ecosystems, health.","c":["science"]}, +{"t":"EPA Environmental Data","u":"https://www.epa.gov/enviro/","d":"Environmental data, maps and facility reports from the Environmental Protection Agency.","c":["science"]}, +{"t":"Greenpeace","u":"https://www.greenpeace.org/","d":"Environmental activism, campaign information and news from the world's largest green NGO.","c":["science","environment"]}, +{"t":"Greenpeace International","u":"https://www.greenpeace.org/international/","d":"Environmental campaigns, research reports and activism resources.","c":["science"]}, +{"t":"GreenPlanet.org","u":"http://www.greenplanet.org/","d":"Environmental resource connecting organizations and individuals for sustainability.","c":["science"]}, +{"t":"IUCN Red List","u":"http://www.iucnredlist.org/","d":"An inventory of the global conservation status of biological species.","c":["science"]}, +{"t":"National Geographic","u":"https://www.nationalgeographic.com","d":"Science, nature and culture coverage, with photography and documentary video.","c":["science","biology"]}, +{"t":"NatureServe.org","u":"http://www.natureserve.org/","d":"Biodiversity database providing scientific information on rare and endangered species.","c":["science"]}, +{"t":"NOAA Ocean Service","u":"https://oceanservice.noaa.gov/","d":"Coastal and ocean data, nautical charts and marine weather from the National Ocean Service.","c":["science"]}, +{"t":"RecyclingGuide.com","u":"http://www.recyclingguide.com/","d":"Recycling information by material type with local drop-off locators.","c":["science"]}, +{"t":"RenewableEnergyCentral","u":"http://www.renewableenergycentral.com/","c":["science"]}, +{"t":"Seismology.com","u":"http://www.seismology.com/","d":"Earthquake science resource with seismic monitoring and research.","c":["science"]}, +{"t":"Smithsonian Institution","u":"https://www.si.edu/","d":"The museums, plus research and education.","c":["science"]}, +{"t":"SustainabilityNetwork","u":"http://www.sustainabilitynetwork.com/","c":["science"]}, +{"t":"The Climate Change Central","u":"http://www.climatechangecentral.com/","d":"Climate change: facts, tips, ways to get involved.","c":["science"]}, +{"t":"The Sierra Club","u":"https://www.sierraclub.org","d":"Environmental advocacy and conservation. Outings, volunteer opportunities, news.","c":["science","environment"]}, +{"t":"Tree Hugger","u":"https://www.treehugger.com/","d":"Green news and sustainable living tips: eco-design, transport, green technology.","c":["science","environment"]}, +{"t":"USGS","u":"https://www.usgs.gov/","d":"The United States Geological Survey: earthquake data, topographic maps, earth science research.","c":["science"]}, +{"t":"Volcanology.com","u":"http://www.volcanology.com/","d":"Volcano science resource with eruption monitoring and research data.","c":["science"]}, +{"t":"VolcanoWorld.org","u":"http://www.volcanoworld.org/","d":"A volcano database: eruption history, photos, educational resources.","c":["science"]}, +{"t":"WaterConservation.org","u":"http://www.waterconservation.org/","d":"Water conservation tips and technology with drought management resources.","c":["science"]}, +{"t":"Woods Hole Oceanographic","u":"https://www.whoi.edu/","d":"An independent oceanographic research institution, running deep-sea exploration expeditions.","c":["science"]}, +{"t":"World Wildlife Fund","u":"https://www.worldwildlife.org/","d":"Wildlife conservation, endangered species information, environmental protection campaigns.","c":["science"]}, +{"t":"AmateurAstronomy.org","u":"http://www.amateurastronomy.org/","d":"Amateur astronomy club directory with star party schedules and observing tips.","c":["astronomy"]}, +{"t":"AsteroidWatch.com","u":"http://www.asteroidwatch.com/","d":"Near-Earth asteroid tracking with impact probability and size data.","c":["astronomy"]}, +{"t":"Astronomy Picture of the Day","u":"https://apod.nasa.gov/","d":"A different astronomy image or photograph each day with a brief explanation by a professional astronomer.","c":["astronomy"]}, +{"t":"Astronomy.com","u":"http://www.astronomy.com/","d":"Sky events, equipment and space exploration, from the astronomy magazine.","c":["astronomy"]}, +{"t":"Bad Astronomy","u":"https://www.badastronomy.com/","d":"Phil Plait debunks astronomical misconceptions and covers space science news.","c":["astronomy"]}, +{"t":"ClearDarkSky.com","u":"http://www.cleardarksky.com/","d":"Astronomy weather forecasts: cloud cover, transparency, seeing.","c":["astronomy"]}, +{"t":"CometWatch.com","u":"http://www.cometwatch.com/","d":"Private observatory in Georgia, USA, for supernova discovery and comet study, home of the Puckett Observatory World Supernova Search.","c":["astronomy"]}, +{"t":"DeepSkyObjects.com","u":"http://www.deepskyobjects.com/","d":"Catalogue of deep sky objects, with observation guides for galaxies, nebulae and star clusters.","c":["astronomy"]}, +{"t":"EarthSky.org","u":"http://www.earthsky.org/","d":"Science news and tonight's sky with updates on astronomy and earth science.","c":["astronomy"]}, +{"t":"Heavens-Above.com","u":"http://www.heavens-above.com/","d":"Satellite tracking predictions for the ISS, Iridium flares and planets.","c":["astronomy"]}, +{"t":"HubbleSite","u":"https://hubblesite.org/","d":"Images, discoveries and news from the Hubble Space Telescope.","c":["astronomy"]}, +{"t":"Messier Objects Database","u":"http://www.messier.seds.org/","d":"A guide to all 110 Messier objects, with images and observation notes.","c":["astronomy"]}, +{"t":"NASA JPL","u":"http://www.jpl.nasa.gov/","d":"Jet Propulsion Laboratory with planetary mission updates and space technology.","c":["astronomy"]}, +{"t":"Planetary Society","u":"https://www.planetary.org/","d":"Public space advocacy and education founded by Carl Sagan, supporting space exploration.","c":["astronomy"]}, +{"t":"PlanetHunters.org","u":"http://www.planethunters.org/","d":"Citizen science project searching for exoplanets in Kepler mission data.","c":["astronomy"]}, +{"t":"RadioAstronomy.org","u":"http://www.radioastronomy.org/","d":"Radio astronomy education with dish construction guides and signal processing.","c":["astronomy"]}, +{"t":"SETI@home","u":"https://setiathome.ssl.berkeley.edu/","d":"Distributed computing project using your idle CPU to search for extraterrestrial radio signals.","c":["astronomy"]}, +{"t":"Sky & Telescope","u":"https://www.skyandtelescope.com","d":"Amateur astronomy magazine online. Observing guides, telescope reviews, a sky events calendar.","c":["astronomy"]}, +{"t":"SkyWatcher.com","u":"http://www.skywatcher.com/","d":"Amateur astronomy guide with telescope reviews and stargazing event calendars.","c":["astronomy"]}, +{"t":"Space.com","u":"https://www.space.com/","d":"Space news and astronomy, from rocket launches to the search for life beyond Earth.","c":["astronomy"]}, +{"t":"SpaceMissionTracker.com","u":"http://www.spacemissiontracker.com/","d":"Active space mission tracking with launch schedules and orbital parameters.","c":["astronomy"]}, +{"t":"SpaceWeather.com","u":"http://www.spaceweather.com/","d":"Solar activity in real time, plus aurora forecasts and near-Earth asteroid tracking.","c":["astronomy"]}, +{"t":"StarDate Online","u":"http://www.stardate.org/","d":"University of Texas astronomy radio program with sky calendars and star guides.","c":["astronomy"]}, +{"t":"StarGazeTonight.com","u":"http://www.stargazetonight.com/","d":"Tonight's sky guide with constellation charts and astronomical event calendar.","c":["astronomy"]}, +{"t":"Stars Central Online","u":"http://www.starscentral.com/","c":["astronomy"]}, +{"t":"The Stars Network","u":"http://www.starsnetwork.com/","c":["astronomy"]}, +{"t":"TheMoonNetwork","u":"http://www.themoonnetwork.com/","d":"Astronomy resource covering The Moon for stargazers and students.","c":["astronomy"]}, +{"t":"WorldWideTelescope","u":"http://www.worldwidetelescope.org/","d":"Browser-based client for showcasing astronomical data and knowledge.","c":["astronomy"]}, +{"t":"AnatomyNetwork","u":"http://www.anatomynetwork.com/","d":"Biology resource covering Anatomy for students and researchers.","c":["biology"]}, +{"t":"Bioinformatics Central Online","u":"http://www.bioinformaticscentral.com/","d":"Reference on Bioinformatics with current research and explanations.","c":["biology"]}, +{"t":"Biology Online","u":"http://www.biology-online.org/","d":"Biology dictionary, tutorials and discussion forums, for students and professionals.","c":["biology"]}, +{"t":"BioMedCentral","u":"http://www.biomedcentral.com/","d":"Open access publisher of peer-reviewed biological and medical research.","c":["biology"]}, +{"t":"BirdLife International","u":"http://www.birdlife.org/","d":"Global partnership of conservation organizations protecting birds and habitats.","c":["biology"]}, +{"t":"BotanyCentral","u":"http://www.botanycentral.com/","d":"Botany, with articles, diagrams and study materials.","c":["biology"]}, +{"t":"Encyclopedia of Life","u":"http://www.eol.org/","d":"Collaborative effort to document all 1.9 million known species on Earth.","c":["biology"]}, +{"t":"EvolutionPages.com","u":"http://www.evolutionpages.com/","d":"An evolutionary biology resource: explanations, timelines, evidence.","c":["biology"]}, +{"t":"FossilRecord.org","u":"http://www.fossilrecord.org/","d":"Paleontology resource with fossil identification guides and geological timelines.","c":["biology"]}, +{"t":"FreshwaterFish.net","u":"http://www.freshwaterfish.net/","d":"A freshwater fish species guide: habitat, diet, fishing information.","c":["biology"]}, +{"t":"GenBank","u":"https://www.ncbi.nlm.nih.gov/genbank/","d":"NIH genetic sequence database with annotated DNA sequences from researchers worldwide.","c":["biology"]}, +{"t":"GeneticsNews.com","u":"http://www.geneticsnews.com/","d":"Genetics and genomics news with research summaries and ethical discussions.","c":["biology"]}, +{"t":"Genome.gov","u":"https://www.genome.gov/HGP/","d":"Human Genome Project updates and genomics research from the National Human Genome Research Institute.","c":["biology"]}, +{"t":"MarineBio.org","u":"http://www.marinebio.org/","d":"A marine biology resource: species information, ocean conservation, research.","c":["biology"]}, +{"t":"MicroscopeWorld.com","u":"http://www.microscopeworld.com/","d":"Seller of industrial, clinical, educational and laboratory microscopes, parts and accessories, with custom systems.","c":["biology"]}, +{"t":"MolecularBiologyCentral","u":"http://www.molecularbiologycentral.com/","d":"Tritech Research supplier of biological equipment and products for research scientists working with worms, flies and frogs.","c":["biology"]}, +{"t":"MushroomExpert.com","u":"http://www.mushroomexpert.com/","d":"Wild mushroom identification: photos, spore prints, edibility.","c":["biology"]}, +{"t":"National Institutes of Health","u":"https://www.nih.gov/","d":"Federal medical research agency funding and publishing biological and medical research.","c":["biology"]}, +{"t":"Nature.com","u":"https://www.nature.com/","d":"An international scientific journal publishing research across all disciplines.","c":["biology"]}, +{"t":"Ornithology.com","u":"http://www.ornithology.com/","d":"Bird science resource with species research and banding data.","c":["biology"]}, +{"t":"PlantIdentification.com","u":"http://www.plantidentification.com/","d":"Plant species identification tool with photo matching and regional guides.","c":["biology"]}, +{"t":"PubMed","u":"https://www.ncbi.nlm.nih.gov/pubmed/","d":"Free database of biomedical and life science journal citations from the National Library of Medicine.","c":["biology"]}, +{"t":"ResearchDigest.org","u":"http://www.researchdigest.org/","d":"Curated summaries of recent scientific research across all disciplines.","c":["biology"]}, +{"t":"Science Magazine","u":"https://www.sciencemag.org/","d":"Peer-reviewed research journal from AAAS covering all scientific disciplines.","c":["biology"]}, +{"t":"Science News","u":"https://www.sciencenews.org/","d":"Concise, current science news from all fields of research published biweekly since 1922.","c":["biology"]}, +{"t":"ScienceDaily","u":"https://www.sciencedaily.com/","d":"Breaking science news and research summaries from universities and journals.","c":["biology"]}, +{"t":"The Anatomy Central","u":"http://www.anatomycentral.com/","d":"Resource applying anatomy, physiology and biomechanics to the practice of hatha yoga, from Bandha Yoga.","c":["biology"]}, +{"t":"The Biotechnology Network","u":"http://www.biotechnologynetwork.com/","d":"Educational hub covering Biotechnology for biology learners.","c":["biology"]}, +{"t":"The Genetics Central","u":"http://www.geneticscentral.com/","d":"A genetics guide: articles, diagrams, study materials.","c":["biology"]}, +{"t":"The Human Genome Project","u":"https://www.genome.gov/","d":"National Human Genome Research Institute tracking the landmark effort to sequence human DNA.","c":["biology"]}, +{"t":"Tree of Life Web","u":"http://www.tolweb.org/","d":"Collaborative project charting the diversity and phylogeny of all living organisms.","c":["biology"]}, +{"t":"Zoological Society","u":"http://www.zsl.org/","d":"London Zoo and Whipsnade Zoo with conservation programs and animal research.","c":["biology"]}, +{"t":"American Chemical Society","u":"https://www.acs.org/","d":"Chemistry research journals, conferences and educational materials from the world's largest scientific society.","c":["chemistry"]}, +{"t":"ChemCollective","u":"http://www.chemcollective.org/","d":"Virtual labs and scenario-based learning activities for chemistry education.","c":["chemistry"]}, +{"t":"Chemguide.co.uk","u":"http://www.chemguide.co.uk/","d":"Chemistry tutorial resource covering A-level and introductory university topics.","c":["chemistry"]}, +{"t":"ChemicalElements.com","u":"https://www.chemicalelements.com/","d":"Interactive periodic table, with data on each element's properties and history.","c":["chemistry"]}, +{"t":"ChemicalForums.com","u":"http://www.chemicalforums.com/","d":"Chemistry discussion forums for students and professionals at all levels.","c":["chemistry"]}, +{"t":"ChemSpider","u":"http://www.chemspider.com/","d":"Free chemical structure database providing access to millions of compounds.","c":["chemistry"]}, +{"t":"ChemWeb","u":"https://www.chemweb.com/","d":"Chemistry portal: journal alerts, conference listings, chemical databases.","c":["chemistry"]}, +{"t":"LabEquipment.com","u":"http://www.labequipment.com/","d":"A laboratory equipment supplier: microscopes, glassware, chemicals.","c":["chemistry"]}, +{"t":"LabSafetyGuide.com","u":"http://www.labsafetyguide.com/","d":"Chemical laboratory safety resource with MSDS lookup and protocol guides.","c":["chemistry"]}, +{"t":"MineralCollector.com","u":"http://www.mineralcollector.com/","d":"Mineral and crystal collecting: identification, localities, dealers.","c":["chemistry"]}, +{"t":"Organic Chemistry Portal","u":"http://www.organic-chemistry.org/","d":"Named reactions, synthesis and resources for organic chemistry researchers.","c":["chemistry"]}, +{"t":"Periodic Table Live","u":"https://periodic.lanl.gov/","d":"Los Alamos National Laboratory interactive periodic table with element data and history.","c":["chemistry"]}, +{"t":"PeriodicTable.com","u":"http://www.periodictable.com/","d":"An interactive periodic table: element properties, photos, experiment videos.","c":["chemistry"]}, +{"t":"PharmaChem.com","u":"http://www.pharmachem.com/","d":"Pharmaceutical chemistry resource with drug design and synthesis methods.","c":["chemistry"]}, +{"t":"PolymerScience.net","u":"http://www.polymerscience.net/","d":"Polymer and materials science education resource.","c":["chemistry"]}, +{"t":"Protein Data Bank","u":"https://www.rcsb.org/","d":"Archive of 3D structures of biological macromolecules for research and education.","c":["chemistry"]}, +{"t":"Royal Society of Chemistry","u":"https://www.rsc.org/","d":"Chemistry news, journals and educational resources from the UK's professional chemistry body.","c":["chemistry"]}, +{"t":"The Exploratorium","u":"https://www.exploratorium.edu","d":"Science museum with interactive online exhibits. Hands-on experiments and educational activities.","c":["chemistry"]}, +{"t":"WebElements","u":"https://www.webelements.com/","d":"Interactive periodic table, with data on every chemical element.","c":["chemistry"]}, +{"t":"American Mathematical Society","u":"https://www.ams.org/","d":"Mathematics research journals, conferences and career resources from the professional society.","c":["mathematics"]}, +{"t":"Art of Problem Solving","u":"http://www.artofproblemsolving.com/","d":"Math competition preparation and advanced problem solving for talented students.","c":["mathematics"]}, +{"t":"BetterExplained.com","u":"http://www.betterexplained.com/","d":"Math concepts explained with intuitive analogies rather than formal proofs.","c":["mathematics"]}, +{"t":"Cut the Knot","u":"https://www.cut-the-knot.org/","d":"Interactive mathematics. Puzzles, games, encyclopedic coverage of mathematical concepts.","c":["mathematics"]}, +{"t":"Desmos Graphing","u":"http://www.desmos.com/","d":"Free online graphing calculator with interactive exploration tools.","c":["mathematics"]}, +{"t":"GeometryHelp.com","u":"http://www.geometryhelp.com/","d":"Geometry tutorials: theorem proofs, formulas, practice problems.","c":["mathematics"]}, +{"t":"Khan Academy Math","u":"http://www.khanacademy.org/math/","d":"Free math instruction from arithmetic through calculus with practice exercises.","c":["mathematics"]}, +{"t":"MathGames.com","u":"http://www.mathgames.com/","d":"Educational math games for K-12 students reinforcing arithmetic and algebra.","c":["mathematics"]}, +{"t":"MathWorld","u":"https://mathworld.wolfram.com/","d":"Eric Weisstein's mathematics encyclopedia, covering all major branches.","c":["mathematics"]}, +{"t":"NIST","u":"https://www.nist.gov/","d":"National Institute of Standards and Technology with measurement standards and technical reference data.","c":["mathematics"]}, +{"t":"NumberTheory.org","u":"http://www.numbertheory.org/","d":"Number theory education resource with prime number research and puzzles.","c":["mathematics"]}, +{"t":"OEIS","u":"http://www.oeis.org/","d":"Online Encyclopedia of Integer Sequences with over 300,000 sequences.","c":["mathematics"]}, +{"t":"Plus Magazine","u":"http://www.plus.maths.org/","d":"Free online mathematics magazine with articles accessible to the general public.","c":["mathematics"]}, +{"t":"ProbabilityTheory.com","u":"http://www.probabilitytheory.com/","d":"Probability and statistics education with real-world examples.","c":["mathematics"]}, +{"t":"StatisticsHelp.com","u":"http://www.statisticshelp.com/","d":"Statistics tutorials from basic probability to advanced regression analysis.","c":["mathematics"]}, +{"t":"The Math Forum","u":"https://mathforum.org/","d":"Drexel University's hub for math education with Ask Dr. Math and problems of the week.","c":["mathematics"]}, +{"t":"Wolfram Alpha","u":"https://www.wolframalpha.com/","d":"Computational knowledge engine answering factual queries with calculated results.","c":["mathematics"]}, +{"t":"American Physical Society","u":"https://www.aps.org/","d":"Physics research journals, conference proceedings and education resources from the APS.","c":["physics"]}, +{"t":"ArXiv","u":"https://arxiv.org/","d":"Cornell's preprint server for physics, mathematics, computer science and quantitative biology.","c":["physics"]}, +{"t":"CERN","u":"http://www.cern.ch/","d":"European Organization for Nuclear Research operating the Large Hadron Collider.","c":["physics"]}, +{"t":"CERN","u":"https://home.cern/","d":"European Organization for Nuclear Research and birthplace of the World Wide Web.","c":["physics"]}, +{"t":"ElectronicsHobby.com","u":"http://www.electronicshobby.com/","d":"Electronics hobby resource with circuit projects and component guides.","c":["physics"]}, +{"t":"Fermilab","u":"https://www.fnal.gov/","d":"Fermi National Accelerator Laboratory with particle physics research and educational resources.","c":["physics"]}, +{"t":"HyperPhysics","u":"http://www.hyperphysics.phy-astr.gsu.edu/","d":"Georgia State University's concept map-based physics reference with calculations.","c":["physics"]}, +{"t":"IEEE","u":"https://www.ieee.org/","d":"The Institute of Electrical and Electronics Engineers: journals, conferences, technical standards.","c":["physics"]}, +{"t":"MinutePhysics","u":"http://www.minutephysics.com/","d":"Quick animated physics explanations making complex concepts accessible.","c":["physics"]}, +{"t":"New Scientist","u":"https://www.newscientist.com/","d":"Global science and technology news with accessible explanations for curious minds.","c":["physics"]}, +{"t":"Physics Central","u":"https://www.physicscentral.com/","d":"American Physical Society's public outreach site making physics accessible and fun.","c":["physics"]}, +{"t":"PhysicsForums.com","u":"http://www.physicsforums.com/","d":"Physics discussion community for students and professionals at all levels.","c":["physics"]}, +{"t":"PhysicsWeb","u":"https://physicsweb.org/","d":"The online home of Physics World magazine. Research news, features, job listings.","c":["physics"]}, +{"t":"RoboticsWeekly.com","u":"http://www.roboticsweekly.com/","d":"Robotics news and project guides for hobbyists and researchers.","c":["physics"]}, +{"t":"The Mechanics Central","u":"http://www.mechanicscentral.com/","d":"A mechanics guide: explanations, problems, demonstrations.","c":["physics"]}, +{"t":"The Physics Classroom","u":"http://www.physicsclassroom.com/","d":"Tutorial-based physics education resource for high school and introductory courses.","c":["physics"]}, +{"t":"Thermodynamics.net","u":"http://www.thermodynamics.net/","d":"Thermodynamics tutorials: laws, cycles, engineering applications.","c":["physics"]}, +{"t":"American Psychological Association","u":"https://www.apa.org/","d":"Psychology research, publications, practice guidelines and mental health resources from the APA.","c":["social-sciences"]}, +{"t":"Anthropology.net","u":"http://www.anthropology.net/","d":"Anthropology blog and news: human evolution, archaeology, culture.","c":["social-sciences"]}, +{"t":"AnthropologyCentral","u":"http://www.anthropologycentral.com/","d":"Academic portal exploring Anthropology and human society.","c":["social-sciences"]}, +{"t":"AnthropologyNet.com","u":"http://www.anthropologynet.com/","d":"An anthropology resource: field reports, book reviews, academic materials.","c":["social-sciences"]}, +{"t":"ArchaeologyMag.com","u":"http://www.archaeologymag.com/","d":"Archaeology magazine with excavation reports and artifact analysis.","c":["social-sciences"]}, +{"t":"Bureau of Labor Statistics","u":"http://www.bls.gov/","d":"Employment, wages, inflation and productivity data from the US Department of Labor.","c":["social-sciences"]}, +{"t":"Census Bureau","u":"https://www.census.gov/","d":"U.S. population data, demographic statistics, American Community Survey results.","c":["social-sciences"]}, +{"t":"CriminologyOnline.com","u":"http://www.criminologyonline.com/","d":"Criminology education resource with crime statistics and research methods.","c":["social-sciences"]}, +{"t":"EconLit","u":"http://www.aeaweb.org/econlit/","d":"American Economic Association's bibliography of economics literature.","c":["social-sciences"]}, +{"t":"Gallup","u":"https://www.gallup.com/","d":"Public opinion polls, workplace analytics and social research on American life and global issues.","c":["social-sciences"]}, +{"t":"Linguistics Central Online","u":"http://www.linguisticscentral.com/","d":"Academic portal exploring Linguistics and human society.","c":["social-sciences"]}, +{"t":"Pew Research Center","u":"https://www.pewresearch.org/","d":"Nonpartisan research on social issues, public opinion and demographic trends in America.","c":["social-sciences"]}, +{"t":"PoliSci.com","u":"http://www.polisci.com/","d":"Political science resources: course materials, research links, job listings.","c":["social-sciences"]}, +{"t":"PsychologyToday.com","u":"http://www.psychologytoday.com/","d":"Psychology magazine: a therapist finder, articles, mental health tests.","c":["social-sciences"]}, +{"t":"PsycINFO (APA)","u":"http://www.apa.org/psycinfo/","d":"American Psychological Association's abstract database of behavioral science literature.","c":["social-sciences"]}, +{"t":"SocioSite","u":"http://www.sociosite.net/","d":"A sociology resource: subject guides, data archives, research links.","c":["social-sciences"]}, +{"t":"SSRN","u":"https://www.ssrn.com/","d":"The Social Science Research Network. Working papers across economics, law and the social sciences.","c":["social-sciences"]}, +{"t":"Stanford Encyclopedia of Philosophy","u":"https://plato.stanford.edu/","d":"Peer-reviewed academic philosophy encyclopedia maintained by Stanford University.","c":["social-sciences"]}, +{"t":"UrbanStudies.org","u":"http://www.urbanstudies.org/","d":"Urban studies and city planning research with data and case studies.","c":["social-sciences"]}, +{"t":"AbeBooks.com","u":"http://www.abebooks.com/","d":"A marketplace for new, used, rare and out-of-print books, from independent sellers.","c":["shopping"]}, +{"t":"Abercrombie & Fitch","u":"https://www.abercrombie.com/","d":"Casual luxury clothing for young adults with the signature fragrance-infused shopping experience.","c":["shopping"]}, +{"t":"Accessories Shop Online","u":"http://www.accessoriesshop.com/","d":"Online store offering Accessories at competitive prices.","c":["shopping"]}, +{"t":"ActionFigures.com","u":"http://www.actionfigures.com/","d":"Hasbro's online store for collectible action figures from Marvel, Star Wars, Transformers and G.I. Joe.","c":["shopping"]}, +{"t":"ActivewearStore","u":"http://www.activewearstore.com/","d":"Online boutique carrying Activewear from popular brands.","c":["shopping"]}, +{"t":"Alibris","u":"https://www.alibris.com/","d":"A marketplace for used, rare and out-of-print books, music and movies, from independent sellers.","c":["shopping"]}, +{"t":"Amazon Books","u":"http://www.amazon.com/books/","d":"World's largest online bookstore with millions of titles and reader reviews.","c":["shopping"]}, +{"t":"Amazon Music","u":"https://www.amazon.com/music/","d":"Amazon's music service with a catalog of 100 million songs and new releases.","c":["shopping"]}, +{"t":"Amazon Toys","u":"https://www.amazon.com/toys/","d":"Amazon's toy department: thousands of toys, games and children's items.","c":["shopping"]}, +{"t":"ArtBookShop.com","u":"http://www.artbookshop.com/","d":"An art book retailer: museum publications, monographs, catalogs.","c":["shopping"]}, +{"t":"AudiobooksShop","u":"http://www.audiobooksshop.com/","d":"Online shop carrying Audiobooks across all genres.","c":["shopping"]}, +{"t":"AudioBookStore.com","u":"http://www.audiobookstore.com/","d":"Audiobook retailer with CD and digital downloads across all genres.","c":["shopping"]}, +{"t":"BabyClothes.com","u":"http://www.babyclothes.com/","d":"Baby and toddler clothing retailer with organic and designer options.","c":["shopping"]}, +{"t":"Backcountry.com","u":"https://www.backcountry.com/","d":"Outdoor gear and clothing for skiing, climbing, mountain biking and camping.","c":["shopping"]}, +{"t":"Barnes & Noble Online","u":"https://www.barnesandnoble.com/","d":"Books, DVDs, music and gifts from America's largest bookseller.","c":["shopping"]}, +{"t":"Bass Pro Shops","u":"https://www.basspro.com/","d":"Fishing, hunting, boating and outdoor gear, from one of the largest retail chains in America.","c":["shopping"]}, +{"t":"BathroomRemodel.com","u":"http://www.bathroomremodel.com/","d":"Bathroom remodeling ideas with fixture reviews and cost estimators.","c":["shopping"]}, +{"t":"BeddingShop","u":"http://www.beddingshop.com/","d":"Home shop with Bedding and seasonal collections.","c":["shopping"]}, +{"t":"BetterWorldBooks","u":"http://www.betterworldbooks.com/","d":"Online bookstore funding literacy programs with every purchase.","c":["shopping"]}, +{"t":"BHG.com","u":"https://www.bhg.com/","d":"Better Homes and Gardens: decorating, gardening, recipes, entertaining ideas.","c":["shopping"]}, +{"t":"BlueFly.com","u":"http://www.bluefly.com/","d":"Designer clothing at discount prices: Gucci, Prada, Marc Jacobs.","c":["shopping"]}, +{"t":"BookCloseouts","u":"https://www.bookcloseouts.com/","d":"Bargain books at up to 90% off retail prices, including remainders and publisher overstock.","c":["shopping"]}, +{"t":"BookDepository.com","u":"http://www.bookdepository.com/","d":"Online book retailer with free delivery worldwide on all orders.","c":["shopping"]}, +{"t":"Borders Books","u":"https://www.borders.com/","d":"National bookstore chain. In-store events, book clubs, online ordering.","c":["shopping"]}, +{"t":"BuildABear.com","u":"http://www.buildabear.com/","d":"Custom stuffed animal workshop where kids create personalized plush friends.","c":["shopping"]}, +{"t":"BurpeeSeed.com","u":"http://www.burpee.com/","d":"An American seed and plant company, with vegetable and flower gardening supplies.","c":["shopping"]}, +{"t":"Cabela's","u":"https://www.cabelas.com/","d":"Hunting, fishing, camping and outdoor recreation gear.","c":["shopping"]}, +{"t":"CampMor.com","u":"http://www.campmor.com/","d":"Outdoor recreation retailer. Camping, hiking and backpacking gear since 1978.","c":["shopping"]}, +{"t":"Candy Shop Online","u":"http://www.candyshop.net/","d":"Online store carrying Candy from quality producers.","c":["shopping"]}, +{"t":"CandyWarehouse.com","u":"http://www.candywarehouse.com/","d":"Bulk candy retailer with nostalgic and imported sweets.","c":["shopping"]}, +{"t":"ChessStore.com","u":"http://www.chessstore.com/","d":"A chess set retailer: wooden, glass and tournament-grade boards.","c":["shopping"]}, +{"t":"ChildrensBookShop.com","u":"http://www.childrensbookshop.com/","d":"Used and secondhand children's bookshop near Hay on Wye, with a searchable online catalogue and worldwide ordering.","c":["shopping"]}, +{"t":"ChocolateSource.com","u":"http://www.chocolatesource.com/","d":"Importer and wholesale distributor of Belgian chocolates and confections, with applications for wholesale accounts.","c":["shopping"]}, +{"t":"CostumeShoppe.com","u":"http://www.costumeshoppe.com/","d":"Halloween and costume retailer with character outfits and accessories.","c":["shopping"]}, +{"t":"CraftBeerStore.com","u":"http://www.craftbeerstore.com/","d":"Facebook page of BeerCraft, a craft beer bottle shop in Rohnert Park, California.","c":["shopping"]}, +{"t":"Crate and Barrel","u":"https://www.crateandbarrel.com/","d":"Modern home furnishings and housewares, plus a wedding registry.","c":["shopping"]}, +{"t":"CyclingGearShop.com","u":"http://www.cyclinggearshop.com/","d":"Bicycle and cycling accessories retailer with component reviews.","c":["shopping"]}, +{"t":"Dean & DeLuca","u":"https://www.deandeluca.com","d":"Gourmet food and kitchenware online. Specialty ingredients, coffee, cheese, chocolate.","c":["shopping"]}, +{"t":"dELiA*s","u":"https://www.delias.com/","d":"Teen girl clothing and accessories retailer popular with the mall-shopping Y2K generation.","c":["shopping"]}, +{"t":"Dick's Sporting Goods","u":"https://www.dickssportinggoods.com/","d":"Sporting goods retailer: equipment, apparel and footwear for every sport.","c":["shopping"]}, +{"t":"Discovery Channel Store","u":"https://store.discovery.com/","d":"Science kits, educational toys and nature-themed gifts from the Discovery Channel.","c":["shopping"]}, +{"t":"DollHouseShop.com","u":"http://www.dollhouseshop.com/","d":"A dollhouse retailer: miniatures, furniture, building supplies.","c":["shopping"]}, +{"t":"EasternMountainSports","u":"http://www.ems.com/","d":"Outdoor clothing and gear: climbing, hiking, paddling equipment.","c":["shopping"]}, +{"t":"EBGames.com","u":"http://www.ebgames.com/","d":"Video game retailer: new and used games, consoles, accessories.","c":["shopping"]}, +{"t":"Eddie Bauer","u":"https://www.eddiebauer.com/","d":"Outdoor-inspired clothing and gear for an active lifestyle with a legacy dating to 1920.","c":["shopping"]}, +{"t":"Epicurious","u":"https://www.epicurious.com/","d":"Recipe database from Gourmet and Bon Appétit magazines with over 15,000 tested recipes.","c":["shopping"]}, +{"t":"Ethan Allen","u":"https://www.ethanallen.com/","d":"Classic American furniture and home décor with interior design services.","c":["shopping"]}, +{"t":"Etsy","u":"https://www.etsy.com/","d":"Handmade and vintage goods.","c":["shopping"]}, +{"t":"FAO Schwarz","u":"https://www.fao.com/","d":"Luxury toy store famous for the floor piano and exclusive, high-end toys and stuffed animals.","c":["shopping"]}, +{"t":"FATBrainToys.com","u":"http://www.fatbraintoys.com/","d":"Educational and specialty toys, chosen for how well they are made and what they teach.","c":["shopping"]}, +{"t":"Foot Locker","u":"https://www.footlocker.com/","d":"Athletic shoes, sneakers and sportswear from the major brands. A mall fixture.","c":["shopping"]}, +{"t":"FreshDirect","u":"https://www.freshdirect.com/","d":"Online grocery delivery serving the New York metro area with farm-fresh produce and meats.","c":["shopping"]}, +{"t":"FreshProduceShop","u":"http://www.freshproduceshop.com/","d":"Online food store offering Fresh Produce delivered to your door.","c":["shopping"]}, +{"t":"FuncoLand","u":"https://www.funcoland.com/","d":"Pre-owned video game retailer with locations in malls across America.","c":["shopping"]}, +{"t":"FurnitureStore","u":"http://www.furniturestore.net/","d":"Shopping site featuring Furniture with reviews and decorating tips.","c":["shopping"]}, +{"t":"GameStop","u":"https://www.gamestop.com/","d":"New and used video games, consoles and accessories, with a trade-in program.","c":["shopping"]}, +{"t":"Gap.com","u":"https://www.gap.com","d":"Classic American clothing and accessories. Shop the full Gap collection online.","c":["shopping"]}, +{"t":"Gardeners.com","u":"http://www.gardeners.com/","d":"Gardening supplies, planters, tools and advice from Vermont-based Gardener's Supply.","c":["shopping"]}, +{"t":"GardenGuides.com","u":"http://www.gardenguides.com/","d":"Gardening resource with planting guides and garden supply reviews.","c":["shopping"]}, +{"t":"GardenToolsShop.com","u":"http://www.gardentoolsshop.com/","d":"Gardening tool reviews and sales, with growing advice.","c":["shopping"]}, +{"t":"Gevalia","u":"https://www.gevalia.com/","d":"Kraft Heinz site for a Swedish coffee brand, with ground coffees, K-Cup pods and cappuccino kits.","c":["shopping"]}, +{"t":"GiftIdeas.com","u":"http://www.giftideas.com/","d":"A gift suggestion engine, organized by recipient, occasion and price.","c":["shopping"]}, +{"t":"GolfSmith.com","u":"http://www.golfsmith.com/","d":"Golf equipment superstore: clubs, balls, bags, custom fitting.","c":["shopping"]}, +{"t":"GourmetGifts.com","u":"http://www.gourmetgifts.com/","d":"Gourmet food gift baskets: cheese, chocolate, wine pairings.","c":["shopping"]}, +{"t":"Hamilton Book","u":"https://www.hamiltonbook.com/","d":"Bargain books by mail and online with deep discounts on publishers' overstock.","c":["shopping"]}, +{"t":"HandbagReviews.com","u":"http://www.handbagreview.com/","d":"Designer handbag reviews with authentication guides and resale values.","c":["shopping"]}, +{"t":"Harry and David","u":"https://www.harryanddavid.com/","d":"Gourmet fruit, chocolate and food gifts, delivered nationwide.","c":["shopping"]}, +{"t":"HatsShop","u":"http://www.hatsshop.com/","d":"Online store offering Hats at competitive prices.","c":["shopping"]}, +{"t":"HGTV","u":"https://www.hgtv.com/","d":"Home improvement and garden design ideas from HGTV, with show recaps and project guides.","c":["shopping"]}, +{"t":"HikingBootsShop","u":"http://www.hikingbootsshop.com/","d":"Shopping site featuring Hiking Boots with gear reviews and guides.","c":["shopping"]}, +{"t":"HomeDepot.com","u":"http://www.homedepot.com/","d":"Home improvement retailer: tools, building materials, garden supplies.","c":["shopping"]}, +{"t":"Hot Topic","u":"https://www.hottopic.com/","d":"Pop culture-inspired clothing, accessories and music for teens and young adults.","c":["shopping"]}, +{"t":"Houseplants Shop Online","u":"http://www.houseplantsshop.com/","d":"Home shop with Houseplants and seasonal collections.","c":["shopping"]}, +{"t":"iGourmet.com","u":"http://www.igourmet.com/","d":"Specialty food marketplace: artisan cheeses, charcuterie, gourmet gifts.","c":["shopping"]}, +{"t":"IKEA.com","u":"http://www.ikea.com/","d":"Swedish furniture retailer with affordable, stylish home furnishings.","c":["shopping"]}, +{"t":"J.Crew","u":"https://www.jcrew.com/","d":"Classic American style for men and women: clothing, shoes, accessories.","c":["shopping"]}, +{"t":"JC Penney","u":"https://www.jcpenney.com/","d":"Department store chain. Clothing, home goods and jewelry, online.","c":["shopping"]}, +{"t":"KitchenAppliancePro.com","u":"http://www.kitchenappliancepro.com/","d":"Kitchen appliance reviews with cooking performance tests and price comparisons.","c":["shopping"]}, +{"t":"Kitchenware Shop Online","u":"http://www.kitchenwareshop.com/","c":["shopping"]}, +{"t":"L.L. Bean","u":"https://www.llbean.com/","d":"Outdoor gear, clothing and home goods, backed by a 100% satisfaction guarantee.","c":["shopping"]}, +{"t":"Lands' End","u":"https://www.landsend.com/","d":"Classic American clothing, outerwear and home textiles, with a no-questions-asked return policy.","c":["shopping"]}, +{"t":"LawnCareGuide.com","u":"http://www.lawncareguide.com/","d":"Lawn care equipment reviews with mower comparisons and seasonal maintenance.","c":["shopping"]}, +{"t":"LEGO.com","u":"http://www.lego.com/","d":"The official LEGO store: building sets, minifigures, digital building tools.","c":["shopping"]}, +{"t":"LightingShop","u":"http://www.lightingshop.com/","d":"Shopping site featuring Lighting with reviews and decorating tips.","c":["shopping"]}, +{"t":"LL Cool J Clothing","u":"https://www.fubu.com/","d":"FUBU urban fashion brand: For Us By Us, streetwear clothing and accessories.","c":["shopping"]}, +{"t":"Lowes.com","u":"http://www.lowes.com/","d":"Home improvement retailer: appliances, tools, flooring, outdoor living.","c":["shopping"]}, +{"t":"Martha Stewart","u":"https://www.marthastewart.com/","d":"America's domestic authority on recipes, decorating, entertaining and crafts.","c":["shopping"]}, +{"t":"MensShoes.com","u":"http://www.mensshoes.com/","d":"A men's shoe retailer: dress, casual, athletic.","c":["shopping"]}, +{"t":"MooseJaw.com","u":"http://www.moosejaw.com/","d":"Outdoor clothing and gear retailer with a quirky personality and great customer service.","c":["shopping"]}, +{"t":"Nike Store","u":"https://www.nike.com/","d":"Athletic footwear, apparel and equipment from Nike.","c":["shopping"]}, +{"t":"Nordstrom","u":"https://www.nordstrom.com/","d":"Upscale department store: designer clothing, shoes and accessories, with free shipping.","c":["shopping"]}, +{"t":"Old Navy","u":"https://www.oldnavy.com/","d":"Affordable casual clothing for the whole family from Gap Inc.","c":["shopping"]}, +{"t":"OliveOilSource.com","u":"http://www.oliveoilsource.com/","d":"Premium olive oil retailer with tasting notes and cooking guides.","c":["shopping"]}, +{"t":"Omaha Steaks","u":"https://www.omahasteaks.com/","d":"Steaks, seafood and gourmet food gifts, shipped frozen to your door.","c":["shopping"]}, +{"t":"OrganicGrocer.com","u":"http://www.organicgrocer.com/","d":"Organic and natural food delivery service with farm-fresh produce.","c":["shopping"]}, +{"t":"OutletStore.com","u":"http://www.outletstore.com/","d":"Online outlet store directory with discount designer merchandise.","c":["shopping"]}, +{"t":"Pacific Sunwear","u":"https://www.pacsun.com/","d":"Surf, skate and streetwear apparel and accessories for teens and young adults.","c":["shopping"]}, +{"t":"PeaPod","u":"https://www.peapod.com/","d":"Online grocery delivery service operating in major US metro areas since 1989.","c":["shopping"]}, +{"t":"PetSupplies.com","u":"http://www.petsupplies.com/","d":"Pet supplies: food, toys and healthcare products for all pets.","c":["shopping"]}, +{"t":"Pier 1 Imports","u":"https://www.pier1.com/","d":"Globally-inspired home décor, furniture and gift items for eclectic decorating.","c":["shopping"]}, +{"t":"Pottery Barn","u":"https://www.potterybarn.com/","d":"Home furnishings, décor and accessories with a classic American look.","c":["shopping"]}, +{"t":"Powell's Books","u":"https://www.powells.com/","d":"The world's largest independent bookstore selling new and used books online from Portland, Oregon.","c":["shopping"]}, +{"t":"RareBookSearch.com","u":"http://www.rarebooksearch.com/","d":"Used and rare book search engine comparing prices from antiquarian dealers.","c":["shopping"]}, +{"t":"REI Outlet","u":"https://www.rei.com/outlet/","d":"Discounted outdoor gear and clothing from the co-op's clearance section.","c":["shopping"]}, +{"t":"RemoteControlCars.com","u":"http://www.remotecontrolcars.com/","d":"RC car and truck retailer with racing-grade models and parts.","c":["shopping"]}, +{"t":"Restoration Hardware","u":"https://www.restorationhardware.com/","d":"Luxury home furnishings with a focus on classic American hardware and timeless design.","c":["shopping"]}, +{"t":"ScienceToys.com","u":"http://www.sciencetoys.com/","d":"Science experiment kits and educational toys for aspiring young scientists.","c":["shopping"]}, +{"t":"Shoes Store Online","u":"http://www.shoesstore.com/","c":["shopping"]}, +{"t":"ShoesOnline.com","u":"http://www.shoesonline.com/","d":"Online shoe retailer: brands, styles, size conversion charts.","c":["shopping"]}, +{"t":"ShoesShop","u":"http://www.shoesshop.com/","c":["shopping"]}, +{"t":"Sierra Trading Post","u":"http://www.sierratradingpost.com/","d":"Discount outdoor clothing and gear from name brands at 35-70% off retail.","c":["shopping"]}, +{"t":"SkateBoardShop.com","u":"http://www.skateboardshop.com/","d":"Skateboard retailer. Decks, trucks, wheels, apparel.","c":["shopping"]}, +{"t":"Small Appliances Shop Online","u":"http://www.smallappliancesshop.com/","d":"Retailer specializing in Small Appliances for home and garden.","c":["shopping"]}, +{"t":"SpecialtyFoods.com","u":"http://www.specialtyfoods.com/","d":"A gourmet and specialty food marketplace: artisanal cheeses, chocolates, more.","c":["shopping"]}, +{"t":"SpicesShop","u":"http://www.spicesshop.com/","d":"Online store carrying Spices from quality producers.","c":["shopping"]}, +{"t":"Stuffed Animals Shop Online","u":"http://www.stuffedanimalsshop.com/","d":"Toy shop with Stuffed Animals and holiday collections.","c":["shopping"]}, +{"t":"SunglassesShop","u":"http://www.sunglassesshop.org/","d":"Retailer specializing in Sunglasses for the whole family.","c":["shopping"]}, +{"t":"Swimwear Shop Online","u":"http://www.swimwearshop.com/","d":"Retailer specializing in Swimwear for the whole family.","c":["shopping"]}, +{"t":"Target","u":"https://www.target.com/","d":"Discount retailer: home, clothing, electronics and groceries at everyday low prices.","c":["shopping"]}, +{"t":"Tea Store Online","u":"http://www.teastore.com/","d":"Food market with Tea and gift selections.","c":["shopping"]}, +{"t":"TeaShop.com","u":"http://www.teashop.com/","d":"Premium tea retailer with loose leaf varieties from tea gardens worldwide.","c":["shopping"]}, +{"t":"TeaSource.com","u":"http://www.teasource.com/","d":"Premium loose leaf teas and teaware with tasting notes and brewing guides.","c":["shopping"]}, +{"t":"TextbookRush.com","u":"http://www.textbookrush.com/","d":"Buy, sell and rent textbooks online, with fast shipping and easy returns.","c":["shopping"]}, +{"t":"The Baked Goods Shop","u":"http://www.bakedgoodsshop.com/","d":"Online food store offering Baked Goods delivered to your door.","c":["shopping"]}, +{"t":"The Bathroom Accessories Shop","u":"http://www.bathroomaccessoriesshop.com/","d":"Online home store offering Bathroom Accessories for every room.","c":["shopping"]}, +{"t":"The Belts Shop","u":"http://www.beltsshop.com/","d":"Shopping site featuring Belts with reviews and size guides.","c":["shopping"]}, +{"t":"The Camping Gear Shop","u":"http://www.campinggearshop.com/","d":"Shopping site featuring Camping Gear with gear reviews and guides.","c":["shopping"]}, +{"t":"The Cheese Shop","u":"http://www.cheeseshop.net/","d":"Orange County cheese shop selling artisanal cheeses, charcuterie, sandwiches, gourmet goods and cheese tools.","c":["shopping"]}, +{"t":"The Coffee Store","u":"http://www.coffeestore.com/","d":"Retailer specializing in Coffee for discerning shoppers.","c":["shopping"]}, +{"t":"The Collectible Toys Shop","u":"http://www.collectibletoysshop.com/","d":"Online toy store offering Collectible Toys for kids of all ages.","c":["shopping"]}, +{"t":"The Denim Shop","u":"http://www.denimshop.org/","d":"Online boutique carrying Denim from popular brands.","c":["shopping"]}, +{"t":"The Gourmet Food Shop","u":"http://www.gourmetfoodshop.com/","d":"Gourmet shop featuring Gourmet Food with tasting notes and reviews.","c":["shopping"]}, +{"t":"The Hot Sauce Shop","u":"http://www.hotsauceshop.com/","d":"Food market with Hot Sauce and gift selections.","c":["shopping"]}, +{"t":"The Kitchenware Store","u":"http://www.kitchenwarestore.com/","c":["shopping"]}, +{"t":"The Olive Oil Shop","u":"http://www.oliveoilshop.com/","d":"NYIOOC marketplace selling award-winning extra virgin olive oils from around the world.","c":["shopping"]}, +{"t":"The Outerwear Shop","u":"http://www.outerwearshop.com/","d":"Online store offering Outerwear at competitive prices.","c":["shopping"]}, +{"t":"The Used Books Store","u":"http://www.usedbooksstore.com/","d":"Bookseller specializing in Used Books for every reader.","c":["shopping"]}, +{"t":"The Vintage Clothing Shop","u":"http://www.vintageclothingshop.com/","d":"Apparel shop with Vintage Clothing and seasonal sales.","c":["shopping"]}, +{"t":"ThinkGeek","u":"https://www.thinkgeek.com/","d":"Gifts for geeks: electronics, gadgets, caffeinated everything, tech-themed apparel.","c":["shopping","consumer-electronics"]}, +{"t":"This Old House","u":"https://www.thisoldhouse.com/","d":"Home improvement how-to guides, project ideas and renovation advice, from the PBS show.","c":["shopping"]}, +{"t":"Toys R Us Online","u":"https://www.toysrus.com/","d":"Children's toy and game superstore with online shopping and the toy finder tool.","c":["shopping"]}, +{"t":"Toys.com","u":"https://www.toys.com/","d":"Online toy shopping: the latest action figures, dolls and board games.","c":["shopping"]}, +{"t":"Trading Cards Shop Online","u":"http://www.tradingcardsshop.com/","d":"Online toy store offering Trading Cards for kids of all ages.","c":["shopping"]}, +{"t":"VintageClothing.com","u":"http://www.vintageclothing.com/","d":"Online vintage clothing marketplace with curated collections from every decade.","c":["shopping"]}, +{"t":"VintageJewelry.com","u":"http://www.vintagejewelry.com/","d":"Vintage and antique jewelry marketplace with authentication and valuation.","c":["shopping"]}, +{"t":"VintageToyStore.com","u":"http://www.vintagetoystore.com/","d":"Collectible vintage toys from the 70s, 80s and 90s, with condition grading.","c":["shopping"]}, +{"t":"WeddingRegistry.com","u":"http://www.weddingregistry.com/","d":"Universal wedding registry service aggregating wishlists from multiple stores.","c":["shopping"]}, +{"t":"Williams-Sonoma","u":"https://www.williams-sonoma.com/","d":"Cookware, kitchen appliances and gourmet food gifts for the home chef.","c":["shopping"]}, +{"t":"Wine.com","u":"https://www.wine.com/","d":"Online wine retailer. Ratings, reviews, curated selections shipped to your door.","c":["shopping"]}, +{"t":"WineEnthusiast.com","u":"http://www.wineenthusiast.com/","d":"Wine accessories: storage, glassware, gift collections.","c":["shopping"]}, +{"t":"WineShop","u":"http://www.wineshop.com/","d":"Gourmet shop featuring Wine with tasting notes and reviews.","c":["shopping"]}, +{"t":"WorkwearPro.com","u":"http://www.workwearpro.com/","d":"Professional workwear and safety clothing retailer with size guides.","c":["shopping"]}, +{"t":"AudiophileGear.com","u":"http://www.audiophilegear.com/","d":"High-end audio equipment: amplifiers, speakers, turntables.","c":["consumer-electronics"]}, +{"t":"B&H Photo Video","u":"http://www.bhphotovideo.com/","d":"A professional photography, video and audio equipment superstore in New York City.","c":["consumer-electronics"]}, +{"t":"BargainBoard.com","u":"http://www.bargainboard.com/","d":"Deal forum and coupon sharing community with cashback shopping portal.","c":["consumer-electronics"]}, +{"t":"Best Buy","u":"https://www.bestbuy.com/","d":"National electronics retailer. TVs, computers, appliances, entertainment.","c":["consumer-electronics"]}, +{"t":"Circuit City","u":"https://www.circuitcity.com/","d":"National consumer electronics retailer and Best Buy's primary brick-and-mortar competitor.","c":["consumer-electronics"]}, +{"t":"Crutchfield","u":"https://www.crutchfield.com/","d":"Car and home audio equipment, with advice, installation guides and free tech support.","c":["consumer-electronics"]}, +{"t":"DealNews","u":"https://www.dealnews.com/","d":"Hand-picked deals and coupons for electronics, clothing and online shopping.","c":["consumer-electronics"]}, +{"t":"DigitalCameraShop.com","u":"http://www.digitalcamerashop.com/","d":"Digital camera retailer with model comparisons and accessory bundles.","c":["consumer-electronics"]}, +{"t":"HeadphonesShop","u":"http://www.headphonesshop.com/","d":"Online electronics store offering Headphones at low prices.","c":["consumer-electronics"]}, +{"t":"HomeTheaterShop","u":"http://www.hometheatershop.com/","d":"Online store carrying Home Theater from top manufacturers.","c":["consumer-electronics"]}, +{"t":"MonoPrice.com","u":"http://www.monoprice.com/","d":"Discount cables, adapters and electronics accessories, guaranteed.","c":["consumer-electronics"]}, +{"t":"MP3PlayerReview.com","u":"http://www.mp3playerreview.com/","d":"Portable MP3 player reviews: iPod, Creative, iRiver.","c":["consumer-electronics"]}, +{"t":"RadioShack","u":"https://www.radioshack.com/","d":"Electronics retailer: components, gadgets, tech accessories. You've got questions, we've got answers.","c":["consumer-electronics"]}, +{"t":"RadioShack Parts","u":"https://www.radioshack.com/parts/","d":"Electronic components, resistors, capacitors and project kits, for hobbyists and tinkerers.","c":["consumer-electronics"]}, +{"t":"Sam's Club","u":"https://www.samsclub.com/","d":"Walmart's membership warehouse club with bulk shopping and business supplies online.","c":["consumer-electronics"]}, +{"t":"Sears","u":"https://www.sears.com/","d":"Department store. Appliances, tools, clothing, the Craftsman brand.","c":["consumer-electronics"]}, +{"t":"The Computers Shop","u":"http://www.computersshop.com/","d":"Shopping site featuring Computers with specs and reviews.","c":["consumer-electronics"]}, +{"t":"Deaf Resource Library","u":"http://www.deaflibrary.org/","d":"An information resource on deaf culture, sign language and deaf education.","c":["cultures-groups"]}, +{"t":"GLSEN","u":"https://www.glsen.org/","d":"Gay, Lesbian & Straight Education Network working to ensure safe schools for all students.","c":["cultures-groups"]}, +{"t":"IndigenousPeoples.org","u":"http://www.indigenouspeoples.org/","d":"Indigenous rights and cultural preservation resource with tribal directories.","c":["cultures-groups"]}, +{"t":"Native American Heritage","u":"https://www.nativeculture.com/","d":"Information on Native American tribes, traditions, art and contemporary issues.","c":["cultures-groups"]}, +{"t":"NativeWeb.org","u":"http://www.nativeweb.org/","d":"Resources for indigenous peoples worldwide: culture, rights, education.","c":["cultures-groups"]}, +{"t":"PFLAG.org","u":"http://www.pflag.org/","d":"Support for LGBTQ+ people, their families and allies, with local chapters nationwide.","c":["cultures-groups"]}, +{"t":"350.org","u":"http://www.350.org/","d":"International climate action organization building a global grassroots movement.","c":["environment"]}, +{"t":"AnimalWelfare.org","u":"http://www.animalwelfare.org/","d":"Animal welfare organization directory with advocacy and donation.","c":["environment"]}, +{"t":"CleanAirWatch.org","u":"http://www.cleanairwatch.org/","d":"Air quality monitoring with pollution data and health impact information.","c":["environment"]}, +{"t":"CleanWater.org","u":"http://www.cleanwater.org/","d":"Advocacy organization working since 1972 to win health and environmental protections, including clean water.","c":["environment"]}, +{"t":"CommunityGarden.org","u":"http://www.communitygarden.org/","d":"Community garden directory with starting guides and urban farming tips.","c":["environment"]}, +{"t":"CompostGuide.com","u":"http://www.compostguide.com/","d":"Home composting techniques with bin reviews and troubleshooting.","c":["environment"]}, +{"t":"Conservation International","u":"https://www.conservation.org/","d":"Biodiversity conservation, sustainable development, climate change mitigation.","c":["environment"]}, +{"t":"Earth Day Network","u":"https://www.earthday.net/","d":"Environmental awareness campaigns, an ecological footprint calculator, green living tips.","c":["environment"]}, +{"t":"Earth911","u":"https://www.earth911.com/","d":"A recycling location finder, green living tips and environmental news.","c":["environment"]}, +{"t":"EarthJustice.org","u":"http://www.earthjustice.org/","d":"An environmental law organization using the courts to protect wildlife, wildlands and health.","c":["environment"]}, +{"t":"EcoWatch.com","u":"http://www.ecowatch.com/","d":"Environmental news and information driving action through compelling journalism.","c":["environment"]}, +{"t":"EnvironmentalLaw.org","u":"http://www.environmentallaw.org/","d":"Environmental law resource with regulation summaries and advocacy tools.","c":["environment"]}, +{"t":"FairTradeUSA.org","u":"http://www.fairtradeusa.org/","d":"Fair trade certification information with product directories and producer stories.","c":["environment"]}, +{"t":"FriendsOfTheEarth","u":"http://www.foe.org/","d":"Environmental advocacy organization fighting for a healthy, just world.","c":["environment"]}, +{"t":"HabitatForHumanity.org","u":"http://www.habitat.org/","d":"Habitat for Humanity building affordable housing with volunteer opportunities.","c":["environment"]}, +{"t":"HomeBrewTalk.com","u":"http://www.homebrewtalk.com/","d":"A home brewing community: recipes, equipment reviews, technique forums.","c":["environment"]}, +{"t":"National Resources Defense Council","u":"https://www.nrdc.org/","d":"Environmental advocacy, with reports on clean energy, water, wildlife and healthy communities.","c":["environment"]}, +{"t":"National Wildlife Federation","u":"https://www.nwf.org/","d":"Wildlife conservation, outdoor recreation, environmental education.","c":["environment"]}, +{"t":"Oceana.org","u":"http://www.oceana.org/","d":"International organization dedicated to protecting and restoring the world's oceans.","c":["environment"]}, +{"t":"OceanCleanup.org","u":"http://www.oceancleanup.org/","d":"Ocean pollution cleanup efforts with volunteer opportunities and research.","c":["environment"]}, +{"t":"PlantATree.org","u":"http://www.plantatree.org/","d":"Forest conservation organization that sees healthy forests as a way to slow climate change and advance social equity.","c":["environment"]}, +{"t":"RainforestActionNetwork","u":"http://www.ran.org/","d":"Campaigns to protect rainforests and indigenous rights, plus an effort to stop destructive financing.","c":["environment"]}, +{"t":"RenewableHome.com","u":"http://www.renewablehome.com/","d":"Home renewable energy guide with solar panel and wind turbine reviews.","c":["environment"]}, +{"t":"Sierra Magazine","u":"https://www.sierraclub.org/sierra/","d":"Environmental news, outdoor adventures and conservation reporting from the Sierra Club.","c":["environment"]}, +{"t":"SolarCooking.org","u":"http://www.solarcooking.org/","d":"Solar cooking resource with oven plans and international development.","c":["environment"]}, +{"t":"SurfriderFoundation","u":"http://www.surfrider.org/","d":"Nonprofit dedicated to protecting and enjoying the world's ocean waves and beaches.","c":["environment"]}, +{"t":"SustainableLiving.com","u":"http://www.sustainableliving.com/","d":"Sustainable lifestyle guide with eco-friendly product reviews and tips.","c":["environment"]}, +{"t":"The Clean Energy Network","u":"http://www.cleanenergynetwork.net/","d":"Reference on Clean Energy for environmentally conscious people.","c":["environment"]}, +{"t":"UrbanFarming.org","u":"http://www.urbanfarming.org/","d":"Urban agriculture resource with rooftop gardening and community farming.","c":["environment"]}, +{"t":"WildernessProject","u":"http://www.twp.org/","d":"Nonprofit helping communities protect, conserve and manage their natural resources to improve lives.","c":["environment"]}, +{"t":"AllRecipes","u":"https://www.allrecipes.com/","d":"A home cook community: user-submitted recipes, ratings, reviews, meal planning tools.","c":["food-drink-society-culture"]}, +{"t":"Bon Appetit","u":"https://www.bonappetit.com/","d":"Recipes, restaurant reviews and culinary trends from the food and entertaining magazine.","c":["food-drink-society-culture"]}, +{"t":"Chowhound","u":"https://www.chowhound.com/","d":"Community discussions about food, restaurants and cooking, from serious eaters.","c":["food-drink-society-culture"]}, +{"t":"Cooking Light","u":"https://www.cookinglight.com/","d":"Healthy recipes, meal plans and nutrition information for lighter eating.","c":["food-drink-society-culture"]}, +{"t":"Food Network","u":"https://www.foodnetwork.com/","d":"Recipes, cooking shows, restaurant guides and food entertainment from the Food Network.","c":["food-drink-society-culture"]}, +{"t":"FoodCoop.org","u":"http://www.foodcoop.org/","d":"Food cooperative directory with membership guides and governance resources.","c":["food-drink-society-culture"]}, +{"t":"FoodSubs.com","u":"http://www.foodsubs.com/","d":"Ingredient substitution database for cooks who need to replace recipe ingredients.","c":["food-drink-society-culture"]}, +{"t":"Greek Recipes","u":"https://www.greekrecipes.com/","d":"Greek cooking: traditional recipes, ingredient guides, Mediterranean cuisine.","c":["food-drink-society-culture"]}, +{"t":"Saveur Magazine","u":"https://www.saveur.com/","d":"Recipes, travel-inspired food stories and international culinary traditions.","c":["food-drink-society-culture"]}, +{"t":"SeriousEats.com","u":"http://www.seriouseats.com/","d":"Food blog: recipes, restaurant reviews, food science explained.","c":["food-drink-society-culture"]}, +{"t":"TasteOfHome.com","u":"http://www.tasteofhome.com/","d":"Reader-submitted recipes and cooking tips from home cooks across America.","c":["food-drink-society-culture"]}, +{"t":"The Grilling Network","u":"http://www.grillingnetwork.net/","d":"A reference on grilling: recipes, traditions, advice.","c":["food-drink-society-culture"]}, +{"t":"VeganLife.com","u":"http://www.veganlife.com/","d":"A vegan lifestyle resource: recipes, product reviews, restaurant guides.","c":["food-drink-society-culture"]}, +{"t":"AncestorSearch.com","u":"http://www.ancestorsearch.com/","d":"Family history research tools with census records and surname databases.","c":["genealogy"]}, +{"t":"Ancestry.com","u":"https://www.ancestry.com/","d":"The world's largest genealogy resource with billions of historical records and family trees.","c":["genealogy"]}, +{"t":"CensusRecords.com","u":"http://www.censusrecords.com/","d":"US census record lookup tool with historical population data by household.","c":["genealogy"]}, +{"t":"Cyndi's List","u":"https://www.cyndislist.com/","d":"Categorized and cross-referenced index of over 250,000 genealogy links on the internet.","c":["genealogy"]}, +{"t":"DNAGenealogy.com","u":"http://www.dnagenealogy.com/","d":"Genetic genealogy resource with DNA testing comparisons and result analysis.","c":["genealogy"]}, +{"t":"Ellis Island Records","u":"https://www.ellisisland.org/","d":"Free searchable database of ship passenger arrival records at Ellis Island from 1892 to 1924.","c":["genealogy"]}, +{"t":"FamilySearch","u":"https://www.familysearch.org/","d":"Free genealogy resource from the LDS Church with billions of historical records worldwide.","c":["genealogy"]}, +{"t":"FamilyTreeMaker.com","u":"http://www.familytreemaker.com/","d":"Genealogy software and online family tree building with record hints.","c":["genealogy"]}, +{"t":"FindAGrave.com","u":"http://www.findagrave.com/","d":"Cemetery records database with burial information and memorial photos.","c":["genealogy"]}, +{"t":"Fold3.com","u":"http://www.fold3.com/","d":"Military records and historical documents for genealogy research.","c":["genealogy"]}, +{"t":"Genealogy.com","u":"https://www.genealogy.com/","d":"Family tree building tools, historical records, genealogy how-to guides.","c":["genealogy"]}, +{"t":"GenealogyBank.com","u":"http://www.genealogybank.com/","d":"Historical newspaper archive and obituary search for genealogy research.","c":["genealogy"]}, +{"t":"GenealogyConference.com","u":"http://www.genealogyconference.com/","d":"Genealogy conference and seminar schedule with speaker listings.","c":["genealogy"]}, +{"t":"GraveFinder.com","u":"http://www.gravefinder.com/","d":"Cemetery and grave locator tool with memorial pages and genealogy links.","c":["genealogy"]}, +{"t":"HeritageSociety.org","u":"http://www.heritagesociety.org/","d":"Cultural heritage society directory with ethnic genealogy resources.","c":["genealogy"]}, +{"t":"HistoricMaps.com","u":"http://www.historicmaps.com/","d":"Historical map collection for genealogy research with boundary changes.","c":["genealogy"]}, +{"t":"ImmigrationRecords.com","u":"http://www.immigrationrecords.com/","d":"Immigration record database with Ellis Island and port of entry records.","c":["genealogy"]}, +{"t":"LandRecords.com","u":"http://www.landrecords.com/","d":"Historical land and property record database for genealogy research.","c":["genealogy"]}, +{"t":"MilitaryRecords.com","u":"http://www.militaryrecords.com/","d":"Military service record database for genealogy with pension files.","c":["genealogy"]}, +{"t":"MyHeritage","u":"https://www.myheritage.com/","d":"Family tree building, DNA testing and historical record search.","c":["genealogy"]}, +{"t":"NewspaperArchive.com","u":"http://www.newspaperarchive.com/","d":"Historical newspaper archive with obituaries and family announcements.","c":["genealogy"]}, +{"t":"ObitArchive.com","u":"http://www.obitarchive.com/","d":"Obituary archive searchable by name with memorial tributes and family links.","c":["genealogy"]}, +{"t":"RootsWeb","u":"https://www.rootsweb.com/","d":"Free genealogy: mailing lists, surname databases, collaborative family trees.","c":["genealogy"]}, +{"t":"ShipPassengerLists.com","u":"http://www.shippassengerlists.com/","d":"Immigration ship passenger manifest database for genealogy research.","c":["genealogy"]}, +{"t":"USGenWeb","u":"https://www.usgenweb.org/","d":"Free genealogy resources organized by U.S. state and county, maintained by volunteers.","c":["genealogy"]}, +{"t":"WorldGenWeb","u":"https://www.worldgenweb.org/","d":"International genealogy resources organized by country with volunteer-maintained archives.","c":["genealogy"]}, +{"t":"AmeriCorps.org","u":"http://www.americorps.org/","d":"AmeriCorps national service program with positions and alumni resources.","c":["people-community"]}, +{"t":"BlackPlanet","u":"https://www.blackplanet.com/","d":"News platform for Black America that aggregates news, culture and community content curated by Black journalists and creators.","c":["people-community"]}, +{"t":"Bolt.com","u":"https://www.bolt.com/","d":"A social networking community for teenagers: profiles, messaging, discussion forums.","c":["people-community"]}, +{"t":"DiaryLand","u":"https://www.diaryland.com/","d":"Free online diary hosting with customizable templates and a community of personal bloggers.","c":["people-community"]}, +{"t":"FeedingAmerica.org","u":"http://www.feedingamerica.org/","d":"Nationwide food bank network fighting hunger with food distribution programs.","c":["people-community"]}, +{"t":"Idealist.org","u":"https://www.idealist.org/","d":"Nonprofit job board and volunteer opportunity listings from organizations worldwide.","c":["people-community"]}, +{"t":"iVillage","u":"https://www.ivillage.com/","d":"An online community for women: parenting, relationships, health, career, lifestyle.","c":["people-community"]}, +{"t":"MiGente","u":"https://www.migente.com/","d":"A social network and community site for Latinos: profiles, chat, cultural content.","c":["people-community"]}, +{"t":"OpenDiary","u":"https://www.opendiary.com/","d":"One of the first online diary communities where users share personal journals and comment on entries.","c":["people-community"]}, +{"t":"TeachForAmerica.org","u":"http://www.teachforamerica.org/","d":"Teach For America with corps member stories and application process.","c":["people-community"]}, +{"t":"UnitedWay.org","u":"http://www.unitedway.org/","d":"A community impact organization advancing education, income and health outcomes.","c":["people-community"]}, +{"t":"VeteransConnect.org","u":"http://www.veteransconnect.org/","d":"California organization helping veterans and their families access free or discounted services, grocery deliveries and holiday gifts.","c":["people-community"]}, +{"t":"VolunteerMatch.org","u":"http://www.volunteermatch.org/","d":"Volunteer opportunity matching service connecting people with nonprofit organizations.","c":["people-community"]}, +{"t":"BabyCenter","u":"https://www.babycenter.com/","d":"For new parents: a pregnancy tracker, a baby name finder, parenting advice, community forums.","c":["relationships-family"]}, +{"t":"Care.com","u":"https://www.care.com/","d":"Find and manage family care: babysitters, nannies, elder care, pet sitters.","c":["relationships-family"]}, +{"t":"CareGiverResource","u":"http://www.caregiver.org/","d":"Family Caregiver Alliance supporting those caring for loved ones with chronic conditions.","c":["relationships-family"]}, +{"t":"DatingNetwork","u":"https://www.datingnetwork.com/","d":"Reviews and rankings of dating sites and apps.","c":["relationships-family"]}, +{"t":"eHarmony","u":"https://www.eharmony.com/","d":"Compatibility-based online dating service using Dr. Warren's 29 dimensions of compatibility.","c":["relationships-family"]}, +{"t":"Foster Care Network Online","u":"https://fostercarenetwork.org/","d":"Information for people thinking of becoming foster parents.","c":["relationships-family"]}, +{"t":"FosterCare.com","u":"http://www.fostercare.com/","d":"Foster care information with agency directories and foster parent training.","c":["relationships-family"]}, +{"t":"GrandparentsRights.org","u":"http://www.grandparentsrights.org/","d":"Grandparents' visitation rights legal resource by state.","c":["relationships-family"]}, +{"t":"Marriage.com","u":"http://www.marriage.com/","d":"Relationship advice, marriage counseling resources, couples communication tools.","c":["relationships-family"]}, +{"t":"Match.com","u":"https://www.match.com/","d":"The internet's largest online dating service connecting singles since 1995.","c":["relationships-family"]}, +{"t":"MilitaryFamily.com","u":"https://militaryfamily.com/","d":"Military family resource with deployment support and community connections.","c":["relationships-family"]}, +{"t":"ParentCenter","u":"https://www.parentcenter.com/","d":"Parenting advice, child development milestones, family health information.","c":["relationships-family"]}, +{"t":"Parents.com","u":"https://www.parents.com/","d":"Parenting magazine online. Pregnancy, child development, family health.","c":["relationships-family"]}, +{"t":"SingleParentMag.com","u":"http://www.singleparent.com/","d":"Resources and community for single parents: advice, forums, dating.","c":["relationships-family"]}, +{"t":"TeenHelp.com","u":"http://www.teenhelp.com/","d":"Statistics and facts on teen issues such as suicide, depression, bullying and pregnancy, for teens and parents.","c":["relationships-family"]}, +{"t":"The Knot","u":"https://www.theknot.com/","d":"Wedding planning tools, vendor search, a registry, inspiration for engaged couples.","c":["relationships-family"]}, +{"t":"TwinParents.com","u":"https://www.twinparents.com/","d":"Resource for parents of twins and multiples with practical advice and community.","c":["relationships-family"]}, +{"t":"Wedding Channel","u":"https://www.weddingchannel.com/","d":"Wedding planning, registry and vendor search for brides and grooms.","c":["relationships-family"]}, +{"t":"WeddingWire","u":"https://www.weddingwire.com/","d":"Wedding vendor reviews, planning tools and inspiration galleries.","c":["relationships-family"]}, +{"t":"Baha'i Faith","u":"http://www.bahai.org/","d":"The official Baha'i Faith website: teachings, community, worldwide activities.","c":["religion-spirituality"]}, +{"t":"Beliefnet","u":"https://www.beliefnet.com/","d":"A multi-faith spiritual community. Articles, prayers, sacred texts, forums.","c":["religion-spirituality"]}, +{"t":"Bible Gateway","u":"https://www.biblegateway.com/","d":"Searchable online Bible in over 100 versions and 50 languages with study tools and devotionals.","c":["religion-spirituality"]}, +{"t":"BibleStudyTools.com","u":"http://www.biblestudytools.com/","d":"A Bible study resource: commentaries, concordances, devotional materials.","c":["religion-spirituality"]}, +{"t":"BuddhaNet","u":"https://www.buddhanet.net/","d":"Buddhist information and education: texts, meditations, a directory of centers.","c":["religion-spirituality"]}, +{"t":"Buddhism Network Online","u":"http://www.buddhismnetwork.com/","d":"Resource exploring Buddhism with articles and discussion.","c":["religion-spirituality"]}, +{"t":"Chabad.org","u":"https://www.chabad.org/","d":"Jewish information, Torah study, holiday guides, community resources.","c":["religion-spirituality"]}, +{"t":"ChurchFinder.com","u":"http://www.churchfinder.com/","d":"Church and worship community directory searchable by denomination and location.","c":["religion-spirituality"]}, +{"t":"Crosswalk","u":"https://www.crosswalk.com/","d":"Christian news, Bible study tools, devotionals, community forums.","c":["religion-spirituality"]}, +{"t":"Hinduism Today","u":"https://www.hinduismtoday.com/","d":"International Hindu magazine: festivals, temples, spiritual practice.","c":["religion-spirituality"]}, +{"t":"Interfaith Alliance","u":"https://www.interfaithalliance.org/","d":"Nonpartisan organization promoting interfaith dialogue and protecting religious liberty.","c":["religion-spirituality"]}, +{"t":"IslamCity.com","u":"http://www.islamicity.com/","d":"An Islamic portal: Quran, hadith, prayer times, community resources.","c":["religion-spirituality"]}, +{"t":"IslamOnline","u":"https://www.islamonline.net/","d":"Islamic information portal, with articles on faith, culture, current affairs and family life.","c":["religion-spirituality"]}, +{"t":"MeditationCenter.org","u":"http://www.meditationcenter.org/","d":"Meditation and mindfulness resource with technique guides and retreat listings.","c":["religion-spirituality"]}, +{"t":"Pagan Library","u":"https://www.paganlibrary.com/","d":"Resources on Wicca, paganism and earth-based spiritual traditions, with articles and rituals.","c":["religion-spirituality"]}, +{"t":"Patheos.com","u":"http://www.patheos.com/","d":"Multi-faith platform hosting blogs and resources across religious traditions.","c":["religion-spirituality"]}, +{"t":"Sacred Texts","u":"https://www.sacred-texts.com/","d":"An archive of public domain religious texts, folklore and mythology from around the world.","c":["religion-spirituality"]}, +{"t":"World Prayers","u":"https://www.worldprayers.org/","d":"A collection of prayers, blessings and meditations from world religions and spiritual traditions.","c":["religion-spirituality"]}, +{"t":"AARP Online","u":"https://www.aarp.org/","d":"For Americans age 50 and over: resources, advocacy, member benefits, community.","c":["seniors"]}, +{"t":"ActiveAging.org","u":"http://www.activeaging.org/","d":"An active lifestyle resource for seniors: travel, fitness, social engagement.","c":["seniors"]}, +{"t":"ActiveRetirement.com","u":"http://www.activeretirement.com/","d":"Retirement lifestyle resource with activity ideas and volunteer opportunities.","c":["seniors"]}, +{"t":"AginginPlaceNetwork","u":"http://www.aginginplacenetwork.net/","d":"Community hub focused on Aging in Place and the senior years.","c":["seniors"]}, +{"t":"Assisted Living Network Online","u":"http://www.assistedlivingnetwork.com/","d":"Community hub focused on Assisted Living and the senior years.","c":["seniors"]}, +{"t":"BenefitsCheckUp.org","u":"http://www.benefitscheckup.org/","d":"An NCOA tool for finding federal, state and private benefits programs for seniors.","c":["seniors"]}, +{"t":"ElderCare.com","u":"http://www.eldercare.com/","d":"Elder care planning resource with facility directories and care options.","c":["seniors"]}, +{"t":"ElderLaw.net","u":"http://www.elderlaw.net/","d":"Elder law attorney directory with estate planning and Medicaid information.","c":["seniors"]}, +{"t":"Grand Magazine","u":"https://www.grandmagazine.com/","d":"Lifestyle magazine for grandparents. Family activities, travel, health.","c":["seniors"]}, +{"t":"GrandparentsDay.org","u":"http://www.grandparentsday.org/","d":"National Grandparents Day Foundation celebrating the bond between generations.","c":["seniors"]}, +{"t":"GrannyFlat.com","u":"http://www.grannyflat.com/","d":"An accessory dwelling unit resource: plans, financing, regulations.","c":["seniors"]}, +{"t":"Lifelong Learning Network Online","u":"http://www.lifelonglearningnetwork.net/","d":"Reference on Lifelong Learning for retirees and their caregivers.","c":["seniors"]}, +{"t":"LifelongLearning.org","u":"http://www.lifelonglearning.org/","d":"Continuing education for seniors with course listings and university programs.","c":["seniors"]}, +{"t":"Medicare.gov","u":"http://www.medicare.gov/","d":"The official Medicare website: plan comparisons, enrollment, coverage information.","c":["seniors"]}, +{"t":"RetiredBrains","u":"https://www.retiredbrains.com/","d":"Job listings, volunteering and lifestyle resources for active retirees and seniors.","c":["seniors"]}, +{"t":"RetirementLiving.com","u":"http://www.retirementliving.com/","d":"Retirement community reviews with cost comparisons and lifestyle information.","c":["seniors"]}, +{"t":"Senior Health Network Online","u":"http://www.seniorhealthnetwork.com/","d":"Resource exploring Senior Health with tips and helpful information.","c":["seniors"]}, +{"t":"Senior Living","u":"https://www.seniorliving.org/","d":"A directory of assisted living facilities, senior housing options and eldercare resources.","c":["seniors"]}, +{"t":"SeniorDiscount.com","u":"http://www.seniordiscount.com/","d":"A senior citizen discount directory: restaurants, retail, travel.","c":["seniors"]}, +{"t":"SeniorFitness.com","u":"http://www.seniorfitness.com/","d":"Exercise and wellness programs designed for older adults with safety guidelines.","c":["seniors"]}, +{"t":"SeniorLiving.com","u":"http://www.seniorliving.com/","d":"A senior housing directory: assisted living, nursing homes, retirement communities.","c":["seniors"]}, +{"t":"SeniorLivingGuide.com","u":"http://www.seniorlivingguide.com/","d":"Elder care resource with assisted living reviews and caregiver support.","c":["seniors"]}, +{"t":"SeniorMag.com","u":"http://www.seniormag.com/","d":"Directory of senior living communities across all 50 states, comparing assisted living, memory care and nursing homes.","c":["seniors"]}, +{"t":"SeniorNet.org","u":"http://www.seniornet.org/","d":"Technology education for older adults with computer classes and community.","c":["seniors"]}, +{"t":"SeniorPlanet.org","u":"http://www.seniorplanet.org/","d":"Technology training and community engagement for older adults.","c":["seniors"]}, +{"t":"The Estate Planning Network","u":"http://www.estateplanningnetwork.com/","d":"Network connecting seniors around Estate Planning and active living.","c":["seniors"]}, +{"t":"ThirdAge","u":"https://www.thirdage.com/","d":"A lifestyle site for baby boomers and adults over 40: health, travel, finances.","c":["seniors"]}, +{"t":"AI Topics (AAAI)","u":"https://aitopics.org","d":"Artificial intelligence resources from the Association for the Advancement of AI. Research and news.","c":[]}, +{"t":"BoatingMagazine.com","u":"http://www.boatingmagazine.com/","d":"Boating magazine: boat reviews, maintenance tips, waterway guides.","c":[]}, +{"t":"Camping World","u":"https://www.campingworld.com/","d":"Camping gear, RV accessories and outdoor equipment.","c":[]}, +{"t":"CanoeKayak.com","u":"http://www.canoekayak.com/","d":"Paddling magazine: gear reviews, trip guides, technique videos.","c":[]}, +{"t":"ContentMarketing.com","u":"http://www.contentmarketing.com/","d":"Content marketing strategy resource with editorial calendar templates.","c":[]}, +{"t":"CulinaryTravel.com","u":"http://www.culinarytravel.com/","d":"Food and culinary travel guide with cooking class and market tours.","c":[]}, +{"t":"CustomerServiceJobs","u":"http://www.customerservicejobs.com/","d":"Career guide for the Customer Service field with salary data and interview advice.","c":[]}, +{"t":"DatabaseJournal.com","u":"http://www.databasejournal.com/","d":"Database administration tutorials: Oracle, SQL Server, MySQL, PostgreSQL.","c":[]}, +{"t":"FoodPhotography.com","u":"http://www.foodphotography.com/","d":"Food photography tutorials: lighting, styling, equipment.","c":[]}, +{"t":"Freshmeat","u":"https://freshmeat.net/","d":"Index of open source and Unix software releases with changelogs and user ratings.","c":[]}, +{"t":"History.com","u":"https://www.history.com","d":"The History Channel online: articles, videos and timelines on world history.","c":[]}, +{"t":"HobbyTron","u":"https://www.hobbytron.com/","d":"Online toy store with action toys, collectibles and vehicles, including Marvel and Disney toys.","c":[]}, +{"t":"HospitalityJobs.com","u":"http://www.hospitalityjobs.com/","d":"Hotel and restaurant industry job board with management positions.","c":[]}, +{"t":"KayakAnglerMag.com","u":"http://www.kayakanglermag.com/","d":"Kayak fishing magazine: gear reviews, technique articles, tournament coverage.","c":[]}, +{"t":"LinuxHowtos.org","u":"http://www.linuxhowtos.org/","d":"Linux tutorial and HOWTO document archive for system administration.","c":[]}, +{"t":"LinuxToday","u":"https://www.linuxtoday.com","d":"Daily Linux news and articles. Community discussion and announcements.","c":[]}, +{"t":"Manufacturing Jobs Online","u":"http://www.manufacturingjobs.com/","c":[]}, +{"t":"Mexico Connect","u":"https://www.mexicoconnect.com","d":"A guide to living, working and traveling in Mexico, with an expat community and travel resources.","c":[]}, +{"t":"MySQL.com","u":"https://www.mysql.com","d":"Open source relational database. Documentation, downloads, developer resources.","c":[]}, +{"t":"OpenSourceProjects.org","u":"http://www.opensourceprojects.org/","d":"Directory of open source projects seeking contributors with skill matching.","c":[]}, +{"t":"PostgreSQL.org","u":"http://www.postgresql.org/","d":"Advanced open source relational database with enterprise features and extensibility.","c":[]}, +{"t":"REI","u":"https://www.rei.com/","d":"An outdoor gear co-op. Equipment guides, reviews, adventure inspiration.","c":[]}, +{"t":"SourceForge","u":"https://sourceforge.net/","d":"The world's largest open source software development and distribution platform.","c":[]}, +{"t":"SQLTutorial.org","u":"http://www.sqltutorial.org/","d":"SQL query tutorial for beginners with interactive practice exercises.","c":[]}, +{"t":"The Administrative Jobs","u":"http://www.administrativejobs.com/","c":[]}, +{"t":"The Cross-Stitch Central","u":"http://www.crossstitchcentral.com/","d":"Cross-stitch: patterns, tutorials, tips.","c":[]} +]} diff --git a/app/src/main/assets/stickers/flamenet.retro.json b/app/src/main/assets/stickers/flamenet.retro.json new file mode 100644 index 0000000..7bbae36 --- /dev/null +++ b/app/src/main/assets/stickers/flamenet.retro.json @@ -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" } + ] +} diff --git a/app/src/main/assets/stickers/flamenet.retro/balloon.png b/app/src/main/assets/stickers/flamenet.retro/balloon.png new file mode 100644 index 0000000..ecb0d19 Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/balloon.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/brb.png b/app/src/main/assets/stickers/flamenet.retro/brb.png new file mode 100644 index 0000000..6679a9c Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/brb.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/cake.png b/app/src/main/assets/stickers/flamenet.retro/cake.png new file mode 100644 index 0000000..adfc79e Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/cake.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/candle.png b/app/src/main/assets/stickers/flamenet.retro/candle.png new file mode 100644 index 0000000..4251ac2 Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/candle.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/coffee.png b/app/src/main/assets/stickers/flamenet.retro/coffee.png new file mode 100644 index 0000000..9f09421 Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/coffee.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/computer.png b/app/src/main/assets/stickers/flamenet.retro/computer.png new file mode 100644 index 0000000..03721f5 Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/computer.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/construction.png b/app/src/main/assets/stickers/flamenet.retro/construction.png new file mode 100644 index 0000000..c05c0d6 Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/construction.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/flame.png b/app/src/main/assets/stickers/flamenet.retro/flame.png new file mode 100644 index 0000000..dba0a60 Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/flame.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/floppy.png b/app/src/main/assets/stickers/flamenet.retro/floppy.png new file mode 100644 index 0000000..96a2084 Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/floppy.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/flowers.png b/app/src/main/assets/stickers/flamenet.retro/flowers.png new file mode 100644 index 0000000..bb72303 Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/flowers.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/gift.png b/app/src/main/assets/stickers/flamenet.retro/gift.png new file mode 100644 index 0000000..331c34e Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/gift.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/heart.png b/app/src/main/assets/stickers/flamenet.retro/heart.png new file mode 100644 index 0000000..ac9ac2d Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/heart.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/hourglass.png b/app/src/main/assets/stickers/flamenet.retro/hourglass.png new file mode 100644 index 0000000..0bc4d13 Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/hourglass.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/lol.png b/app/src/main/assets/stickers/flamenet.retro/lol.png new file mode 100644 index 0000000..0f0caf0 Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/lol.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/love-letter.png b/app/src/main/assets/stickers/flamenet.retro/love-letter.png new file mode 100644 index 0000000..9611ad9 Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/love-letter.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/moon.png b/app/src/main/assets/stickers/flamenet.retro/moon.png new file mode 100644 index 0000000..a30a3be Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/moon.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/music.png b/app/src/main/assets/stickers/flamenet.retro/music.png new file mode 100644 index 0000000..f96d0ee Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/music.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/new.png b/app/src/main/assets/stickers/flamenet.retro/new.png new file mode 100644 index 0000000..ab71da3 Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/new.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/paper-plane.png b/app/src/main/assets/stickers/flamenet.retro/paper-plane.png new file mode 100644 index 0000000..20bc6ca Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/paper-plane.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/pizza.png b/app/src/main/assets/stickers/flamenet.retro/pizza.png new file mode 100644 index 0000000..7e923bc Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/pizza.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/star.png b/app/src/main/assets/stickers/flamenet.retro/star.png new file mode 100644 index 0000000..9c6a13c Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/star.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/sun.png b/app/src/main/assets/stickers/flamenet.retro/sun.png new file mode 100644 index 0000000..71b866e Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/sun.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/thumbs-up.png b/app/src/main/assets/stickers/flamenet.retro/thumbs-up.png new file mode 100644 index 0000000..fdf158d Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/thumbs-up.png differ diff --git a/app/src/main/assets/stickers/flamenet.retro/trophy.png b/app/src/main/assets/stickers/flamenet.retro/trophy.png new file mode 100644 index 0000000..f13cd59 Binary files /dev/null and b/app/src/main/assets/stickers/flamenet.retro/trophy.png differ diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/AddressCard.kt b/app/src/main/kotlin/io/flamenet/messenger/app/AddressCard.kt index c9a3744..4c0cd46 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/AddressCard.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/AddressCard.kt @@ -3,6 +3,7 @@ package io.flamenet.messenger.app import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.content.Intent import android.graphics.Bitmap import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement @@ -34,13 +35,13 @@ import io.flamenet.messenger.R import android.graphics.Color as AndroidColor /** - * Your address, as a code somebody can scan and a string they can copy. + * Your address, as a code somebody can scan and a string they can copy, and + * the hello link (§6.34) that carries it. */ @Composable fun AddressCard( fnid: String?, relay: String, - handle: String? = null, ) { val context = LocalContext.current @@ -59,13 +60,11 @@ fun AddressCard( } val address = "$fnid@$relay" - // The handle rides in the CODE only, never in the copyable string, it - // lets a same-relay scanner resolve the account through the ordinary - // username lookup. - val encoded = - "flamenet:$address" + - (handle?.takeIf { it.isNotBlank() }?.let { "?u=$it" } ?: "") - val bitmap = remember(encoded) { qrBitmap(encoded) } + // The code holds the hello link, so a phone's own camera opens the + // relay's page for it and this app's scanner reads the address out of + // it. Null only for an address that is not one, which nothing derives. + val hello = HelloLink.forAddress(address) + val bitmap = remember(hello, address) { qrBitmap(hello ?: address) } Column( modifier = Modifier.fillMaxWidth(), @@ -74,7 +73,12 @@ fun AddressCard( if (bitmap != null) { Image( bitmap = bitmap.asImageBitmap(), - contentDescription = stringResource(R.string.social_address_qr_desc), + contentDescription = + if (hello != null) { + stringResource(R.string.social_address_qr_hello_desc) + } else { + stringResource(R.string.social_address_qr_desc) + }, // None, or the modules blur when scaled up and the code // stops scanning, the default filtering is smooth. filterQuality = FilterQuality.None, @@ -114,9 +118,66 @@ fun AddressCard( stringResource(R.string.social_address_explainer), style = MaterialTheme.typography.bodySmall, ) + + if (hello != null) { + Spacer(Modifier.height(Space.lg)) + HelloLinkSection(hello) + } } } +/** + * The hello link: your address as a link anyone can open, to put on a FlameNet + * profile or anywhere else. + */ +@Composable +private fun HelloLinkSection(link: String) { + val context = LocalContext.current + Text("Hello link", style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(Space.xs)) + Text( + link, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.testTag("hello-link"), + ) + Spacer(Modifier.height(Space.sm)) + Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) { + OutlinedButton( + onClick = { copy(context, "Flamenet hello link", link) }, + modifier = Modifier.testTag("copy-hello-link"), + ) { Text("Copy link") } + OutlinedButton( + onClick = { share(context, link) }, + modifier = Modifier.testTag("share-hello-link"), + ) { Text("Share link") } + } + Spacer(Modifier.height(Space.sm)) + Text( + HELLO_LINK_EXPLAINED, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.testTag("hello-link-explained"), + ) +} + +/** What a hello link does, and what it does not. */ +internal const val HELLO_LINK_EXPLAINED = + "Put it on your FlameNet profile, or anywhere you like. Whoever opens it can " + + "ask to message you. FlameNet doesn't learn anything from the messenger, and " + + "the messenger doesn't learn your FlameNet name." + +/** The system's share sheet. Nothing is sent by this app. */ +private fun share( + context: Context, + link: String, +) { + val send = + Intent(Intent.ACTION_SEND) + .setType("text/plain") + .putExtra(Intent.EXTRA_TEXT, link) + runCatching { context.startActivity(Intent.createChooser(send, "Share your hello link")) } +} + private fun copy( context: Context, label: String, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/AssetImages.kt b/app/src/main/kotlin/io/flamenet/messenger/app/AssetImages.kt new file mode 100644 index 0000000..1a5ae29 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/AssetImages.kt @@ -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>() + + 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) +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CardArt.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CardArt.kt new file mode 100644 index 0000000..547931d --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CardArt.kt @@ -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/.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." +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CardUi.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CardUi.kt new file mode 100644 index 0000000..4df54d4 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CardUi.kt @@ -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(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 diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatGames.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatGames.kt new file mode 100644 index 0000000..906a01a --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatGames.kt @@ -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 = 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 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, + ): 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, + ): 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), + ) + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 44a2d31..1535a32 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -779,7 +779,7 @@ fun ColumnScope.AccountScreen( SettingsSection { // Beside the address it replaced (audit F5). phase.addressChangedFrom?.let { AddressChangedNotice(it) } - AddressCard(fnid = phase.fnid, relay = phase.relay, handle = phase.handle) + AddressCard(fnid = phase.fnid, relay = phase.relay) } PortalBand() @@ -1694,6 +1694,8 @@ fun ColumnScope.ChatScreen( heldMessages: Map = emptyMap(), /** Conversations where a send could not go sealed (§14.5). */ unsealedSendAt: Map = emptyMap(), + /** How this account signs a card unless the person changes it. */ + myName: String = "", ) { // Keyed on the thread, so switching conversations re-reads rather than // carrying one person's half-written message into somebody else's composer. @@ -1848,6 +1850,24 @@ fun ColumnScope.ChatScreen( /** The message the next send answers (android#16). */ var replying by remember(chat.threadId) { mutableStateOf(null) } + // §6.31 and §6.32: what is being made, opened, changed or cancelled. + var composingCard by remember(chat.threadId) { mutableStateOf(false) } + var composingEvent by remember(chat.threadId) { mutableStateOf(false) } + var composingGame by remember(chat.threadId) { mutableStateOf(false) } + var openCard by remember(chat.threadId) { mutableStateOf(null) } + var sealedCardOpensOn by remember(chat.threadId) { mutableStateOf(null) } + var editingEvent by remember(chat.threadId) { mutableStateOf(null) } + var cancellingEvent by remember(chat.threadId) { mutableStateOf(null) } + val nameOf: (Long) -> String = { id -> + when { + id == myUserId -> "you" + chat.group == null && id == chat.peerId -> chat.title + else -> + buddies.firstOrNull { it.userId == id }?.let { it.displayName.ifBlank { it.handle } } ?: "user $id" + } + } + val screenContext = LocalContext.current + // §6.29. Above the messages rather than in them: a poll is not a bubble, // it changes as pieces arrive, and it has to stay reachable while people // are still voting. @@ -1880,7 +1900,7 @@ fun ColumnScope.ChatScreen( ) { Text(stringResource(R.string.chat_pinned), fontSize = 11.sp, color = Palette.SecondaryText) Text( - pinned.text.take(80), + pinned.summary.take(80), fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -1908,12 +1928,81 @@ fun ColumnScope.ChatScreen( groups = groups, onDismiss = { forwarding = null }, onPick = { peerId, group -> - model.forwardMessage(line.text, peerId, group) + if (line.card != null || line.event != null) { + model.forwardCardOrEvent(line, peerId, group) + } else { + model.forwardMessage(line.text, peerId, group) + } forwarding = null }, ) } + openCard?.let { card -> CardInsideDialog(card) { openCard = null } } + sealedCardOpensOn?.let { day -> SealedCardDialog(day) { sealedCardOpensOn = null } } + if (composingCard) { + NewCardDialog( + defaultTo = if (chat.group == null) chat.title else "", + defaultFrom = myName, + onDismiss = { composingCard = false }, + onSend = { card -> + model.sendCard(chat, card) + composingCard = false + }, + ) + } + if (composingGame) { + NewGameDialog( + onDismiss = { composingGame = false }, + onStart = { type, iGoFirst -> + model.startGame(chat, type, iGoFirst) + composingGame = false + }, + ) + } + if (composingEvent) { + EventEditorDialog( + initial = null, + onDismiss = { composingEvent = false }, + onSave = { event -> + model.sendEvent(chat, event) + composingEvent = false + }, + ) + } + editingEvent?.let { state -> + EventEditorDialog( + initial = state.event, + onDismiss = { editingEvent = null }, + onSave = { event -> + model.reviseEvent(chat, state.event.id, event) + editingEvent = null + }, + ) + } + cancellingEvent?.let { state -> + androidx.compose.material3.AlertDialog( + onDismissRequest = { cancellingEvent = null }, + title = { Text("Cancel ${state.event.shownTitle}?") }, + text = { + Text( + "Everyone in this chat is told it is cancelled. Their answers stay on the card.", + fontSize = 14.sp, + ) + }, + confirmButton = { + TextButton( + onClick = { + model.cancelEvent(chat, state.event.id) + cancellingEvent = null + }, + modifier = Modifier.testTag("event-cancel-confirm"), + ) { Text("Cancel event", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { TextButton(onClick = { cancellingEvent = null }) { Text("Keep it") } }, + ) + } + reporting?.let { line -> // WHO wrote it, which in a group is not who the conversation is with. // @@ -1948,12 +2037,13 @@ fun ColumnScope.ChatScreen( model.reportStoryProblem(stringResource(R.string.chat_report_nobody)) reporting = null } else { + val words = reportableWords(line) ReportSheet( authorName = authorName, - preview = line.text, + preview = words, onDismiss = { reporting = null }, onSend = { reason, includeContent, note -> - model.reportMessage(line.text, author, reason, includeContent, note) + model.reportMessage(words, author, reason, includeContent, note) reporting = null }, onBlock = { @@ -2014,6 +2104,20 @@ fun ColumnScope.ChatScreen( }, // Only for a message you did not send. picture = authorId?.let { avatars[it] }, + chatTitle = chat.title, + nameOf = nameOf, + onOpenCard = { openCard = it }, + onSealedCard = { sealedCardOpensOn = it }, + onAnswer = { eventId, answer -> model.answerEvent(chat, eventId, answer) }, + onCalendar = { event -> + if (!EventCalendar.open(screenContext, event)) { + model.say("No calendar app on this phone would take the event.") + } + }, + onGameMove = { gameId, move -> model.playGameMove(chat, gameId, move) }, + onGameEnd = { gameId, ending -> model.endGame(chat, gameId, ending) }, + onNewGameLike = { state -> model.startGameLike(chat, state) }, + onLongPress = { menuOpen = true }, ) DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { // The reactions are IN the menu, as its first row, rather @@ -2077,15 +2181,37 @@ fun ColumnScope.ChatScreen( } } } - if (line.text.isNotBlank()) { + val copyable = copyableWords(line) + if (copyable.isNotBlank()) { DropdownMenuItem( text = { Text(stringResource(R.string.common_copy)) }, onClick = { - clipboard.setText(AnnotatedString(line.text)) + clipboard.setText(AnnotatedString(copyable)) menuOpen = false }, ) } + // §6.32: only the organizer changes or cancels an event. + line.event?.takeIf { it.organizer == myUserId && !it.organizerProvisional }?.let { state -> + DropdownMenuItem( + text = { Text("Change event") }, + onClick = { + editingEvent = state + menuOpen = false + }, + modifier = Modifier.testTag("edit-event"), + ) + if (!state.event.cancelled) { + DropdownMenuItem( + text = { Text("Cancel event") }, + onClick = { + cancellingEvent = state + menuOpen = false + }, + modifier = Modifier.testTag("cancel-event"), + ) + } + } // Every control below needs an id to point at, so all of // them are absent, not disabled, on a message written // before ids existed. @@ -2113,7 +2239,7 @@ fun ColumnScope.ChatScreen( // Needs an id to point at, like Edit and Pin, and a 1:1 // thread, because the group send path does not carry a // reply yet. - if (line.id != null && line.text.isNotBlank() && chat.peerId != null) { + if (line.id != null && line.summary.isNotBlank() && chat.peerId != null) { DropdownMenuItem( text = { Text(stringResource(R.string.chat_menu_reply)) }, onClick = { @@ -2123,7 +2249,7 @@ fun ColumnScope.ChatScreen( modifier = Modifier.testTag("reply-message"), ) } - if (line.text.isNotBlank()) { + if (canForward(line)) { DropdownMenuItem( text = { Text(stringResource(R.string.chat_menu_forward)) }, onClick = { @@ -2134,7 +2260,7 @@ fun ColumnScope.ChatScreen( ) } // Only on messages somebody else sent (android#20). - if (!line.mine && line.text.isNotBlank()) { + if (!line.mine && line.summary.isNotBlank()) { DropdownMenuItem( text = { Text(stringResource(R.string.chat_menu_report)) }, onClick = { @@ -2278,7 +2404,7 @@ fun ColumnScope.ChatScreen( color = Palette.SecondaryText, ) Text( - line.text, + line.summary, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -2306,7 +2432,7 @@ fun ColumnScope.ChatScreen( val answering = replying?.let { line -> line.id?.let { id -> - Content.Reference(id, line.author, line.text.take(140)) + Content.Reference(id, line.author, line.summary.take(140)) } } model.sendMessage(draft, named, replyTo = answering) @@ -2483,6 +2609,24 @@ fun ColumnScope.ChatScreen( showAttach = false pickingFile = true }, + onCard = { + showAttach = false + composingCard = true + }, + onEvent = { + showAttach = false + composingEvent = true + }, + // §6.33: games are one-to-one only. + onGame = + if (chat.peerId != null && chat.group == null) { + { + showAttach = false + composingGame = true + } + } else { + null + }, ) } @@ -2500,7 +2644,7 @@ fun ColumnScope.ChatScreen( if (showStickers) { StickerPicker { sticker -> // A sticker IS the message. - model.sendSticker(chat, sticker.id) + model.sendSticker(chat, sticker.pack, sticker.id) showStickers = false } } @@ -2609,6 +2753,19 @@ private fun Bubble( myUserId: Long = 0L, authorName: String = "", picture: io.flamenet.e2e.Attachment.Ref? = null, + /** Who a sealed card of ours is for, when the card names nobody. */ + chatTitle: String = "", + /** A member's name, for who answered an event. */ + nameOf: (Long) -> String = { "user $it" }, + onOpenCard: (Content.Card) -> Unit = {}, + onSealedCard: (opensOn: String) -> Unit = {}, + onAnswer: (eventId: String, answer: String) -> Unit = { _, _ -> }, + onCalendar: (Content.Event) -> Unit = {}, + onGameMove: (gameId: String, move: String) -> Unit = { _, _ -> }, + onGameEnd: (gameId: String, ending: String) -> Unit = { _, _ -> }, + onNewGameLike: (ChatGames.State) -> Unit = {}, + /** A card, an event or a game takes taps itself, so it passes the long press on. */ + onLongPress: () -> Unit = {}, ) { val displayContext = LocalContext.current val display = remember { DisplaySettings(displayContext) } @@ -2715,21 +2872,63 @@ private fun Bubble( val stk = line.sticker if (stk != null) { val context = LocalContext.current - val stickerLabel = - stringResource(R.string.chat_sticker_description, StickerPack.name(context, stk.pack, stk.id)) - Text( - StickerPack.glyph(context, stk.pack, stk.id), - fontSize = 64.sp, + val drawn = remember(stk) { StickerPack.resolve(context, stk.pack, stk.id) } + val stickerLabel = stringResource(R.string.chat_sticker_description, drawn.name) + StickerArt( + drawn, + size = 96.dp, modifier = Modifier .padding(horizontal = Kit.bubblePadding) - .semantics { - contentDescription = stickerLabel - }.testTag("sticker-${stk.id}"), + .semantics { contentDescription = stickerLabel } + .testTag("sticker-${stk.id}"), ) return@Column } + // §6.31 and §6.32. A card and an event draw as themselves rather + // than inside a bubble, and keep their reactions underneath. + val card = line.card + val event = line.event + if (card != null) { + CardBubble( + line = line, + card = card, + forName = card.shownTo ?: chatTitle, + onOpen = { onOpenCard(card) }, + onSealed = onSealedCard, + onLongPress = onLongPress, + ) + Reactions(line, myUserId) + return@Column + } + val game = line.game + if (game != null) { + GameBubble( + state = game, + peerName = chatTitle, + onMove = { move -> onGameMove(game.id, move) }, + onEnd = { ending -> onGameEnd(game.id, ending) }, + onNewGame = { onNewGameLike(game) }, + onLongPress = onLongPress, + ) + Reactions(line, myUserId) + return@Column + } + if (event != null) { + EventBubble( + line = line, + state = event, + myUserId = myUserId, + nameOf = nameOf, + onAnswer = { answer -> onAnswer(event.event.id, answer) }, + onAddToCalendar = { onCalendar(event.event) }, + onLongPress = onLongPress, + ) + Reactions(line, myUserId) + return@Column + } + // A sent bubble is the chrome gradient with a highlight along its // top; a received one is plain surface inside a rule. Flat fills // on both sides is what a generic messenger looks like, and it is @@ -2798,58 +2997,66 @@ private fun Bubble( } line.link?.let { LinkPreviewCard(it, mine = line.mine) } } - // Under the bubble, not inside it. - if (line.reactions.isNotEmpty() || line.privateReactions.isNotEmpty()) { - Row( - horizontalArrangement = Arrangement.spacedBy(Space.xs), - modifier = - Modifier - .padding(horizontal = Kit.bubblePadding, vertical = 2.dp) - .testTag("reactions"), - ) { - line.reactions.entries.sortedBy { it.key }.forEach { (emoji, who) -> - Text( - if (who.size == 1) emoji else "$emoji ${who.size}", - fontSize = 13.sp, - color = Palette.SecondaryText, - modifier = - Modifier - .clip(RoundedCornerShape(Radius.small)) - .background(MaterialTheme.colorScheme.surfaceVariant) - .padding(horizontal = 6.dp, vertical = 2.dp), - ) - } - // Labelled, because a reaction only two people can see - // should not be mistaken for one the group watched arrive. - line.privateReactions.entries.sortedBy { it.key }.forEach { (emoji, who) -> - Text( - run { - // The person who sent it is told who saw it; - // the author is told that nobody else did. - val side = - if (myUserId in who) { - stringResource(R.string.chat_reaction_only_them) - } else { - stringResource(R.string.chat_reaction_only_you) - } - if (who.size == 1) "$emoji $side" else "$emoji ${who.size} $side" - }, - fontSize = 13.sp, - color = Palette.SecondaryText, - modifier = - Modifier - .clip(RoundedCornerShape(Radius.small)) - .background(MaterialTheme.colorScheme.surfaceVariant) - .padding(horizontal = 6.dp, vertical = 2.dp) - .testTag("private-reactions"), - ) - } - } - } + Reactions(line, myUserId) } } } +/** The reactions under a bubble, a card or an event. Under it, not inside it. */ +@Composable +private fun Reactions( + line: Session.Line, + myUserId: Long, +) { + if (line.reactions.isEmpty() && line.privateReactions.isEmpty()) return + Row( + horizontalArrangement = Arrangement.spacedBy(Space.xs), + modifier = + Modifier + .padding(horizontal = Kit.bubblePadding, vertical = 2.dp) + .testTag("reactions"), + ) { + line.reactions.entries.sortedBy { it.key }.forEach { (emoji, who) -> + Text( + if (who.size == 1) emoji else "$emoji ${who.size}", + fontSize = 13.sp, + color = Palette.SecondaryText, + modifier = + Modifier + .clip(RoundedCornerShape(Radius.small)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + // Labelled, because a reaction only two people can see + // should not be mistaken for one the group watched arrive. + line.privateReactions.entries.sortedBy { it.key }.forEach { (emoji, who) -> + Text( + run { + // The person who sent it is told who saw it; + // the author is told that nobody else did. + val side = + if (myUserId in who) { + stringResource(R.string.chat_reaction_only_them) + } else { + stringResource(R.string.chat_reaction_only_you) + } + if (who.size == 1) "$emoji $side" else "$emoji ${who.size} $side" + }, + fontSize = 13.sp, + color = Palette.SecondaryText, + modifier = + Modifier + .clip(RoundedCornerShape(Radius.small)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 6.dp, vertical = 2.dp) + .testTag("private-reactions"), + ) + } + } +} + + /** * Section 23.7: nothing until the last thousand, then how much is left, then * the reason the send button stopped working. The same words as iOS. @@ -4645,7 +4852,11 @@ private fun ForwardSheet( Column(modifier = Modifier.testTag("forward-list")) { Text( // States the cost plainly. - stringResource(R.string.chat_forward_note), + when { + line.card != null -> stringResource(R.string.chat_forward_note_card) + line.event != null -> stringResource(R.string.chat_forward_note_event) + else -> stringResource(R.string.chat_forward_note) + }, fontSize = 12.sp, color = Palette.SecondaryText, ) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Directory.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Directory.kt new file mode 100644 index 0000000..e1c2ad9 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Directory.kt @@ -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, + val sites: List, +) { + 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, + ) { + /** 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> = + buildMap> { + 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> = + 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 get() = childrenOf[""].orEmpty() + + fun category(id: String): Category? = byId[id] + + fun children(id: String): List = childrenOf[id].orEmpty() + + /** The sites filed directly under [id], by title. */ + fun sitesIn(id: String): List = sitesOf[id].orEmpty() + + /** Sites under [id] and everything beneath it, each counted once. */ + fun count(id: String): Int { + val seen = HashSet() + 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 { + 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> { 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+") + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/DirectoryScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/DirectoryScreen.kt new file mode 100644 index 0000000..967cfd3 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/DirectoryScreen.kt @@ -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(null) } + // What happened to the send, said in the dialog the person is looking at. + var outcome by remember { mutableStateOf(null) } + var said by remember { mutableStateOf(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, + 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(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") + }, + ) +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/DirectoryShare.kt b/app/src/main/kotlin/io/flamenet/messenger/app/DirectoryShare.kt new file mode 100644 index 0000000..35dbd73 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/DirectoryShare.kt @@ -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) +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/EventText.kt b/app/src/main/kotlin/io/flamenet/messenger/app/EventText.kt new file mode 100644 index 0000000..9610a18 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/EventText.kt @@ -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) + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/EventUi.kt b/app/src/main/kotlin/io/flamenet/messenger/app/EventUi.kt new file mode 100644 index 0000000..9c5563a --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/EventUi.kt @@ -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: ". + 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 + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt index 02292e7..5fcf8f1 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt @@ -699,29 +699,35 @@ fun ColumnScope.AttachPanel( model: MessengerViewModel, onPhoto: () -> Unit, onFiles: () -> Unit, + /** A greeting card (§6.31). */ + onCard: () -> Unit, + /** An event the chat can answer (§6.32). */ + onEvent: () -> Unit, + /** A game (§6.33); null where none can be played, which is anywhere but a one-to-one chat. */ + onGame: (() -> Unit)? = null, ) { - Column( + // One row, because they sit together, scrolling sideways when the screen + // is narrow. Locked Files still opens its sheet, which says how to unlock it. + Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(Radius.small)) .background(Palette.SurfaceAlt) + .horizontalScroll(rememberScrollState()) .padding(Space.md) .testTag("attach-panel"), - verticalArrangement = Arrangement.spacedBy(Space.sm), + horizontalArrangement = Arrangement.spacedBy(Space.xs), ) { - KitSecondaryButton( - text = stringResource(R.string.main_files_photo), - onClick = onPhoto, - modifier = Modifier.fillMaxWidth(), - tag = "attach-photo", - ) + KitSecondaryButton(text = stringResource(R.string.main_files_photo), onClick = onPhoto, tag = "attach-photo") KitSecondaryButton( text = if (model.filesUnlocked()) stringResource(R.string.main_files_from_files) else stringResource(R.string.main_files_from_files_locked), onClick = onFiles, - modifier = Modifier.fillMaxWidth(), tag = "attach-files", ) + KitSecondaryButton(text = stringResource(R.string.main_files_card), onClick = onCard, tag = "attach-card") + KitSecondaryButton(text = stringResource(R.string.main_files_event), onClick = onEvent, tag = "attach-event") + onGame?.let { KitSecondaryButton(text = stringResource(R.string.main_files_game), onClick = it, tag = "attach-game") } } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/GameUi.kt b/app/src/main/kotlin/io/flamenet/messenger/app/GameUi.kt new file mode 100644 index 0000000..de4e9b4 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/GameUi.kt @@ -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") } }, + ) +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/HelloLink.kt b/app/src/main/kotlin/io/flamenet/messenger/app/HelloLink.kt new file mode 100644 index 0000000..ffbcb2e --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/HelloLink.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt index e907a01..a4d1031 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt @@ -28,6 +28,7 @@ import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Mic import androidx.compose.material.icons.outlined.MicOff import androidx.compose.material.icons.outlined.MoreHoriz +import androidx.compose.material.icons.outlined.Newspaper import androidx.compose.material.icons.outlined.NotificationsNone import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.material.icons.outlined.PersonOutline @@ -76,6 +77,9 @@ object KitIcon { val Files: ImageVector = Icons.Outlined.FolderOpen val Wallet: ImageVector = Icons.Outlined.AccountBalanceWallet + /** The Today tab: headlines first, so a newspaper. */ + val Today: ImageVector = Icons.Outlined.Newspaper + // Composer. val Attach: ImageVector = Icons.Outlined.AddCircleOutline val Camera: ImageVector = Icons.Outlined.PhotoCamera diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/LineActions.kt b/app/src/main/kotlin/io/flamenet/messenger/app/LineActions.kt new file mode 100644 index 0000000..dc1b5d1 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/LineActions.kt @@ -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() + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index f14da64..0529bb3 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -107,6 +107,10 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { val lock = AppLock(this) + // Only on a fresh start: an activity recreated for a rotation is handed + // the same launch intent again, and the link was already taken. + if (savedInstanceState == null) openHelloLinkIfAsked(intent) + setContent { MessengerTheme { // `android:windowSoftInputMode="adjustResize"` stopped doing @@ -196,10 +200,23 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { model.handleLinkIntent(text) } + /** + * A hello link (§6.34), `flamenetmessenger://hi#<address>`, opened from a + * browser or another app. Anything can send one of these, so all it does is + * fill in the add screen; the person decides whether to write. + */ + private fun openHelloLinkIfAsked(intent: Intent?) { + val link = helloLinkIn(intent) ?: return + // Taken once. `onStart` and a later `onNewIntent` see this intent again. + intent?.data = null + model.openHelloLink(link) + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) answerIfAsked(intent) + openHelloLinkIfAsked(intent) openLinkIfAsked(intent) } @@ -220,6 +237,17 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { } } +/** + * The link a VIEW intent for the app's own scheme carries, as text, or null + * for any other intent. Whether it holds an address is the view model's call. + */ +internal fun helloLinkIn(intent: Intent?): String? { + if (intent?.action != Intent.ACTION_VIEW) return null + val data = intent.data ?: return null + if (!HelloLink.isAppScheme(data.scheme)) return null + return data.toString() +} + @Composable private fun Root(model: MessengerViewModel = viewModel()) { val phase by model.phase.collectAsStateWithLifecycle() @@ -320,6 +348,7 @@ private fun Root(model: MessengerViewModel = viewModel()) { groups = current.groups, heldMessages = current.heldMessages, unsealedSendAt = current.unsealedSendAt, + myName = current.handle, ) } else { // The content area is weighted, and the tab bar is not. @@ -342,6 +371,16 @@ private fun Root(model: MessengerViewModel = viewModel()) { MessagesScreen(model, current) } + // Hidden with the tab under supervision + // (DESIGN 31), wherever the screen was left. + MessengerViewModel.Phase.Screen.TODAY -> { + if (ParentalControls.todayHidden(androidx.compose.ui.platform.LocalContext.current)) { + SignedIn(model, current) + } else { + TodayScreen(model, current) + } + } + MessengerViewModel.Phase.Screen.CALLS -> { CallsScreen(model, current) } @@ -678,10 +717,15 @@ object NavDestinations { /** * The bar, in order. */ - fun tabs(storiesHidden: Boolean): List<Tab> = + fun tabs( + storiesHidden: Boolean, + todayHidden: Boolean = false, + ): List<Tab> = listOfNotNull( Tab(Screen.CONTACTS, "Home", "nav-contacts", R.string.main_home), Tab(Screen.MESSAGES, "Messages", "nav-messages", R.string.main_messages), + // Beside Messages: the public Today file and the directory (§8.12). + Tab(Screen.TODAY, "Today", "nav-today", R.string.main_today).takeUnless { todayHidden }, Tab(Screen.CALLS, "Calls", "nav-calls", R.string.main_calls), Tab(Screen.STORIES, "Stories", "nav-stories", R.string.main_stories).takeUnless { storiesHidden }, Tab(Screen.FILES, "Files", "nav-files", R.string.main_files), @@ -726,9 +770,13 @@ internal fun BottomNav( horizontalArrangement = Arrangement.SpaceEvenly, ) { // Supervision removes the Stories surface rather than emptying it. - val storiesHidden = - ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current) - for (tab in NavDestinations.tabs(storiesHidden)) { + val context = androidx.compose.ui.platform.LocalContext.current + val tabs = + NavDestinations.tabs( + storiesHidden = ParentalControls.storiesHidden(context), + todayHidden = ParentalControls.todayHidden(context), + ) + for (tab in tabs) { val badge = when (tab.screen) { // The inbox badge lives on Messages. @@ -746,13 +794,24 @@ internal fun BottomNav( when (tab.screen) { Screen.CONTACTS -> KitIcon.Home to KitIcon.Home Screen.MESSAGES -> KitIcon.Chats to KitIcon.ChatsActive + Screen.TODAY -> KitIcon.Today to KitIcon.Today Screen.CALLS -> KitIcon.Call to KitIcon.Call Screen.STORIES -> KitIcon.Stories to KitIcon.Stories Screen.FILES -> KitIcon.Files to KitIcon.Files Screen.WALLET -> KitIcon.Wallet to KitIcon.Wallet else -> KitIcon.Account to KitIcon.Account } - NavButton(model, phase, tab.screen, icon, active, stringResource(tab.labelRes), badge, tab.tag) + NavButton( + model, + phase, + tab.screen, + icon, + active, + stringResource(tab.labelRes), + badge, + tab.tag, + compact = tabs.size > 7, + ) } } } @@ -772,6 +831,11 @@ private fun RowScope.NavButton( label: String, badge: Int, tag: String, + /** + * Eight tabs leave about 45dp each on a 360dp phone, so the labels go one + * size smaller and stay on one line. + */ + compact: Boolean = false, ) { val selected = phase.screen == screen val face = if (selected) Color.White else Color(0xFFB4D2EE) @@ -815,6 +879,9 @@ private fun RowScope.NavButton( style = MaterialTheme.typography.labelSmall, color = face, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + fontSize = if (compact) 10.sp else MaterialTheme.typography.labelSmall.fontSize, + maxLines = 1, + softWrap = false, ) } if (badge > 0) { @@ -862,6 +929,17 @@ internal fun ColumnScope.SignedIn( scanning = true } + // A hello link that was opened (§6.34) lands in the add screen and stops + // there. Nothing is looked up or sent until the person chooses to. + val hello by model.helloAddress.collectAsStateWithLifecycle() + LaunchedEffect(hello) { + val address = hello ?: return@LaunchedEffect + openProfile = null + scannedPrefill = address + addingContact = true + model.helloAddressTaken() + } + // The contact screen REPLACES the list rather than sitting under it, the // same way an open chat does. val profile = openProfile?.let { id -> phase.buddies.firstOrNull { it.userId == id } } @@ -905,9 +983,12 @@ internal fun ColumnScope.SignedIn( ScanAddressDialog( onFound = { raw -> scanning = false - parseScannedAddress(raw)?.let { card -> - // Bind by the fnid, never the handle (audit LOW). - scannedPrefill = "${'$'}{card.fnid}@${'$'}{card.relay}" + // A hello link's code, an older card, or a bare address. + val prefill = prefillFor(raw) + if (prefill == null) { + model.unreadableCode() + } else { + scannedPrefill = prefill addingContact = true } }, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt index 78a0cbc..3677356 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt @@ -103,15 +103,13 @@ fun ColumnScope.MessagesScreen( model.openContactLink(raw) return@ScanAddressDialog } - parseScannedAddress(raw)?.let { card -> - scannedPrefill = - if (card.handle != null && - card.relay.equals(phase.relay, ignoreCase = true) - ) { - card.handle - } else { - "${card.fnid}@${card.relay}" - } + // A hello link's code, an older card, or a bare address, + // bound by the fnid as Home's scanner is. + val prefill = prefillFor(raw) + if (prefill == null) { + model.unreadableCode() + } else { + scannedPrefill = prefill addingContact = true } }, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt index 94c1481..eeb78fe 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt @@ -213,7 +213,10 @@ class MessengerService : Service() { relay = relay, api = MessengerApi(credential.relayBaseUrl, credential.username, credential.appPassword), - ) + ).also { s -> + s.onWiped = { TodayData.forget(applicationContext) } + s.attachmentCacheDir = cacheDir + } }.getOrNull() private suspend fun run() { @@ -316,10 +319,24 @@ class MessengerService : Service() { "call=$onACall touched=${touched.size} " + (outcome.exceptionOrNull()?.let { "failed=${it::class.simpleName}" } ?: "ok"), ) - // Requests and copies from this account's own devices are quiet - // (spec 14.5, 17.2): they count as unread, never as an alert. - val loud = touched - s.quietThreads - if (loud.isNotEmpty()) notifyMessages(loud.size) + // A card, an event or an invitation that came alone gets its own + // words. A game's moves, offers and endings are said too, but they + // never count as unread, so they are not among `touched` (DESIGN 29). + val notice = s.takeNotice() + val gameNews = s.takeGameNews() + // A snoozed chat holds all of it, an ordinary message as well as + // a game's news (owner, 0.32). + val snoozed = runCatching { s.snoozedThreads() }.getOrDefault(emptySet()) + // Requests and copies from this account's own devices are quiet + // (spec 14.5, 17.2): they count as unread, never as an alert, so + // they come out before anything is raised. + val loud = touched - s.quietThreads + NotificationSettings.due(loud, notice, gameNews, snoozed)?.let { due -> + notifyMessages(due.threads, notice = due.notice, s = s) + } + // Answers only redraw a card, and nothing here is drawing one + // (§6.32: answers do not notify). Taken so they do not pile up. + s.takeQuietlyChanged() // Reply reminders fire from THIS loop, after `receive()`, and the // order matters: an inbound message clears the reminder inside // `Session.record`, so a reply that has just landed cancels its own @@ -362,6 +379,8 @@ class MessengerService : Service() { private fun notifyMessages( threads: Int, from: String? = null, + notice: Session.Notice? = null, + s: Session? = null, ) { val settings = NotificationSettings(this) // The master switch and the per-kind one, both checked before a @@ -376,14 +395,27 @@ class MessengerService : Service() { PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) // Still no sender and no preview unless it was asked for. - val text = settings.messageText(threads, from) + val text = + if (notice != null && threads == 1 && s != null) { + // Never the words of a card: who sent it and, when it is + // sealed, the day it opens. Only looked up when it will be shown. + val name = if (settings.showPreviews) runCatching { nameFor(notice.from, s) }.getOrNull() else null + val opensOn = (notice as? Session.Notice.Card)?.opensAtMillis?.let { CardArt.day(it) } + settings.noticeText(notice, name, opensOn) + } else { + settings.messageText(threads, from) + } + // Their move and a draw offer come without a sound; an invitation, + // an ending and everything else with one. + val quiet = threads == 1 && NotificationSettings.isQuiet(notice) notify( CHANNEL_MESSAGES, 2, text, - NotificationCompat.PRIORITY_HIGH, + if (quiet) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_HIGH, open, number = threads, + silent = quiet, ) } @@ -499,6 +531,8 @@ class MessengerService : Service() { open: PendingIntent, category: String? = null, number: Int = 0, + /** Shown without a sound or a buzz, as a game's next move is (§6.33). */ + silent: Boolean = false, ) { val n = NotificationCompat @@ -507,6 +541,7 @@ class MessengerService : Service() { .setContentText(text) .setSmallIcon(android.R.drawable.stat_notify_chat) .setPriority(priority) + .setSilent(silent) .setAutoCancel(true) .setContentIntent(open) .also { b -> category?.let { b.setCategory(it) } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 101952d..7d4b3bf 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -238,9 +238,10 @@ class MessengerViewModel( /** * STORIES is new, and it is a tab rather than a strip because a feed - * needs a screen. + * needs a screen. TODAY is the public Today file and the directory + * behind it (SPEC §8.12). */ - enum class Screen { CONTACTS, MESSAGES, CALLS, STORIES, NOTIFICATIONS, ACCOUNT, WALLET, FILES, SEARCH } + enum class Screen { CONTACTS, MESSAGES, TODAY, CALLS, STORIES, NOTIFICATIONS, ACCOUNT, WALLET, FILES, SEARCH } /** What the call UI needs to draw itself. */ data class CallState( @@ -412,9 +413,19 @@ class MessengerViewModel( api = MessengerApi(credential.relayBaseUrl, credential.username, credential.appPassword), ) - session?.let { wireSealingPolicy(it); retireLeftoverDevice(it) } - val fetchedBuddies = runCatching { session!!.roster() } - val fetchedGroups = runCatching { session!!.namedGroups() } + session?.let { + wireSealingPolicy(it) + retireLeftoverDevice(it) + forgetTodayOnWipe(it) + // So a story that comes down takes its decrypted bytes with it. + it.attachmentCacheDir = getApplication<Application>().cacheDir + } + // DESIGN 47: from what is on this phone, so a relay + // out of reach never keeps the chats waiting; the + // network catches up after (catchUpAfterStart). + val storedBuddies = session!!.storedRoster() + val storedGroups = session!!.storedGroups() + val ids = storedBuddies.orEmpty().map { b -> b.userId } stored to Phase.SignedIn( handle = credential.username, @@ -425,26 +436,20 @@ class MessengerViewModel( // passphrase to derive it from. fnid = credential.fnid, status = str(R.string.core_signed_in_on_this_device), - buddies = fetchedBuddies.getOrDefault(emptyList()), - buddiesLoaded = fetchedBuddies.isSuccess, + buddies = storedBuddies.orEmpty(), + buddiesLoaded = storedBuddies != null, // From the vault, or every badge is gone at // each launch whether or not the messages // were read. The markers were UI state // alone until now. unread = session?.vault?.unreadThreads() ?: emptySet(), - avatars = - session?.avatars( - fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }, - ) ?: emptyMap(), - previews = - session?.previews( - fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }, - ) ?: emptyMap(), + avatars = session?.avatars(ids) ?: emptyMap(), + previews = session?.previews(ids) ?: emptyMap(), calls = session?.callLog() ?: emptyList(), storyAudience = session?.storyAudience() ?: Session.StoryAudience(), myAvatar = session?.myAvatar(), - groups = fetchedGroups.getOrDefault(emptyList()), - groupsLoaded = fetchedGroups.isSuccess, + groups = storedGroups.orEmpty(), + groupsLoaded = storedGroups != null, ) } } @@ -455,6 +460,7 @@ class MessengerViewModel( _phase.value = restored.second ?: Phase.SignedOut() if (restored.second != null) { startPolling() + catchUpAfterStart() republishIfMissingFromTheLog() } }, @@ -465,6 +471,24 @@ class MessengerViewModel( ) } + /** + * The network's half of a start from what is on the phone (DESIGN 47): + * the contact list and the groups as the relay has them now, whenever it + * answers. A relay still out of reach leaves the stored ones showing. + */ + private fun catchUpAfterStart() = + viewModelScope.launch { + val current = session ?: return@launch + val buddies = withContext(Dispatchers.IO) { runCatching { current.roster() } } + val groups = withContext(Dispatchers.IO) { runCatching { current.namedGroups() } } + buddies.getOrNull()?.let { list -> + val ids = list.map { it.userId } + val (avatars, previews) = withContext(Dispatchers.IO) { current.avatars(ids) to current.previews(ids) } + update { it.copy(buddies = list, buddiesLoaded = true, avatars = it.avatars + avatars, previews = it.previews + previews) } + } + groups.getOrNull()?.let { list -> update { it.copy(groups = list, groupsLoaded = true) } } + } + /** * Put this device back in the transparency log if it is not in it * (android#6). @@ -696,7 +720,13 @@ class MessengerViewModel( relay = relay, api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential), ) - session?.let { wireSealingPolicy(it); retireLeftoverDevice(it) } + session?.let { + wireSealingPolicy(it) + retireLeftoverDevice(it) + forgetTodayOnWipe(it) + // So a story that comes down takes its decrypted bytes with it. + it.attachmentCacheDir = getApplication<Application>().cacheDir + } // Our return address, before the first federated send. this.accountKeySeed = accountKeySeed @@ -2094,6 +2124,85 @@ class MessengerViewModel( } } + // -- Today, the directory and hello links (§8.12, §6.12, §6.34) ----------. + + /** + * The home relay with its scheme and port, which is where the Today file + * is read from. Null when signed out. + */ + fun homeRelayBaseUrl(): String? = session?.relay?.baseUrl + + /** + * Send a directory site to one person: the address as the words, and a + * preview made from the entry unless link previews are off, through the + * same path a typed message takes. Nothing is fetched. [done] gets the + * sentence to show. + */ + fun shareSite( + site: Directory.Site, + peerUserId: Long, + peerName: String, + done: (String) -> Unit, + ) = viewModelScope.launch { + val current = session ?: return@launch done("You're signed out, so nothing was sent.") + val withPreview = mediaSettings.linkPreviews + val result = + withContext(Dispatchers.IO) { + runCatching { DirectoryShare.send(current, peerUserId, site, withPreview) } + } + result + .onSuccess { sent -> + done( + when { + // Session.send keeps it in the outbox in this case. + !sent.reachedSomeone -> "Waiting to send to $peerName. It goes when one of their devices is back." + else -> describeDelivery(sent) ?: "Sent ${site.title} to $peerName." + }, + ) + }.onFailure { failure -> done(describe(failure)) } + } + + private val _helloAddress = MutableStateFlow<String?>(null) + + /** + * An address a hello link brought in, waiting for Home's add screen. In + * memory only, and taken as soon as Home shows it. + */ + val helloAddress: StateFlow<String?> = _helloAddress.asStateFlow() + + /** + * A hello link was opened. It fills in Home's add screen with the address + * it names and does nothing more: nothing is looked up or sent, and nobody + * is added, until the person chooses to. Signed out, the address waits for + * sign-in. + */ + fun openHelloLink(link: String) { + val address = HelloLink.fromLink(link) + if (address == null) { + tell("That link doesn't hold a Flamenet address, so there is nobody to add.") + return + } + _helloAddress.value = address + update { it.copy(screen = Phase.Screen.CONTACTS, openChat = null, error = null) } + } + + /** Home has put the address into its add screen. */ + fun helloAddressTaken() { + _helloAddress.value = null + } + + /** A scanned code that holds no address in any form. */ + fun unreadableCode() = tell("That code doesn't hold a Flamenet address.") + + /** A sentence for whoever is looking, signed in or not. */ + private fun tell(message: String) { + when (val now = _phase.value) { + is Phase.SignedIn -> update { it.copy(error = message) } + is Phase.SignedOut -> _phase.value = now.copy(error = message) + else -> Unit + } + } + // -- Contacts: the rest of the CRUD --------------------------------------. fun removeBuddy(userId: Long) = @@ -3730,6 +3839,9 @@ class MessengerViewModel( /** A story pick that cannot be staged, said in the same place other errors are. */ fun reportStoryProblem(message: String) = update { it.copy(error = message) } + /** Say something on the screen that is not about any one send. */ + fun say(message: String) = update { it.copy(error = message) } + fun discardStagedStory() = update { it.copy(storyDraft = null) } /** The deliberate Post from the composer sheet. */ @@ -3956,6 +4068,12 @@ class MessengerViewModel( } } + /** A wipe clears the Today tab's file and city picks with the vault (DESIGN 32). */ + private fun forgetTodayOnWipe(session: Session) { + val app = getApplication<Application>() + session.onWiped = { TodayData.forget(app) } + } + private fun wireSealingPolicy(session: Session) { val settings = MediaSettings(getApplication()) session.conversations.requireSealed = { settings.requireSealedSending } @@ -4237,6 +4355,56 @@ class MessengerViewModel( fun closeStory() = update { it.copy(openStory = null) } + /** + * The seam for drawing a screen from a vault in a test, with no relay to + * sign in to: pictures already decrypted into the cache load as they do + * for a signed-in person. + */ + internal fun useSessionForTesting(s: Session) { + session = s + } + + /** + * Take one of my stories down for everyone it went to (§6.35). It leaves + * this phone at once; what the sends reached is said, not hidden. + */ + fun takeDownStory(story: Vault.Story) = + viewModelScope.launch { + val current = session ?: return@launch + val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty() + update { it.copy(busy = true, error = null) } + val done = withContext(Dispatchers.IO) { runCatching { current.takeDownStory(story, contacts) } } + val live = withContext(Dispatchers.IO) { runCatching { current.stories() } }.getOrNull() + update { state -> + state.copy( + busy = false, + openStory = state.openStory?.takeIf { it.id != story.id }, + stories = live ?: state.stories, + error = done.fold(onSuccess = StoryWords::takenDown, onFailure = ::describe), + ) + } + } + + /** New words for one of my stories (§6.15 for stories). Never empty: that is a take-down. */ + fun editStoryCaption( + story: Vault.Story, + caption: String, + ) = viewModelScope.launch { + val current = session ?: return@launch + if (caption.isBlank()) return@launch + val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty() + update { it.copy(busy = true, error = null) } + val done = withContext(Dispatchers.IO) { runCatching { current.editStoryCaption(story, caption, contacts) } } + val live = withContext(Dispatchers.IO) { runCatching { current.stories() } }.getOrNull() + update { state -> + state.copy( + busy = false, + stories = live ?: state.stories, + error = done.fold(onSuccess = StoryWords::edited, onFailure = ::describe), + ) + } + } + // -- Voice notes (§6.6) --------------------------------------------------. private var recording: VoiceNote.Recording? = null @@ -4374,6 +4542,7 @@ class MessengerViewModel( */ fun sendSticker( chat: Phase.Chat, + pack: String, stickerId: String, ) = viewModelScope.launch { val current = session ?: return@launch @@ -4382,11 +4551,11 @@ class MessengerViewModel( runCatching { val group = chat.group if (group != null) { - current.sendGroupSticker(group, StickerPack.CORE, stickerId) + current.sendGroupSticker(group, pack, stickerId) } else { current.sendSticker( chat.peerId ?: throw OnlyTextToAnotherRelay(), - StickerPack.CORE, + pack, stickerId, ) } @@ -4403,6 +4572,197 @@ class MessengerViewModel( }.onFailure { failure -> update { it.copy(error = describe(failure)) } } } + // -- Cards and events (§6.31 and §6.32) ----------------------------------. + + /** Redraw [threadId] if it is the conversation on screen, and only then. */ + private fun redraw( + current: Session, + threadId: String, + error: String? = null, + ) = update { state -> + state.copy( + openChat = + state.openChat?.let { open -> + if (open.threadId == threadId) open.copy(lines = current.history(threadId)) else open + }, + error = error, + ) + } + + /** Send a card into whatever this conversation is. */ + fun sendCard( + chat: Phase.Chat, + card: Content.Card, + ) = viewModelScope.launch { + val current = session ?: return@launch + val done = + withContext(Dispatchers.IO) { + runCatching { + val group = chat.group + if (group != null) { + current.sendGroupCard(group, card) + } else { + current.sendCard(chat.peerId ?: throw OnlyTextToAnotherRelay(), card) + } + } + } + done + .onSuccess { sent -> redraw(current, chat.threadId, sent.let(::describeDelivery)) } + .onFailure { failure -> update { it.copy(error = describe(failure)) } } + } + + /** Put a new event, organized by this account, into this conversation. */ + fun sendEvent( + chat: Phase.Chat, + event: Content.Event, + ) = viewModelScope.launch { + val current = session ?: return@launch + val done = + withContext(Dispatchers.IO) { + runCatching { + if (chat.group == null && chat.peerId == null) throw OnlyTextToAnotherRelay() + current.sendEvent(chat.peerId, chat.group, event) + } + } + done + .onSuccess { sent -> redraw(current, chat.threadId, sent.let(::describeDelivery)) } + .onFailure { failure -> update { it.copy(error = describe(failure)) } } + } + + /** Change an event this account organized. Everyone in the chat gets the new version. */ + fun reviseEvent( + chat: Phase.Chat, + eventId: String, + edited: Content.Event, + ) = changeEvent(chat, eventId, "The change reached nobody, so the event is as it was.") { edited } + + /** Cancel an event this account organized. */ + fun cancelEvent( + chat: Phase.Chat, + eventId: String, + ) = changeEvent(chat, eventId, "The cancel reached nobody, so the event still stands.") { + it.copy(cancelled = true) + } + + private fun changeEvent( + chat: Phase.Chat, + eventId: String, + failed: String, + change: (Content.Event) -> Content.Event, + ) = viewModelScope.launch { + val current = session ?: return@launch + val ok = + withContext(Dispatchers.IO) { + runCatching { current.reviseEvent(chat.threadId, eventId, chat.peerId, chat.group, change) } + .getOrDefault(false) + } + redraw(current, chat.threadId, if (ok) null else failed) + } + + /** Answer an event: yes, maybe or no. */ + fun answerEvent( + chat: Phase.Chat, + eventId: String, + answer: String, + ) = viewModelScope.launch { + val current = session ?: return@launch + val ok = + withContext(Dispatchers.IO) { + runCatching { current.answerEvent(chat.threadId, eventId, answer, chat.peerId, chat.group) } + .getOrDefault(false) + } + redraw(current, chat.threadId, if (ok) null else "Your answer reached nobody. Nothing changed; try again.") + } + + /** + * Forward a card or an event (§6.31, §6.32). The screen offers + * neither for a card still sealed here or a cancelled event. + */ + fun forwardCardOrEvent( + line: Session.Line, + toPeerUserId: Long?, + toGroup: RelayClient.Group?, + ) = viewModelScope.launch { + val current = session ?: return@launch + val ok = + withContext(Dispatchers.IO) { + runCatching { + val card = line.card + val event = line.event + when { + card != null -> current.forwardCard(card, toPeerUserId, toGroup) + event != null -> current.forwardEvent(event.event, toPeerUserId, toGroup) + else -> false + } + }.getOrDefault(false) + } + // The forward lands in another thread; this one is redrawn in case it + // was forwarded to itself. + update { state -> + state.copy( + openChat = state.openChat?.let { chat -> chat.copy(lines = current.history(chat.threadId)) }, + error = if (ok) null else "That could not be forwarded. Nothing was sent.", + ) + } + } + + // -- Games (§6.33) ---------------------------------------------------------. + + /** Start a game in a one-to-one chat. The screen offers it nowhere else. */ + fun startGame( + chat: Phase.Chat, + type: io.flamenet.games.GameType, + iGoFirst: Boolean, + ) = viewModelScope.launch { + val current = session ?: return@launch + val done = + withContext(Dispatchers.IO) { + runCatching { + if (chat.group != null) throw IllegalStateException("Games are for one-to-one chats.") + current.startGame(chat.peerId ?: throw OnlyTextToAnotherRelay(), type, iGoFirst) + } + } + done + .onSuccess { sent -> redraw(current, chat.threadId, describeDelivery(sent)) } + .onFailure { failure -> update { it.copy(error = describe(failure)) } } + } + + /** "Start a new game" from a game whose boards disagree: same game, same sides. */ + fun startGameLike( + chat: Phase.Chat, + state: ChatGames.State, + ) = startGame(chat, state.type, iGoFirst = state.me == io.flamenet.games.Side.FIRST) + + /** Play one move. Taken back, and said so, when it reached nobody. */ + fun playGameMove( + chat: Phase.Chat, + gameId: String, + move: String, + ) = viewModelScope.launch { + val current = session ?: return@launch + val peer = chat.peerId ?: return@launch + val ok = + withContext(Dispatchers.IO) { + runCatching { current.playGameMove(chat.threadId, gameId, move, peer) }.getOrDefault(false) + } + redraw(current, chat.threadId, if (ok) null else "That move did not reach them, so it was not played. Try again.") + } + + /** Resign, offer or answer a draw, or decline or cancel before play starts. */ + fun endGame( + chat: Phase.Chat, + gameId: String, + ending: String, + ) = viewModelScope.launch { + val current = session ?: return@launch + val peer = chat.peerId ?: return@launch + val ok = + withContext(Dispatchers.IO) { + runCatching { current.endGame(chat.threadId, gameId, ending, peer) }.getOrDefault(false) + } + redraw(current, chat.threadId, if (ok) null else "That did not reach them, so nothing changed. Try again.") + } + /** * How the open conversation is named in the vault, the same ids the * transcript uses, so a group, a local contact and somebody on another @@ -4697,6 +5057,28 @@ class MessengerViewModel( return@launch } val threads = received.getOrDefault(emptySet()) + // §6.32. An answer redraws its event's card and is + // not news: the open chat catches up, nothing is + // marked unread. + val quiet = current.takeQuietlyChanged() + if (quiet.isNotEmpty()) { + update { state -> + state.copy( + openChat = + state.openChat?.let { + if (it.threadId in quiet) { + // A pin is quiet too (§6.19), and moves the banner. + it.copy( + lines = current.history(it.threadId), + pinnedMessageId = current.pinnedMessage(it.threadId), + ) + } else { + it + } + }, + ) + } + } // What ingest set aside this cycle, so the banner can // say what is waiting rather than only that keys // changed. Cheap: a sealed read of one small file, and @@ -4736,7 +5118,11 @@ class MessengerViewModel( // leaving the loop, the next poll may well succeed. polled.getOrNull()?.let { live -> update { state -> - if (state.stories.map { it.id } == live.map { it.id }) { + // A new or departed id, or new words: an edited + // caption keeps its id (§6.15). Not the whole + // story, whose picture key compares by identity. + fun shown(list: List<Vault.Story>) = list.map { Triple(it.id, it.caption, it.editedAt) } + if (shown(state.stories) == shown(live)) { state } else { state.copy(stories = live) @@ -4886,7 +5272,10 @@ class MessengerViewModel( polling?.cancel() polling = null session = null - withContext(Dispatchers.IO) { runCatching { KeystoreVault.destroy(getApplication()) } } + withContext(Dispatchers.IO) { + runCatching { KeystoreVault.destroy(getApplication()) } + runCatching { TodayData.forget(getApplication()) } + } identity = null vault = KeystoreVault.open(getApplication()) _phase.value = @@ -4907,7 +5296,10 @@ class MessengerViewModel( polling?.cancel() polling = null session = null - withContext(Dispatchers.IO) { runCatching { KeystoreVault.destroy(getApplication()) } } + withContext(Dispatchers.IO) { + runCatching { KeystoreVault.destroy(getApplication()) } + runCatching { TodayData.forget(getApplication()) } + } identity = null vault = KeystoreVault.open(getApplication()) _phase.value = @@ -4941,6 +5333,9 @@ class MessengerViewModel( } else { identity = null } + // Signing out, erasing or not, takes the Today tab's file and city + // picks with it (DESIGN 32). + withContext(Dispatchers.IO) { runCatching { TodayData.forget(getApplication()) } } _phase.value = Phase.SignedOut() } @@ -5248,7 +5643,7 @@ private fun describeFailureFromTheServer( internal class OnlyTextToAnotherRelay : Exception( "Only text can be sent to somebody on another server for now. " + - "Pictures, files and stickers cannot go there yet.", + "Pictures, files, stickers, cards, events and games cannot go there yet.", ) /** diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/NotificationSettings.kt b/app/src/main/kotlin/io/flamenet/messenger/app/NotificationSettings.kt index e789b28..ae2b4ba 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/NotificationSettings.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/NotificationSettings.kt @@ -1,6 +1,7 @@ package io.flamenet.messenger.app import android.content.Context +import io.flamenet.e2e.Vault /** * What a notification is allowed to say, and whether to post one at all. @@ -51,6 +52,13 @@ class NotificationSettings( else -> res.getString(io.flamenet.messenger.R.string.core_notif_new_messages_in_chats, threads) } + /** The text for a card or an event that arrived alone. */ + fun noticeText( + notice: Session.Notice, + from: String?, + opensOn: String?, + ): String = noticeText(showPreviews, notice, from, opensOn) + /** The same question for a reply reminder. */ fun replyReminderText( count: Int, @@ -63,6 +71,12 @@ class NotificationSettings( else -> res.getString(io.flamenet.messenger.R.string.core_notif_no_reply_in_chats, count) } + /** One poll's notification: how many chats it covers, and the notice that words it. */ + data class Due( + val threads: Int, + val notice: Session.Notice?, + ) + companion object { /** * Pure, and takes the preference as an argument, so a test can call @@ -79,6 +93,82 @@ class NotificationSettings( else -> "New messages in $threads chats" } + /** + * The line for a card, an event or a game's news that was the only + * thing to arrive (§6.31 to §6.33). Never the words of a card, sealed + * or not: a notification is read on a lock screen. With previews off, + * or nobody to name, it is the generic line any message gets. + * + * @param opensOn the day a sealed card opens, as the reader's calendar + * writes it, or null when it is not sealed. + */ + fun noticeText( + showPreviews: Boolean, + notice: Session.Notice, + from: String?, + opensOn: String?, + ): String { + val generic = messageText(showPreviews = false, threads = 1, from = null) + if (!showPreviews) return generic + val name = from?.takeIf { it.isNotBlank() } + return when (notice) { + is Session.Notice.Card -> + when { + name == null -> generic + opensOn != null -> "$name sent you a card to open on $opensOn" + else -> "$name sent you a card" + } + + is Session.Notice.Invite -> if (name == null) generic else "$name invited you: ${notice.title}" + + is Session.Notice.Changed -> + if (notice.cancelled) "${notice.title} was cancelled" else "${notice.title} changed" + + // §6.33: "Sam invited you to play chess" (DESIGN 29), "Your + // move: chess with Sam", and how a game ended. + is Session.Notice.GameInvite -> + if (name == null) generic else "$name invited you to play ${ChatGames.lowerName(notice.type)}" + + is Session.Notice.GameTurn -> + "Your move: ${ChatGames.lowerName(notice.type)}" + (name?.let { " with $it" } ?: "") + + is Session.Notice.GameOver -> + ChatGames.name(notice.type) + (name?.let { " with $it" } ?: "") + ": " + notice.result + + is Session.Notice.GameDrawOffer -> if (name == null) generic else "$name offers a draw: ${ChatGames.name(notice.type)}" + } + } + + /** + * Said without a sound: their move and a draw offer (§6.33). An + * invitation and an ending come with one (DESIGN 29), like a message. + */ + fun isQuiet(notice: Session.Notice?): Boolean = + notice is Session.Notice.GameTurn || notice is Session.Notice.GameDrawOffer + + /** + * What one poll's notification is about, or null for none. A snoozed + * chat holds every notification until the snooze ends, an ordinary + * message's as well as a game's, and what came still counts as + * unread. Game news that is not unread (DESIGN 29) is said only when + * no chat has anything else to say. + * + * @param touched the chats the poll made unread. + * @param snoozed the chats snoozed now, by thread id. + */ + fun due( + touched: Set<String>, + notice: Session.Notice?, + gameNews: Session.Notice?, + snoozed: Set<String>, + ): Due? { + val heard = touched - snoozed + if (heard.isNotEmpty()) return Due(heard.size, notice.takeIf { heard == touched }) + // Games are one-to-one (§6.33), so the sender names the chat. + val news = gameNews?.takeIf { Vault.threadForUser(it.from) !in snoozed } ?: return null + return Due(1, news) + } + fun replyReminderText( showPreviews: Boolean, count: Int, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControls.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControls.kt index c6a06ba..1161c93 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControls.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControls.kt @@ -46,6 +46,13 @@ object ParentalControls { fun callsDisabled(context: Context) = isEnabled(context) && prefs(context).getBoolean(CALLS_DISABLED, false) + /** + * The Today tab and the small-web directory behind it: hidden whenever + * supervision is on (DESIGN 31). Headlines and links to the open web are + * not a gate a parent releases one by one. + */ + fun todayHidden(context: Context) = isEnabled(context) + fun setGate( context: Context, key: String, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Pickers.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Pickers.kt index 29499a4..0b28e4a 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Pickers.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Pickers.kt @@ -11,7 +11,9 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.rememberScrollState @@ -37,15 +39,15 @@ import androidx.compose.ui.unit.sp import io.flamenet.messenger.R /** - * Stickers, from the shipped pack. + * Stickers, one section per shipped pack, in manifest order. */ @Composable fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) { val context = LocalContext.current - val stickers = StickerPack.all(context) + val packs = StickerPack.packs(context).filter { it.stickers.isNotEmpty() } - if (stickers.isEmpty()) { - // The manifest failed to load. + if (packs.isEmpty()) { + // No manifest would load. Text( stringResource(R.string.main_stickers_failed), fontSize = 14.sp, @@ -60,7 +62,7 @@ fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) { modifier = Modifier .fillMaxWidth() - .heightIn(max = 220.dp) + .heightIn(max = 260.dp) .clip(RoundedCornerShape(Radius.large)) .background(Palette.SurfaceAlt) .testTag("sticker-picker"), @@ -68,23 +70,66 @@ fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) { androidx.compose.foundation.layout .PaddingValues(Space.sm), ) { - items(stickers, key = { it.id }) { sticker -> - Box( - modifier = - Modifier - .padding(Space.xs) - .clip(RoundedCornerShape(Radius.small)) - .clickable { onPick(sticker) } - .semantics { contentDescription = sticker.name } - .testTag("sticker-pick-${sticker.id}"), - contentAlignment = Alignment.Center, - ) { - Text(sticker.glyph, fontSize = 34.sp, modifier = Modifier.padding(Space.sm)) + packs.forEach { pack -> + item(key = "pack-${pack.id}", span = { GridItemSpan(maxLineSpan) }) { + Text( + pack.name, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + color = Palette.SecondaryText, + modifier = + Modifier + .padding(horizontal = Space.xs, vertical = Space.xs) + .testTag("sticker-pack-${pack.id}"), + ) + } + items(pack.stickers, key = { "${it.pack}/${it.id}" }) { sticker -> + Box( + modifier = + Modifier + .padding(Space.xs) + .clip(RoundedCornerShape(Radius.small)) + .clickable { onPick(sticker) } + .semantics { contentDescription = sticker.name } + .testTag("sticker-pick-${sticker.id}"), + contentAlignment = Alignment.Center, + ) { + StickerArt(StickerPack.Resolved(sticker.image, sticker.glyph, sticker.name), size = 48.dp) + } } } } } +/** + * One sticker as drawn: its picture when the pack ships one and it loads, + * otherwise its glyph. Described by the caller, so this adds no label. + */ +@Composable +fun StickerArt( + sticker: StickerPack.Resolved, + size: androidx.compose.ui.unit.Dp, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val picture = remember(sticker.image) { sticker.image?.let { StickerPack.image(context, it) } } + if (picture != null) { + androidx.compose.foundation.Image( + bitmap = picture, + contentDescription = null, + modifier = modifier.size(size), + ) + } else { + // Glyphs are set in sp at about two thirds of the box, which is how + // the old 64sp sticker sat in its space. + Text( + sticker.glyph, + fontSize = (size.value * 0.68f).sp, + modifier = modifier.padding(Space.xs), + ) + } +} + /** * Emoji, straight into the draft. */ diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/PollUi.kt b/app/src/main/kotlin/io/flamenet/messenger/app/PollUi.kt index a9b487b..3558d9d 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/PollUi.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/PollUi.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -67,7 +68,7 @@ fun ColumnScope.PollCard( if (poll.members.size > 2) stringResource(R.string.social_poll_anonymous) else stringResource(R.string.social_poll), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, - color = Palette.SecondaryText, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text(poll.question, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) @@ -93,13 +94,13 @@ fun ColumnScope.PollCard( Text( stringResource(R.string.social_poll_you_chose, poll.options[poll.myChoice ?: 0]), fontSize = 14.sp, - color = Palette.SecondaryText, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Text( waitingLine(poll, standing, name), fontSize = 13.sp, - color = Palette.SecondaryText, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -183,7 +184,7 @@ private fun ColumnScope.Results( stringResource(R.string.social_poll_guarantee_pair) }, fontSize = 12.sp, - color = Palette.SecondaryText, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -241,7 +242,7 @@ fun NewPollDialog( stringResource(R.string.social_poll_group_note) }, fontSize = 12.sp, - color = Palette.SecondaryText, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } }, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ScanAddress.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ScanAddress.kt index 66ded51..a54501c 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ScanAddress.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ScanAddress.kt @@ -25,38 +25,40 @@ import io.flamenet.messenger.R * Point the camera at somebody's address, the other half of [AddressCard]. */ -/** What a valid code carries. `handle` only when the QR's author included it. */ +/** What a valid code carries: an address, and nothing else is taken from it. */ internal data class ScannedAddress( val fnid: String, val relay: String, - val handle: String?, ) /** - * `flamenet:<fnid>@<relay>[?u=<handle>&k=…]`, or the bare `fnid@relay` somebody - * retypes. + * A hello link (§6.34), which is what an Address screen's code holds now; the + * bare `fnid@relay` somebody retypes; or `flamenet:<fnid>@<relay>[?u=<handle>&k=…]`, + * the card older builds draw, whose query is ignored. Null for anything else, + * a code that only looks like an address included. */ internal fun parseScannedAddress(text: String): ScannedAddress? { + HelloLink.address(text)?.let { address -> + val at = address.indexOf('@') + return ScannedAddress(address.substring(0, at), address.substring(at + 1)) + } var s = text.trim() if (s.lowercase().startsWith("flamenet:")) s = s.substring("flamenet:".length) - var handle: String? = null - val q = s.indexOf('?') - if (q >= 0) { - s.substring(q + 1).split('&').forEach { pair -> - if (pair.startsWith("u=")) handle = pair.substring(2) - } - s = s.substring(0, q) - } - val at = s.indexOf('@') - if (at <= 0 || at == s.length - 1) return null - val fnid = s.substring(0, at).trim() - val relay = s.substring(at + 1).trim().lowercase() - if (fnid.isEmpty() || relay.isEmpty() || !relay.contains('.')) return null - // A relay is a hostname. - if (relay.contains('/') || relay.contains(':')) return null - return ScannedAddress(fnid, relay, handle?.takeIf { it.isNotBlank() }) + s = s.substringBefore('?') + // The same rule as a hello link, so a code holding `mailto:someone@host` + // or a web address with an @ in it is not taken for a person. + val address = HelloLink.canonical(s.trim()) ?: return null + val at = address.indexOf('@') + return ScannedAddress(address.substring(0, at), address.substring(at + 1)) } +/** + * What an add screen is filled with for a scanned code, on Home and on + * Messages alike: the address, bound by the fnid and never by a handle the + * code also names (audit LOW, DESIGN 32). Null when the code holds no address. + */ +internal fun prefillFor(scanned: String): String? = parseScannedAddress(scanned)?.let { "${it.fnid}@${it.relay}" } + /** Full-screen camera dialog. Calls [onFound] once with the raw decoded text. */ @Composable fun ScanAddressDialog( diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index e48b93a..52fe70f 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -5,6 +5,7 @@ import io.flamenet.client.DeviceClock import io.flamenet.client.MessengerApi import io.flamenet.client.RelayClient import io.flamenet.e2e.Attachment +import io.flamenet.e2e.ChatEvents import io.flamenet.e2e.ContactCard import io.flamenet.e2e.Content import io.flamenet.e2e.DeviceIdentity @@ -89,7 +90,34 @@ class Session( * A timer this one message carries, in seconds (§6.17); 0 for none. */ val lifetimeSeconds: Int = 0, - ) + /** Set iff this line is a greeting card (§6.31), kept as it arrived. */ + val card: Content.Card? = null, + /** + * Set iff this line is an event (§6.32): the latest revision, who + * organized it, and every answer so far. Revisions and answers fold + * into this one line and never add lines of their own. + */ + val event: ChatEvents.State? = null, + /** + * Set iff this line is a game (§6.33): the start message, which holds + * the board, the whole log, and how it ended. + */ + val game: ChatGames.State? = null, + ) { + /** + * What a quote, a pin or the conversation list says about this line: + * its words, or what a card, an event or a game is. Never a card's + * words, which may still be sealed for whoever reads the quote. + */ + val summary: String + get() = + when { + card != null -> CardArt.summary(card) + event != null -> (if (event.event.cancelled) "Cancelled: " else "Event: ") + event.event.shownTitle + game != null -> "Game: " + ChatGames.name(game.type) + else -> text + } + } /** Where an outbound message got to. */ enum class Delivery { PENDING, DELIVERED, FAILED } @@ -110,8 +138,17 @@ class Session( val stored = ephemeral[threadId] ?: vault.history(threadId) return (0 until stored.length()).map { val m = stored.getJSONObject(it) + val event = ChatEvents.fromRow(m) + val game = ChatGames.fromRow(m) Line( - text = m.optString("text"), + // An event or a game this build cannot draw, from a backup or + // a newer app, says so instead of showing as an empty bubble. + text = + m.optString("text").ifEmpty { + val unreadable = + (m.has(ChatEvents.ROW_EVENT) && event == null) || (m.has(ChatGames.ROW_GAME) && game == null) + if (unreadable) UNSUPPORTED else "" + }, mine = m.optBoolean("mine"), at = m.optLong("at"), id = m.optString("mid").takeIf { it.isNotBlank() }, @@ -147,6 +184,9 @@ class Session( m.optBoolean("reStory", false) || m.optInt("reStory", 0) != 0, editedAt = m.optLong("edited", 0L).takeIf { it > 0L }, lifetimeSeconds = m.optInt("secs", 0), + card = m.optJSONObject("card")?.let(Content.Card::fromJson), + event = event, + game = game, ) } } @@ -182,6 +222,12 @@ class Session( * message id, and the bubble has nothing to resolve. */ aboutAStory: Boolean = false, + /** A greeting card (§6.31). */ + card: Content.Card? = null, + /** An event, with its organizer and any answers already in (§6.32). */ + event: ChatEvents.State? = null, + /** A game, on its start message (§6.33). */ + game: ChatGames.State? = null, ) { val row = JSONObject() @@ -206,6 +252,10 @@ class Session( // this `reStory` in §16 and so does the row, so the two agree // without a rename in the middle. .also { if (aboutAStory) it.put("reStory", true) } + // Under the wire's own names, which are also the backup's. + .also { if (card != null) it.put("card", JSONObject(card.toJson())) } + .also { if (event != null) ChatEvents.toRow(event, it) } + .also { if (game != null) ChatGames.toRow(game, it) } // Off the record: the row is held in memory and nothing reaches disk. // This is the one place a message is ever written, which is what makes @@ -319,13 +369,31 @@ class Session( /** Reminders that have come due at [now]. */ fun dueReplyReminders(now: Long = System.currentTimeMillis()): Map<String, Long> = vault.dueReplyReminders(now) - /** Delete one message from this device (delete for me). */ + /** + * Delete one message from this device (delete for me), where its line + * lives. An off-the-record line is in memory, and the vault must not be + * written for that conversation at all, not even with an empty history. + */ fun deleteMessage( threadId: String, at: Long, mine: Boolean, text: String, - ) = vault.deleteLine(threadId, at, mine, text) + ): Boolean = + mutateHistory(threadId) { stored -> + var hit = false + var i = 0 + while (i < stored.length()) { + val m = stored.optJSONObject(i) + if (m != null && m.optLong("at") == at && m.optBoolean("mine") == mine && m.optString("text") == text) { + stored.remove(i) + hit = true + } else { + i++ + } + } + hit + } /** Send an image. Returns what happened, same as a text send. */ fun sendImage( @@ -334,19 +402,32 @@ class Session( mime: String, width: Int, height: Int, + ): io.flamenet.client.Conversations.Sent = + sendUploaded(peerUserId, conversations.uploadAttachment(bytes, mime, width, height)) + + /** + * An attachment already uploaded, to one person: recorded from the + * reference just built, so the sender's own thread shows it without + * fetching it back, and queued as that reference when it reached nobody, + * as a text is, so the bubble leaves pending once it goes. + */ + private fun sendUploaded( + peerUserId: Long, + ref: Attachment.Ref, ): io.flamenet.client.Conversations.Sent { - val sent = - conversations.sendAttachment(peerUserId, bytes, mime, Attachment.IMAGE, width = width, height = height) - // From the reference we just built, so the sender's own thread shows - // the image without fetching it back from the relay. + val wire = Content.attachment(ref) + val sent = conversations.send(peerUserId, wire) + val at = System.currentTimeMillis() record( Vault.threadForUser(peerUserId), "", mine = true, - attachment = sent.attachment, + attachment = ref, + at = at, delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING, ) - return sent + if (!sent.reachedSomeone) enqueue(peerUserId, wire, at) + return sent.copy(attachment = ref) } /** @@ -357,18 +438,8 @@ class Session( bytes: ByteArray, mime: String, seconds: Double, - ): io.flamenet.client.Conversations.Sent { - val sent = - conversations.sendAttachment(peerUserId, bytes, mime, Attachment.AUDIO, duration = seconds) - record( - Vault.threadForUser(peerUserId), - "", - mine = true, - attachment = sent.attachment, - delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING, - ) - return sent - } + ): io.flamenet.client.Conversations.Sent = + sendUploaded(peerUserId, conversations.uploadAttachment(bytes, mime, 0, 0, Attachment.AUDIO, duration = seconds)) /** * Send a document from the Files vault. @@ -383,28 +454,24 @@ class Session( // android#17, a photo sent as a FILE used to keep its GPS while the // same photo through the picker did not. val clean = MetadataScrub.scrub(bytes).bytes - val sent = - conversations.sendAttachment( - peerUserId, + val ref = + conversations.uploadAttachment( clean, "application/octet-stream", - Attachment.FILE, + width = 0, + height = 0, + kind = Attachment.FILE, filename = name, classification = classification, compartments = compartments, ) - record( - Vault.threadForUser(peerUserId), - "", - mine = true, - attachment = sent.attachment, - delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING, - ) - return sent + return sendUploaded(peerUserId, ref) } /** - * Send a document to a group. + * Send a document to a group: one upload, one envelope per member, and + * the members nothing reached queued with the group thread, as a group + * photo is. */ fun sendGroupFile( group: RelayClient.Group, @@ -415,10 +482,8 @@ class Session( ): Conversations.Sent { // The group variant needs it too. val clean = MetadataScrub.scrub(bytes).bytes - val sent = - conversations.sendGroupAttachment( - group, - userId, + val ref = + conversations.uploadAttachment( clean, "application/octet-stream", width = 0, @@ -428,15 +493,18 @@ class Session( classification = classification, compartments = compartments, ) - record( - Vault.threadForGroup(group.id), - "", - mine = true, - attachment = sent.attachment, - author = userId, - delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING, - ) - return sent + val wire = Content.attachment(ref, null, group.id, groupName = vault.groupName(group.id)) + return sendToGroupAndRecord(group, wire) { at, delivery -> + record( + Vault.threadForGroup(group.id), + "", + mine = true, + attachment = ref, + author = userId, + at = at, + delivery = delivery, + ) + }.copy(attachment = ref) } /** Bytes for an attachment, cached on disk after the first fetch. */ @@ -478,11 +546,14 @@ class Session( return removed } + /** The file a decrypted attachment is cached under. */ + private fun cachedAttachmentName(attachmentId: String) = "att-" + attachmentId.replace('/', '_') + fun attachmentBytes( ref: Attachment.Ref, cacheDir: java.io.File, ): ByteArray { - val cached = java.io.File(cacheDir, "att-" + ref.id.replace('/', '_')) + val cached = java.io.File(cacheDir, cachedAttachmentName(ref.id)) if (cached.exists()) return cached.readBytes() val bytes = conversations.fetchAttachment(ref) cached.writeBytes(bytes) @@ -702,6 +773,665 @@ class Session( ) } + // -- Cards and events (§6.31 and §6.32) -----------------------------------. + + private fun newMessageId(): String = + java.util.UUID + .randomUUID() + .toString() + + /** + * Send one envelope to every member but us, and record our copy once. + * + * The same rule as a group sticker: the bubble is recorded whatever + * happened, and exactly the members nothing reached are queued, with the + * group thread to restate when they are. + */ + private fun sendToGroupAndRecord( + group: RelayClient.Group, + wire: String, + file: (at: Long, delivery: Delivery) -> Unit, + ): Conversations.Sent { + var delivered = 0 + var failed = 0 + val at = System.currentTimeMillis() + for (member in group.memberIds) { + if (member == userId) continue + val one = conversations.send(member, wire) + delivered += one.delivered.size + failed += one.failed.size + if (!one.reachedSomeone) enqueue(member, wire, at, thread = Vault.threadForGroup(group.id)) + } + file(at, if (delivered > 0) Delivery.DELIVERED else Delivery.PENDING) + return Conversations.Sent( + delivered = List(delivered) { "" }, + failed = if (failed > 0) mapOf("group" to "some members unreachable") else emptyMap(), + ) + } + + /** A card to one person. Recorded either way, and queued if it reached nobody. */ + fun sendCard( + peerUserId: Long, + card: Content.Card, + ): Conversations.Sent { + val mid = newMessageId() + val wire = Content.card(card, mid) + val sent = conversations.send(peerUserId, wire) + val at = System.currentTimeMillis() + record( + Vault.threadForUser(peerUserId), + "", + mine = true, + at = at, + messageId = mid, + card = card, + delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING, + ) + onSendSealed(peerUserId, sent.sealed, at) + if (!sent.reachedSomeone) enqueue(peerUserId, wire, at) + return sent + } + + /** A card to a group. */ + fun sendGroupCard( + group: RelayClient.Group, + card: Content.Card, + ): Conversations.Sent { + val mid = newMessageId() + val wire = Content.card(card, mid, groupId = group.id, groupName = vault.groupName(group.id)) + return sendToGroupAndRecord(group, wire) { at, delivery -> + record( + Vault.threadForGroup(group.id), + "", + mine = true, + author = userId, + at = at, + messageId = mid, + card = card, + delivery = delivery, + ) + } + } + + /** A new event, organized by this account, to one person or a group. */ + fun sendEvent( + peerUserId: Long?, + group: RelayClient.Group?, + event: Content.Event, + ): Conversations.Sent { + val mid = newMessageId() + val state = ChatEvents.State(event, organizer = userId) + if (group != null) { + val wire = Content.event(event, mid, groupId = group.id, groupName = vault.groupName(group.id)) + return sendToGroupAndRecord(group, wire) { at, delivery -> + record( + Vault.threadForGroup(group.id), + "", + mine = true, + author = userId, + at = at, + messageId = mid, + event = state, + delivery = delivery, + ) + } + } + val peer = peerUserId ?: throw IllegalArgumentException("an event needs a conversation") + val wire = Content.event(event, mid) + val sent = conversations.send(peer, wire) + val at = System.currentTimeMillis() + record( + Vault.threadForUser(peer), + "", + mine = true, + at = at, + messageId = mid, + event = state, + delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING, + ) + onSendSealed(peer, sent.sealed, at) + if (!sent.reachedSomeone) enqueue(peer, wire, at) + return sent + } + + /** Where an event stands in a thread, or null when the thread has no such event. */ + fun eventState( + threadId: String, + eventId: String, + ): ChatEvents.State? = history(threadId).firstNotNullOfOrNull { line -> line.event?.takeIf { it.event.id == eventId } } + + /** + * Change one event's state where its line lives, memory or vault. The + * block returns the new state, or null to leave it alone. + */ + private fun updateEvent( + threadId: String, + eventId: String, + change: (ChatEvents.State) -> ChatEvents.State?, + ): Boolean = + mutateHistory(threadId) { stored -> + for (i in 0 until stored.length()) { + val row = stored.optJSONObject(i) ?: continue + val state = ChatEvents.fromRow(row) ?: continue + if (state.event.id != eventId) continue + val next = change(state) ?: return@mutateHistory false + ChatEvents.toRow(next, row) + return@mutateHistory true + } + false + } + + /** + * Change or cancel an event this account organized: the next revision, + * under a fresh mid, folded into the same card here as everywhere else. + * Put back as it was when it reached nobody, the way an edit is. + */ + fun reviseEvent( + threadId: String, + eventId: String, + peerUserId: Long?, + group: RelayClient.Group?, + change: (Content.Event) -> Content.Event, + ): Boolean { + val before = eventState(threadId, eventId) ?: return false + // Only the organizer, and only once settled: a stand-in can be + // replaced by the real rev 1, and its revisions with it. + if (before.organizer != userId || before.organizerProvisional) return false + val next = change(before.event).copy(id = eventId, rev = before.event.rev + 1) + val folded = ChatEvents.fold(before, userId, next) + if (folded.change != ChatEvents.Change.REVISED) return false + if (!updateEvent(threadId, eventId) { folded.state }) return false + val body = Content.event(next, newMessageId(), groupId = group?.id, groupName = group?.let { vault.groupName(it.id) }) + if (!fanOut(body, peerUserId, group, threadId)) { + // Only the event goes back: an answer that arrived meanwhile stays. + updateEvent(threadId, eventId) { it.copy(event = before.event) } + return false + } + return true + } + + /** + * Answer an event. Everyone in the chat is sent it, which the card says. + * Put back as it was when it reached nobody, the way a reaction is. + */ + fun answerEvent( + threadId: String, + eventId: String, + answer: String, + peerUserId: Long?, + group: RelayClient.Group?, + nowSeconds: Long = System.currentTimeMillis() / 1000, + ): Boolean { + val state = eventState(threadId, eventId) ?: return false + if (state.event.cancelled || answer !in Content.Rsvp.ANSWERS) return false + val previous = state.answers[userId] + val rsvp = Content.Rsvp(eventId, answer, state.nextNumber(userId, nowSeconds)) + if (!updateEvent(threadId, eventId) { ChatEvents.answer(it, userId, rsvp) }) return false + if (!fanOut(Content.rsvp(rsvp, groupId = group?.id), peerUserId, group, threadId)) { + updateEvent(threadId, eventId) { s -> + s.copy(answers = if (previous == null) s.answers - userId else s.answers + (userId to previous)) + } + return false + } + return true + } + + /** + * Send an envelope to everyone in a thread. False when it reached nobody, + * so the caller can put its own change back. In a group, the members it + * missed while reaching others are queued, so they still get it. + */ + private fun fanOut( + body: String, + peerUserId: Long?, + group: RelayClient.Group?, + threadId: String, + ): Boolean { + if (group == null) { + val peer = peerUserId ?: return false + return runCatching { conversations.send(peer, body).reachedSomeone }.getOrDefault(false) + } + val missed = mutableListOf<Long>() + var reached = false + for (member in group.memberIds) { + if (member == userId) continue + val ok = runCatching { conversations.send(member, body).reachedSomeone }.getOrDefault(false) + if (ok) reached = true else missed += member + } + if (reached) { + val at = System.currentTimeMillis() + missed.forEach { enqueue(it, body, at, thread = threadId) } + } + return reached + } + + /** + * Forward a card: a new mid, marked forwarded, and never sealed, whatever + * date the original was sealed until (§6.31). + */ + fun forwardCard( + card: Content.Card, + toPeerUserId: Long?, + toGroup: RelayClient.Group?, + ): Boolean { + val copy = card.copy(open = null) + val mid = newMessageId() + val body = + Content.card(copy, mid, groupId = toGroup?.id, groupName = toGroup?.let { vault.groupName(it.id) }, forwarded = true) + if (!sendToThread(body, toPeerUserId, toGroup)) return false + val thread = toGroup?.let { Vault.threadForGroup(it.id) } ?: toPeerUserId?.let { Vault.threadForUser(it) } ?: return true + record( + thread, + "", + mine = true, + author = if (toGroup != null) userId else 0, + messageId = mid, + card = copy, + forwarded = true, + delivery = Delivery.DELIVERED, + ) + return true + } + + /** + * Forward an event as a copy: a fresh id at rev 1, organized by whoever + * forwards it, with none of the answers (§6.32). A cancelled + * event is not forwarded. + */ + fun forwardEvent( + event: Content.Event, + toPeerUserId: Long?, + toGroup: RelayClient.Group?, + ): Boolean { + if (event.cancelled) return false + val copy = event.copy(id = newMessageId(), rev = 1) + val mid = newMessageId() + val body = + Content.event(copy, mid, groupId = toGroup?.id, groupName = toGroup?.let { vault.groupName(it.id) }, forwarded = true) + if (!sendToThread(body, toPeerUserId, toGroup)) return false + val thread = toGroup?.let { Vault.threadForGroup(it.id) } ?: toPeerUserId?.let { Vault.threadForUser(it) } ?: return true + record( + thread, + "", + mine = true, + author = if (toGroup != null) userId else 0, + messageId = mid, + event = ChatEvents.State(copy, organizer = userId), + forwarded = true, + delivery = Delivery.DELIVERED, + ) + return true + } + + /** + * File an event envelope into [threadId]: a new card the first time its + * id is seen there, otherwise folded into the card already there, which + * keeps the mid it first arrived with for replies and reactions. + */ + internal fun receiveEvent( + threadId: String, + sender: Long, + event: Content.Event, + messageId: String?, + mine: Boolean = false, + forwarded: Boolean = false, + lifetimeSeconds: Int = 0, + at: Long = System.currentTimeMillis(), + ): ChatEvents.Folded { + var known: ChatEvents.Folded? = null + mutateHistory(threadId) { stored -> + for (i in 0 until stored.length()) { + val row = stored.optJSONObject(i) ?: continue + val state = ChatEvents.fromRow(row) ?: continue + if (state.event.id != event.id) continue + val folded = ChatEvents.fold(state, sender, event) + known = folded + val changed = folded.change == ChatEvents.Change.REVISED || folded.change == ChatEvents.Change.SETTLED + if (changed) ChatEvents.toRow(folded.state!!, row) + return@mutateHistory changed + } + false + } + known?.let { return it } + val fresh = ChatEvents.fold(null, sender, event) + val state = applyHeldAnswers(threadId, fresh.state!!) + record( + threadId, + "", + mine = mine, + author = sender, + at = at, + messageId = messageId, + event = state, + forwarded = forwarded, + lifetimeSeconds = lifetimeSeconds, + ) + return fresh.copy(state = state) + } + + /** + * Fold an answer into the event it names in [threadId], or hold it until + * that event arrives. True when an event's card changed. + */ + internal fun receiveAnswer( + threadId: String, + sender: Long, + rsvp: Content.Rsvp, + ): Boolean { + if (eventState(threadId, rsvp.eventId) == null) { + holdAnswer(threadId, sender, rsvp) + return false + } + return updateEvent(threadId, rsvp.eventId) { ChatEvents.answer(it, sender, rsvp) } + } + + /** + * Answers that came in before their event, by thread, event and sender. + * In memory only, for every thread, and bounded: an answer is held for + * the event that follows it, not kept, and one lost with the process is + * one its sender gives again with a higher count. + */ + private val heldAnswers = LinkedHashMap<Triple<String, String, Long>, Content.Rsvp>() + + private fun holdAnswer( + threadId: String, + sender: Long, + rsvp: Content.Rsvp, + ) = synchronized(heldAnswers) { + val key = Triple(threadId, rsvp.eventId, sender) + val held = heldAnswers[key] + if (held == null || ChatEvents.beats(rsvp.n, rsvp.answer, ChatEvents.Answer(held.answer, held.n))) { + heldAnswers[key] = rsvp + } + while (heldAnswers.size > MAX_HELD_ANSWERS) heldAnswers.remove(heldAnswers.keys.first()) + } + + private fun applyHeldAnswers( + threadId: String, + state: ChatEvents.State, + ): ChatEvents.State = + synchronized(heldAnswers) { + var out = state + val mine = heldAnswers.keys.filter { it.first == threadId && it.second == state.event.id } + for (key in mine) { + heldAnswers.remove(key)?.let { rsvp -> out = ChatEvents.answer(out, key.third, rsvp) ?: out } + } + out + } + + /** + * What arrived with its own words on a lock screen (§6.31 and §6.32). + * Previews decide whether any of it is shown; see `NotificationSettings`. + */ + sealed interface Notice { + /** Who sent it. */ + val from: Long + + /** A card, and when it opens if it is still sealed. */ + data class Card( + override val from: Long, + val opensAtMillis: Long?, + ) : Notice + + /** A new event. */ + data class Invite( + override val from: Long, + val title: String, + ) : Notice + + /** An event changed or was cancelled, named as the reader knew it. */ + data class Changed( + override val from: Long, + val title: String, + val cancelled: Boolean, + ) : Notice + + /** + * An invitation to a game (§6.33): "Sam invited you to play chess", + * with a sound, and the chat counts as unread (DESIGN 29). + */ + data class GameInvite( + override val from: Long, + val type: io.flamenet.games.GameType, + ) : Notice + + /** + * Their move in a game, and it is this person's turn (§6.33). Shown + * without a sound, and never unread (DESIGN 29). + */ + data class GameTurn( + override val from: Long, + val type: io.flamenet.games.GameType, + ) : Notice + + /** A game finished, with how, from this person's side. Said with a sound, never unread. */ + data class GameOver( + override val from: Long, + val type: io.flamenet.games.GameType, + val result: String, + ) : Notice + + /** They offer a draw. Shown without a sound, like a move, and never unread. */ + data class GameDrawOffer( + override val from: Long, + val type: io.flamenet.games.GameType, + ) : Notice + } + + /** + * The notice for the last batch received, when it brought exactly one + * thing a notification would be about and that thing was a card, an + * event or a game invitation. Anything more and the notification stays + * the generic one. + */ + @Volatile private var lastNotice: Notice? = null + + /** Take the last batch's notice, leaving none behind. */ + fun takeNotice(): Notice? = lastNotice.also { lastNotice = null } + + /** + * The last game news of the batch that is said but not unread (DESIGN + * 29): their move, a draw offer or an ending. Its chat is redrawn through + * [takeQuietlyChanged]; only an invitation counts as unread. + */ + @Volatile private var lastGameNews: Notice? = null + + /** Take the last batch's game news, leaving none behind. */ + fun takeGameNews(): Notice? = lastGameNews.also { lastGameNews = null } + + /** + * Threads whose cards changed in a way that is not news: answers, a late + * rev 1 that only settles an organizer, and anything from this account's + * own other device. Redrawn, never notified and never marked unread + * (§6.32: answers do not notify). + */ + private val quietlyChanged = mutableSetOf<String>() + + fun takeQuietlyChanged(): Set<String> = + synchronized(quietlyChanged) { + val out = quietlyChanged.toSet() + quietlyChanged.clear() + out + } + + private fun changedQuietly(threadId: String) = synchronized(quietlyChanged) { quietlyChanged += threadId } + + /** + * Where a card, an event or an answer from [message] belongs, or null when + * it belongs nowhere. A group envelope needs a sender the roster lists. An + * envelope from this account's own other device has no conversation to + * land in unless it names a group, because there are no sent copies + * (no sent copies, §17.2). + */ + private fun placeFor(message: io.flamenet.client.Conversations.Received): String? { + message.fromAddress?.let { return Vault.threadForAddress(it) } + val gid = message.content.groupId + if (gid != null) { + val known = groupRoster(gid) ?: return null + return if (message.fromUser in known) Vault.threadForGroup(gid) else null + } + if (isOwn(message)) return null + return Vault.threadForUser(message.fromUser) + } + + private fun isOwn(message: io.flamenet.client.Conversations.Received): Boolean = + message.fromAddress == null && message.senderProven && message.fromUser == userId + + // -- Games (§6.33) -------------------------------------------------------------. + + /** + * Start a game with one person. The start message carries the board; + * moves and endings travel as control traffic that names the game by id. + * Recorded either way, and queued if it reached nobody, like a card. + */ + fun startGame( + peerUserId: Long, + type: io.flamenet.games.GameType, + iGoFirst: Boolean, + ): Conversations.Sent { + val mid = newMessageId() + val state = + ChatGames.State( + id = newMessageId(), + type = type, + me = if (iGoFirst) io.flamenet.games.Side.FIRST else io.flamenet.games.Side.SECOND, + startedByMe = true, + ) + val wire = Content.game(ChatGames.wire(state), mid) + val sent = conversations.send(peerUserId, wire) + val at = System.currentTimeMillis() + record( + Vault.threadForUser(peerUserId), + "", + mine = true, + at = at, + messageId = mid, + game = state, + delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING, + ) + onSendSealed(peerUserId, sent.sealed, at) + if (!sent.reachedSomeone) enqueue(peerUserId, wire, at) + return sent + } + + /** Where a game stands in a thread, or null when the thread has no such game. */ + fun gameState( + threadId: String, + gameId: String, + ): ChatGames.State? = history(threadId).firstNotNullOfOrNull { line -> line.game?.takeIf { it.id == gameId } } + + /** Change one game's state where its line lives. Null from [change] leaves it alone. */ + private fun updateGame( + threadId: String, + gameId: String, + change: (ChatGames.State) -> ChatGames.State?, + ): Boolean = + mutateHistory(threadId) { stored -> + for (i in 0 until stored.length()) { + val row = stored.optJSONObject(i) ?: continue + val state = ChatGames.fromRow(row) ?: continue + if (state.id != gameId) continue + val next = change(state) ?: return@mutateHistory false + ChatGames.toRow(next, row) + return@mutateHistory true + } + false + } + + /** + * Play one move. It goes out with the whole log. Taken back when it + * reached nobody, so the two boards never disagree over a move that was + * not sent. + */ + fun playGameMove( + threadId: String, + gameId: String, + move: String, + peerUserId: Long, + ): Boolean { + val before = gameState(threadId, gameId) ?: return false + val next = ChatGames.play(before, move) ?: return false + if (!updateGame(threadId, gameId) { next }) return false + if (!sendGameControl(peerUserId, Content.gameMove(ChatGames.wire(next)))) { + updateGame(threadId, gameId) { before } + return false + } + return true + } + + /** Resign, offer a draw or answer one, or decline or cancel before play starts. */ + fun endGame( + threadId: String, + gameId: String, + ending: String, + peerUserId: Long, + ): Boolean { + val before = gameState(threadId, gameId) ?: return false + if (!ChatGames.canEnd(before, ending)) return false + val next = ChatGames.ended(before, ending) + if (!updateGame(threadId, gameId) { next }) return false + if (!sendGameControl(peerUserId, Content.gameEnd(ChatGames.wire(before, ending)))) { + updateGame(threadId, gameId) { before } + return false + } + return true + } + + /** Give a rebuilt game's card the id its start message carried, once. */ + private fun adoptGameMessageId( + threadId: String, + gameId: String, + messageId: String, + ) = mutateHistory(threadId) { stored -> + for (i in 0 until stored.length()) { + val row = stored.optJSONObject(i) ?: continue + if (ChatGames.fromRow(row)?.id != gameId) continue + if (row.optString("mid").isNotEmpty()) return@mutateHistory false + row.put("mid", messageId) + return@mutateHistory true + } + false + } + + private fun sendGameControl( + peerUserId: Long, + body: String, + ): Boolean = runCatching { conversations.send(peerUserId, body).reachedSomeone }.getOrDefault(false) + + /** + * Take a move or an ending from the other side into [threadId] (§6.33, + * receiving). A game this chat does not hold is rebuilt from the log on a + * card of its own. Returns what it did. + */ + internal fun receiveGame( + threadId: String, + sender: Long, + kind: String, + game: Content.Game, + at: Long = System.currentTimeMillis(), + ): ChatGames.Received { + val current = gameState(threadId, game.id) + val received = ChatGames.receive(current, kind, game) + val next = + when (received) { + is ChatGames.Received.Moved -> received.state + is ChatGames.Received.Ended -> received.state + is ChatGames.Received.Offered -> received.state + is ChatGames.Received.Quiet -> received.state + is ChatGames.Received.OutOfStep -> received.state + ChatGames.Received.Ignored -> return received + } + if (current == null) { + // The card stands in for the start message, which was this + // person's when they started the game, as a decline shows. + val mine = next.startedByMe + record(threadId, "", mine = mine, author = if (mine) userId else sender, at = at, game = next) + } else { + updateGame(threadId, game.id) { next } + } + return received + } + /** * Who may see this account's stories. */ @@ -1301,7 +2031,11 @@ class Session( return sent } - /** An image to a group: one upload, one envelope per member. */ + /** + * An image to a group: one upload, one envelope per member, and the + * members nothing reached queued with the group thread to restate, as a + * group sticker or card is, so the bubble leaves pending once they have it. + */ fun sendGroupImage( group: RelayClient.Group, bytes: ByteArray, @@ -1309,16 +2043,20 @@ class Session( width: Int, height: Int, ): Conversations.Sent { - val sent = conversations.sendGroupAttachment(group, userId, bytes, mime, width, height) - record( - Vault.threadForGroup(group.id), - "", - mine = true, - attachment = sent.attachment, - author = userId, - delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING, - ) - return sent + // Uploaded once; what waits in the outbox is the reference, not the bytes. + val ref = conversations.uploadAttachment(bytes, mime, width, height) + val wire = Content.attachment(ref, null, group.id, groupName = vault.groupName(group.id)) + return sendToGroupAndRecord(group, wire) { at, delivery -> + record( + Vault.threadForGroup(group.id), + "", + mine = true, + attachment = ref, + author = userId, + at = at, + delivery = delivery, + ) + }.copy(attachment = ref) } /** @@ -1361,9 +2099,18 @@ class Session( } System.err.println("fnmsg-wipe: honouring an erase order, forgetting everything held here") vault.forgetEverything() + // And what the app keeps beside the vault, the Today tab's file and + // city picks (DESIGN 32), which this module cannot reach itself. + runCatching { onWiped?.invoke() } return true } + /** + * Called after this device erases itself on an order (§6.26), for what + * the app keeps outside the vault. + */ + @Volatile var onWiped: (() -> Unit)? = null + fun receive(waitSeconds: Int = 0): Set<String> { // Keep this phone reachable by strangers. Each new sender's first // message takes a one-time prekey, and nothing topped the pool back @@ -1399,6 +2146,13 @@ class Session( /** How many messages are waiting on each sender whose keys are unaccepted. */ fun heldEnvelopeCounts(): Map<Long, Int> = conversations.heldEnvelopeCounts() + /** + * The seam for checking what a batch does to threads without a relay to + * deliver it: cards, events and answers are routed here exactly as they + * are from a poll. + */ + internal fun absorbForTesting(everything: List<io.flamenet.client.Conversations.Received>): Set<String> = absorb(everything) + /** * Everything that happens to a batch of received messages once they are * decrypted: stories, reactions, threads, call signals. @@ -1518,10 +2272,132 @@ class Session( // renderable content as a message. val arrived = everything.filter { it.content.call == null && !it.content.isControl } val touched = mutableSetOf<String>() + // How many things this batch changed that a notification is about, + // and which of them have words of their own (§6.31 and §6.32). + var news = 0 + val notices = mutableListOf<Notice>() + // A game's moves, offers and endings are said and redrawn, never + // unread: a game in progress would otherwise keep a chat unread for + // every turn (DESIGN 29). + val gameNews = mutableListOf<Notice>() + + fun touch(thread: String) { + touched += thread + news++ + } + + // §6.32. An answer is control traffic, so it never reaches the + // renderable filter below. It folds into its event's card here, and + // quietly: an answer does not notify and does not mark anything unread. + everything.forEach { message -> + val rsvp = message.content.rsvp ?: return@forEach + val thread = placeFor(message) ?: return@forEach + if (receiveAnswer(thread, message.fromUser, rsvp)) changedQuietly(thread) + } + // §6.33. Moves and endings are control traffic too, one-to-one only: + // a game in a group is not a thing, so one naming a group is dropped, + // and so is one from another server, where no game can be started. + everything.forEach { message -> + val content = message.content + if (content.kind != Content.Kind.GAME_MOVE && content.kind != Content.Kind.GAME_END) return@forEach + val game = content.game ?: return@forEach + if (content.groupId != null || message.fromAddress != null) return@forEach + val thread = placeFor(message) ?: return@forEach + when (val received = receiveGame(thread, message.fromUser, content.kind, game)) { + // Not even a move that had to build the board, its invitation + // lost on the way, counts as unread (DESIGN 29). + is ChatGames.Received.Moved -> { + changedQuietly(thread) + if (received.state.myMove) gameNews += Notice.GameTurn(message.fromUser, received.state.type) + } + + is ChatGames.Received.Ended -> { + changedQuietly(thread) + gameNews += + Notice.GameOver(message.fromUser, received.state.type, ChatGames.result(received.state).orEmpty()) + } + + is ChatGames.Received.Offered -> { + changedQuietly(thread) + gameNews += Notice.GameDrawOffer(message.fromUser, received.state.type) + } + + // The card says the boards disagree; nothing to say elsewhere. + is ChatGames.Received.OutOfStep -> { + changedQuietly(thread) + } + + // A declined offer of ours: redrawn, never notified. + is ChatGames.Received.Quiet -> { + changedQuietly(thread) + } + + ChatGames.Received.Ignored -> { + Unit + } + } + } + + // §6.15, §6.19, §6.9 and §6.22. Edits, pins and reactions change what + // is already here and are never a bubble, so the renderable filter + // below drops every one of them: they are applied here, from the whole + // batch. None is news either, each of those sections says it must not + // notify, so a thread they change is redrawn and never marked unread. + // + // Story reactions are collected as storyKey, who reacted, the emoji + // ("" takes one back), and applied after the loop below. + val storyReactionsIncoming = mutableListOf<Triple<String, Long, String>>() + everything.forEach { message -> + val content = message.content + // A story's new caption names a story, not a message (below). + if (content.editsStory) return@forEach + val editing = content.editOf + val pinned = content.pins + val reaction = content.reaction + if (editing == null && pinned == null && reaction == null) return@forEach + val about = content.about + // A reaction to one of OUR stories, which lives outside every thread. + if (reaction != null && content.reactsToStory) { + if (about != null) storyReactionsIncoming += Triple(about.messageId, message.fromUser, reaction) + return@forEach + } + // Where it was sent: a group only from one of its members, and + // nowhere for this account's own other device in a 1:1. + val thread = placeFor(message) ?: return@forEach + val changed = + when { + editing != null -> applyEdit(thread, editing, message.fromUser, content.text.orEmpty()) + pinned != null -> applyPin(thread, pinned).let { true } + about != null -> { + val emoji = reaction.orEmpty() + applyReaction(thread, about.messageId, message.fromUser, emoji, private = message.content.reactionIsPrivate) + } + else -> false + } + if (changed) changedQuietly(thread) + } + + // §6.35 and §6.15 for stories. Control traffic about a story this + // phone holds from the sender, applied here because neither ever + // reaches the renderable filter below. Never a bubble, never news. + everything.forEach { message -> + if (message.fromAddress != null || !message.senderProven) return@forEach + val content = message.content + content.unpost?.let { unpost -> + applyTakeDown(message.fromUser, unpost) + return@forEach + } + val storyId = content.editOf + if (content.editsStory && storyId != null) { + applyStoryEdit(message.fromUser, storyId, content.text.orEmpty(), message.at) + } + } + + // `arrived` is declared once, above, and deliberately NOT filtered on + // `isRenderable`: see the note there. The loop below files only + // renderable content as a message. val now = DeviceClock.expirySeconds() val incomingStories = mutableListOf<Vault.Story>() - // storyKey, who reacted, the emoji ("" takes one back). - val storyReactionsIncoming = mutableListOf<Triple<String, Long, String>>() for (message in arrived) { // A story bypasses the thread entirely (§6.6). @@ -1548,81 +2424,6 @@ class Session( if (story.isActive(now)) incomingStories += story continue } - // An EDIT (§6.15) changes a message; it is never one. - message.content.editOf?.let { editing -> - val gid = - if (message.fromAddress != null) { - null - } else { - message.content.groupId - } - val thread = - gid?.let(Vault::threadForGroup) - ?: message.fromAddress?.let(Vault::threadForAddress) - ?: Vault.threadForUser(message.fromUser) - if (applyEdit( - thread, - editing, - message.fromUser, - message.content.text.orEmpty(), - ) - ) { - touched += thread - } - continue - } - // A PIN (§6.19) is an id, not a bubble. - message.content.pins?.let { pinned -> - val gid = - if (message.fromAddress != null) { - null - } else { - message.content.groupId - } - val thread = - gid?.let(Vault::threadForGroup) - ?: message.fromAddress?.let(Vault::threadForAddress) - ?: Vault.threadForUser(message.fromUser) - applyPin(thread, pinned) - touched += thread - continue - } - // A reaction to a MESSAGE. - if (message.content.reaction != null && !message.content.reactsToStory) { - val about = message.content.about - if (about != null) { - val gid = - if (message.fromAddress != null) { - null - } else { - message.content.groupId - } - val thread = - gid?.let(Vault::threadForGroup) - ?: message.fromAddress?.let(Vault::threadForAddress) - ?: Vault.threadForUser(message.fromUser) - if (applyReaction( - thread, - about.messageId, - message.fromUser, - message.content.reaction.orEmpty(), - private = message.content.reactionIsPrivate, - ) - ) { - touched += thread - } - } - continue - } - // A reaction to one of OUR stories. - if (message.content.reactsToStory) { - val about = message.content.about - if (about != null) { - storyReactionsIncoming += - Triple(about.messageId, message.fromUser, message.content.reaction.orEmpty()) - } - continue - } val ref = message.content.attachment if (ref != null && ref.kind == Attachment.STORY && message.fromAddress == null) { // A story is caught before the group-message branch, so without @@ -1680,23 +2481,118 @@ class Session( ?.let(Vault::threadForGroup) ?: message.fromAddress?.let(Vault::threadForAddress) ?: Vault.threadForUser(message.fromUser) + // §6.31 and §6.32. One from this account's own other device is + // ours, and belongs nowhere unless it names a group. + val content = message.content + val own = (content.card != null || content.event != null || content.game != null) && isOwn(message) + if (own && gid == null) continue + // §6.33. A game started by the other side. A move may have + // rebuilt it already; then the start only lends its id to that + // card, so replies and reactions reach it, and adds no second one. + content.game?.let { game -> + // Somebody on another server: no move could be sent back, so + // it shows as a placeholder, not a board nobody can play. + if (message.fromAddress != null) { + record(thread, UNSUPPORTED, mine = false, author = message.fromUser, messageId = content.messageId) + touch(thread) + continue + } + val type = io.flamenet.games.GameType.fromWire(game.type) + val side = io.flamenet.games.Side.fromWire(game.mine) + if (type != null && side != null) { + if (gameState(thread, game.id) == null) { + record( + thread, + "", + mine = false, + author = message.fromUser, + messageId = content.messageId, + game = ChatGames.State(game.id, type, me = side.opponent, startedByMe = false), + ) + // An invitation is something to answer: unread, and + // said with a sound (DESIGN 29). + touch(thread) + notices += Notice.GameInvite(message.fromUser, type) + } else { + content.messageId?.let { mid -> adoptGameMessageId(thread, game.id, mid) } + } + } + continue + } + content.event?.let { event -> + val folded = + receiveEvent( + thread, + message.fromUser, + event, + content.messageId, + mine = own, + forwarded = content.isForwarded, + lifetimeSeconds = content.lifetimeSeconds ?: 0, + ) + when (folded.change) { + // Our own, from another of our devices, is not news to us. + ChatEvents.Change.NEW -> { + if (own) { + changedQuietly(thread) + } else { + touch(thread) + notices += Notice.Invite(message.fromUser, event.shownTitle) + } + } + + ChatEvents.Change.REVISED -> { + if (own) { + changedQuietly(thread) + } else { + touch(thread) + notices += + Notice.Changed( + message.fromUser, + (folded.before ?: event).shownTitle, + cancelled = event.cancelled, + ) + } + } + + ChatEvents.Change.SETTLED -> { + changedQuietly(thread) + } + + ChatEvents.Change.IGNORED -> { + Unit + } + } + continue + } record( thread, - message.content.text, - mine = false, - attachment = message.content.attachment, - sticker = message.content.sticker?.let { Sticker(it.pack, it.id) }, + content.text, + mine = own, + link = content.link, + attachment = content.attachment, + sticker = content.sticker?.let { Sticker(it.pack, it.id) }, author = message.fromUser, // THEIR id, not one we make up. - messageId = message.content.messageId, - about = message.content.about, - aboutAStory = message.content.repliesToStory, - lifetimeSeconds = message.content.lifetimeSeconds ?: 0, + messageId = content.messageId, + about = content.about, + aboutAStory = content.repliesToStory, + lifetimeSeconds = content.lifetimeSeconds ?: 0, // §6.16, so second-hand words are not drawn as the sender's // own. - forwarded = message.content.isForwarded, + forwarded = content.isForwarded, + card = content.card, ) - touched += thread + if (own) { + changedQuietly(thread) + continue + } + touch(thread) + content.card?.let { card -> + val arrivedAt = System.currentTimeMillis() + val opens = card.opensAtMillis(arrivedAt)?.takeIf { it > arrivedAt } + notices += Notice.Card(message.fromUser, opens) + } } if (incomingStories.isNotEmpty()) { @@ -1707,9 +2603,11 @@ class Session( storyReactionsIncoming.forEach { (key, who, emoji) -> applyStoryReaction(key, who, emoji) } - // Requests and own copies touched a thread too, but they are quiet. - quietThreads = quiet - return touched + quiet + // Requests and own copies touched a thread too, but they are quiet. + quietThreads = quiet + lastNotice = notices.singleOrNull()?.takeIf { news == 1 } + lastGameNews = gameNews.lastOrNull() + return touched + quiet } // -- Stories (§6.6) ------------------------------------------------------. @@ -1785,6 +2683,7 @@ class Session( postedAt = System.currentTimeMillis(), seen = true, gid = gid, + sentTo = recipients, ), now, ) @@ -1894,6 +2793,7 @@ class Session( postedAt = System.currentTimeMillis(), seen = true, expiresAt = posted.expires, + sentTo = toUserIds, ), now, ) @@ -1901,6 +2801,178 @@ class Session( return posted } + // -- Taking a story down, and changing its words (§6.35, §6.15) -----------. + + /** + * Where decrypted attachments are cached on this phone, so a story that + * comes down leaves no bytes behind. Set by whoever made the session. + */ + var attachmentCacheDir: java.io.File? = null + + /** Who a change to one of this account's stories goes to. */ + data class StoryRecipients( + val userIds: List<Long>, + /** True when these are the people it went to; false for the audience as it is now. */ + val asPosted: Boolean, + ) + + /** + * The people [story] went to: as recorded when it was posted; for one + * posted before that was kept, a group story's roster, or else today's + * story audience among [contacts]. This account's own other devices are + * reached apart. + */ + fun storyRecipients( + story: Vault.Story, + contacts: List<Long>, + ): StoryRecipients { + story.sentTo?.let { sent -> return StoryRecipients(sent.filter { it != userId }.distinct(), asPosted = true) } + story.gid?.let { gid -> + return StoryRecipients(groupRoster(gid).orEmpty().filter { it != userId }.sorted(), asPosted = false) + } + return StoryRecipients(storyAudience().recipients(contacts).filter { it != userId }, asPosted = false) + } + + /** What a take-down or an edit reached. */ + data class StoryChange( + val reached: Int, + val missed: Int, + val asPosted: Boolean, + ) + + /** + * Take one of this account's stories down for everyone it went to (§6.35, + * DESIGN items 25 and 33). A story in a set brings the whole set down. This + * phone's copy goes first, whatever the sends do; each recipient's app + * removes theirs when the message reaches it, silently. + */ + fun takeDownStory( + story: Vault.Story, + contacts: List<Long>, + ): StoryChange { + require(story.mine) { "only this account's own stories can be taken down" } + val setId = story.set?.id + val body = if (setId != null) Content.unpostStorySet(setId) else Content.unpostStory(story.id) + val recipients = storyRecipients(story, contacts) + removeStories { held -> + held.authorId == userId && if (setId != null) held.set?.id == setId else held.id == story.id + } + return sendAboutStory(recipients, body) + } + + /** + * New words for one of this account's stories (§6.15 for stories, DESIGN + * item 26). A set's words are on its first picture. Never empty: emptying + * a story is taking it down. + */ + fun editStoryCaption( + story: Vault.Story, + caption: String, + contacts: List<Long>, + ): StoryChange { + require(story.mine) { "only this account's own stories can be edited" } + val words = caption.trim() + require(words.isNotEmpty()) { "an empty caption is a take-down, not an edit" } + val target = + story.set?.let { set -> + vault.loadStories().firstOrNull { it.authorId == userId && it.set?.id == set.id && it.set?.i == 0 } + } ?: story + val now = System.currentTimeMillis() + changeStories { held -> + if (held.authorId == userId && held.id == target.id) held.copy(caption = words, editedAt = now) else null + } + return sendAboutStory(storyRecipients(target, contacts), Content.storyEdit(target.id, words)) + } + + /** Control traffic about a story: silent, never queued, and to this account's other devices too (§6.22.1). */ + private fun sendAboutStory( + recipients: StoryRecipients, + body: String, + ): StoryChange { + var reached = 0 + var missed = 0 + for (peer in recipients.userIds) { + if (runCatching { conversations.send(peer, body).reachedSomeone }.getOrDefault(false)) reached++ else missed++ + } + runCatching { conversations.send(userId, body) } + return StoryChange(reached, missed, recipients.asPosted) + } + + /** + * Take down what [author] asked to: a story this phone holds from them, + * or every item of their set (§6.35). Anything else is not theirs to + * remove, or not here, and changes nothing. + */ + internal fun applyTakeDown( + author: Long, + unpost: Content.Unpost, + ): Boolean = + removeStories { held -> + held.authorId == author && + (held.id == unpost.storyId || (unpost.setId != null && held.set?.id == unpost.setId)) + }.isNotEmpty() + + /** + * New words from [author] for a story this phone holds from them. Never + * empties it, and never lets an older edit replace a newer one: [at] is the + * relay's time for the envelope (§6.15). + */ + internal fun applyStoryEdit( + author: Long, + storyId: String, + caption: String, + at: Long, + ): Boolean { + if (caption.isBlank()) return false + val stamp = at.takeIf { it > 0L } ?: System.currentTimeMillis() + return changeStories { held -> + when { + held.authorId != author || held.id != storyId -> null + (held.editedAt ?: 0L) > stamp -> null + else -> held.copy(caption = caption, editedAt = stamp) + } + } + } + + /** + * Remove every story [matches] picks, from the live set and the archive, + * with its reactions and any decrypted copy of its bytes. + */ + private fun removeStories(matches: (Vault.Story) -> Boolean): List<Vault.Story> { + val now = DeviceClock.expirySeconds() + val live = vault.loadStories() + val archived = vault.storyArchive() + val gone = live.filter(matches) + archived.filter(matches) + if (gone.isEmpty()) return gone + if (live.any(matches)) vault.saveStories(live.filterNot(matches), now) + if (archived.any(matches)) vault.saveStoryArchive(archived.filterNot(matches)) + if (gone.any { storyReactions.remove(it.id) != null }) vault.saveStoryReactions(storyReactions) + attachmentCacheDir?.let { dir -> gone.mapNotNull { it.ref?.id }.forEach { forgetCachedBytes(dir, it) } } + return gone + } + + /** Apply [change] to every story it returns a new one for, live or archived. */ + private fun changeStories(change: (Vault.Story) -> Vault.Story?): Boolean { + var changedLive = false + val live = vault.loadStories().map { held -> change(held)?.also { changedLive = true } ?: held } + if (changedLive) vault.saveStories(live, DeviceClock.expirySeconds()) + var changedArchive = false + val archived = vault.storyArchive().map { held -> change(held)?.also { changedArchive = true } ?: held } + if (changedArchive) vault.saveStoryArchive(archived) + return changedLive || changedArchive + } + + /** The decrypted copy [attachmentBytes] keeps, and any video file made from it to play. */ + private fun forgetCachedBytes( + cacheDir: java.io.File, + attachmentId: String, + ) { + java.io.File(cacheDir, cachedAttachmentName(attachmentId)).delete() + cacheDir + .listFiles { f: java.io.File -> f.name.startsWith("story-$attachmentId") && f.name.endsWith(".mp4") } + ?.forEach { it.delete() } + } + /** * Muting somebody's stories is NOT muting their messages, and uses its own * key. @@ -1967,7 +3039,7 @@ class Session( id to Preview( text = - line.text.ifBlank { + line.summary.ifBlank { if (line.attachment != null) "Photo" else "" }, at = line.at, @@ -2055,6 +3127,7 @@ class Session( // rather than a story that looks different to the // person who sent it. streamUrl = streamUrl, + sentTo = toUserIds, ), now, ) @@ -2093,6 +3166,7 @@ class Session( postedAt = System.currentTimeMillis(), seen = true, set = Content.StorySet(setIdOf(posted), index, posted.size), + sentTo = toUserIds, ) } } @@ -2289,11 +3363,47 @@ class Session( // from `listGroups`, and dropping those would make somebody's older // groups vanish the moment sealing shipped. val plain = relay.listGroups().filterNot { it.id in sealedIds } - return (sealed + plain).map { group -> + val all = sealed + plain + // Kept for a start with the relay out of reach (DESIGN 47). + runCatching { + vault.saveGroupList( + org.json.JSONArray().also { list -> + all.forEach { g -> + list.put( + JSONObject() + .put("id", g.id) + .put("name", g.name) + .put("owner", g.owner) + .put("members", org.json.JSONArray(g.memberIds)), + ) + } + }, + ) + } + return all.map { group -> vault.groupName(group.id)?.let { group.copy(name = it) } ?: group } } + /** + * The groups as last listed, from this phone alone, or null when they + * never were (DESIGN 47). + */ + fun storedGroups(): List<RelayClient.Group>? { + val list = vault.loadGroupList() ?: return null + return (0 until list.length()).mapNotNull { i -> + val o = list.optJSONObject(i) ?: return@mapNotNull null + val id = o.optString("id").takeIf { it.isNotEmpty() } ?: return@mapNotNull null + val members = o.optJSONArray("members") ?: org.json.JSONArray() + RelayClient.Group( + id = id, + name = vault.groupName(id) ?: o.optString("name"), + owner = o.optLong("owner"), + memberIds = (0 until members.length()).map { members.optLong(it) }, + ) + } + } + /** Whether this group's roster is the sealed kind. For offering the upgrade. */ fun isSealed(gid: String): Boolean = vault.groupSecrets(gid) != null @@ -2631,19 +3741,29 @@ class Session( /** * The roster, from wherever it authoritatively lives. */ + /** + * The contact list kept on this phone, everybody shown offline, or null + * when none was ever kept (DESIGN 47). + */ + fun storedRoster(): List<io.flamenet.client.MessengerApi.Buddy>? = if (vault.hasRoster()) storedBuddies() else null + + private fun storedBuddies(): List<io.flamenet.client.MessengerApi.Buddy> { + val items = vault.loadRoster().first + return (0 until items.length()).map { + val e = items.getJSONObject(it) + io.flamenet.client.MessengerApi.Buddy( + userId = e.optLong("id"), + displayName = e.optString("name", ""), + handle = e.optString("handle", ""), + presence = "offline", + ) + } + } + fun roster(): List<io.flamenet.client.MessengerApi.Buddy> { val (items, offServer) = vault.loadRoster() - fun stored() = - (0 until items.length()).map { - val e = items.getJSONObject(it) - io.flamenet.client.MessengerApi.Buddy( - userId = e.optLong("id"), - displayName = e.optString("name", ""), - handle = e.optString("handle", ""), - presence = "offline", - ) - } + fun stored() = storedBuddies() if (offServer) return stored() return try { val answer = api.roster() @@ -2758,6 +3878,12 @@ class Session( ): Boolean = shouldAdoptRosterForTest(incoming, held, relayKeepsRoster) companion object { + /** What a line says when this build cannot draw what it holds (§6.10). */ + const val UNSUPPORTED = "[unsupported attachment]" + + /** How many answers may wait in memory for an event not yet arrived. */ + private const val MAX_HELD_ANSWERS = 512 + /** * The rule, reachable without building a Session so a test can ask it * directly rather than grep for it. diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StickerPack.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StickerPack.kt index aab2df1..43f0003 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StickerPack.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StickerPack.kt @@ -1,89 +1,133 @@ package io.flamenet.messenger.app import android.content.Context +import androidx.compose.ui.graphics.ImageBitmap import org.json.JSONObject /** - * The shipped sticker set, read from the same manifest the iOS app reads. + * The shipped sticker packs, read from the same manifests the iOS app reads. + * + * Every manifest under `assets/stickers/` is a pack: the glyph set first, then + * the FlameNet art, then anything else by id. A sticker draws as its image when + * the pack ships one, as its glyph when it does not or the image will not load, + * and as the pack's fallback when this build does not know the id. A pack this + * build does not have at all draws as the core fallback (docs/STICKERS.md). */ object StickerPack { data class Sticker( + val pack: String, val id: String, val glyph: String, val name: String, + /** A file beside the manifest, `stickers/<pack>/<image>`, or null. */ + val image: String? = null, + ) + + data class Pack( + val id: String, + val name: String, + val fallback: String, + val stickers: List<Sticker>, + ) + + /** What one reference draws as. */ + data class Resolved( + /** The asset path of the picture, or null to draw [glyph]. */ + val image: String?, + val glyph: String, + val name: String, ) const val CORE = "flamenet.core" + const val RETRO = "flamenet.retro" - private var loaded: Map<String, Sticker>? = null - private var order: List<Sticker> = emptyList() - private var fallbackGlyph = "🙂" + /** Manifest order in the picker: the glyphs, then the art. */ + private val PICKER_ORDER = listOf(CORE, RETRO) - /** Every sticker in the shipped pack, in manifest order. */ - fun all(context: Context): List<Sticker> { - ensure(context) - return order - } + private const val DEFAULT_FALLBACK = "\uD83D\uDE42" - /** - * What to draw for a reference. - */ - fun glyph( - context: Context, - pack: String, - id: String, - ): String { - ensure(context) - if (pack != CORE) return fallbackGlyph - return loaded?.get(id)?.glyph ?: fallbackGlyph - } + @Volatile private var loaded: List<Pack>? = null - fun name( + /** Every pack, in picker order. Empty when no manifest would load. */ + fun packs(context: Context): List<Pack> = + loaded ?: synchronized(this) { + loaded ?: load(context).also { loaded = it } + } + + /** What to draw for a reference. */ + fun resolve( context: Context, pack: String, id: String, - ): String { - ensure(context) - if (pack != CORE) return context.getString(io.flamenet.messenger.R.string.main_sticker) - return loaded?.get(id)?.name ?: context.getString(io.flamenet.messenger.R.string.main_sticker) - } + ): Resolved = resolve(packs(context), pack, id) - private fun ensure(context: Context) { - if (loaded != null) return - // Failure is empty, not a crash. - val text = - runCatching { - context.assets - .open("stickers/$CORE.json") - .bufferedReader() - .use { it.readText() } - }.getOrNull() ?: run { - loaded = emptyMap() - return - } + /** The image for a resolved sticker, or null when there is none or it will not decode. */ + fun image( + context: Context, + path: String, + ): ImageBitmap? = AssetImages.load(context, path) - val json = - runCatching { JSONObject(text) }.getOrNull() - ?: run { - loaded = emptyMap() - return + private fun load(context: Context): List<Pack> { + // Failure is an empty list, never a crash: the picker says so. + val names = runCatching { context.assets.list("stickers")?.toList() }.getOrNull().orEmpty() + val packs = + names + .filter { it.endsWith(".json") } + .mapNotNull { file -> + runCatching { + context.assets.open("stickers/$file").bufferedReader().use { it.readText() } + }.getOrNull()?.let(::parse) } + return ordered(packs) + } + + // -- Pure, so the rules can be checked without a device --------------------. - fallbackGlyph = json.optString("fallback").ifBlank { "🙂" } + /** One manifest, or null when it is not one. Stickers without an id are skipped. */ + fun parse(text: String): Pack? { + val json = runCatching { JSONObject(text) }.getOrNull() ?: return null + val id = json.optString("pack").takeIf { it.isNotBlank() } ?: return null + val fallback = json.optString("fallback").ifBlank { DEFAULT_FALLBACK } val array = json.optJSONArray("stickers") - val list = mutableListOf<Sticker>() - for (i in 0 until (array?.length() ?: 0)) { - val o = array!!.optJSONObject(i) ?: continue - val id = o.optString("id") - if (id.isBlank()) continue - list += + val stickers = + (0 until (array?.length() ?: 0)).mapNotNull { i -> + val o = array!!.optJSONObject(i) ?: return@mapNotNull null + val sid = o.optString("id").takeIf { it.isNotBlank() } ?: return@mapNotNull null Sticker( - id = id, - glyph = o.optString("glyph").ifBlank { fallbackGlyph }, - name = o.optString("name").ifBlank { id }, + pack = id, + id = sid, + glyph = o.optString("glyph").ifBlank { fallback }, + name = o.optString("name").ifBlank { sid }, + // Only a plain file name: a manifest must not point the + // loader anywhere outside its own pack's folder. + image = + o + .optString("image") + .takeIf { it.isNotBlank() && '/' !in it && '\\' !in it && it != ".." } + ?.let { "stickers/$id/$it" }, ) + } + return Pack(id, json.optString("name").ifBlank { id }, fallback, stickers) + } + + /** Core first, then the art, then any other pack by id; one copy of each. */ + fun ordered(packs: List<Pack>): List<Pack> { + val unique = packs.distinctBy { it.id } + val known = PICKER_ORDER.mapNotNull { id -> unique.firstOrNull { it.id == id } } + return known + unique.filter { it.id !in PICKER_ORDER }.sortedBy { it.id } + } + + fun resolve( + packs: List<Pack>, + pack: String, + id: String, + ): Resolved { + val p = packs.firstOrNull { it.id == pack } + val s = p?.stickers?.firstOrNull { it.id == id } + return when { + s != null -> Resolved(s.image, s.glyph, s.name) + p != null -> Resolved(null, p.fallback, "Sticker") + else -> Resolved(null, packs.firstOrNull { it.id == CORE }?.fallback ?: DEFAULT_FALLBACK, "Sticker") } - order = list - loaded = list.associateBy { it.id } } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt index 3ffc714..cb81745 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt @@ -173,7 +173,7 @@ private fun StorySetComposerDialog( Text( pluralStringResource(R.plurals.social_story_set_summary, items.size, items.size), fontSize = 12.sp, - color = Palette.SecondaryText, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.testTag("story-set-summary"), ) androidx.compose.material3.OutlinedTextField( @@ -303,10 +303,23 @@ private fun StoryCard( // The deadline, in the slot the kit gives a description, and // §6.23's count beside it: a card that silently stands for // five pictures is a card that loses four. + // §6.15: a story whose words changed says so. + val edited = if (story.editedAt != null) " · Edited" else "" Text( - if (setCount > 1) pluralStringResource(R.plurals.social_story_remaining_with_count, setCount, remaining, setCount) else remaining, + ( + if (setCount > 1) { + pluralStringResource( + R.plurals.social_story_remaining_with_count, + setCount, + remaining, + setCount, + ) + } else { + remaining + } + ) + edited, fontSize = 12.sp, - color = Palette.SecondaryText, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.testTag("story-set-count-${story.id}"), ) } @@ -326,39 +339,32 @@ private fun StoryCard( Spacer(Modifier.height(Space.lg)) - // The image. 375x320 in the kit; an aspect ratio here, because the kit - // is drawn on one canvas and this has to hold on every phone. - Box( - modifier = - Modifier - .fillMaxWidth() - .height(240.dp) - .clip(RoundedCornerShape(Radius.small)) - .background(Palette.SurfaceAlt), - contentAlignment = Alignment.Center, - ) { - // The card's own image, cropped to FILL the box, not the chat - // bubble's 260dp thumbnail, which floated small on a full-width - // backboard and read as a broken layout. §6.13, an embedded live - // stream. - val stream = story.streamUrl - val streamHost = story.streamHost - if (stream != null && streamHost != null && + // DESIGN item 27: the whole picture, never cut. The card takes the + // picture's own shape between a floor and a ceiling (`StoryFrame`), + // with plain bars past either. §6.13, an embedded live stream, sits in + // the frame of its poster. + val stream = story.streamUrl + val streamHost = story.streamHost + val ref = story.ref + when { + stream != null && streamHost != null && io.flamenet.e2e.StreamDisclosure - .acceptable(stream) - ) { - StoryStreamGate(stream, streamHost) - } else { - val ref = story.ref - when { - // Words alone, set large on a tinted band, the way every - // other messenger draws one. - ref == null -> TextStoryCard(story.caption) + .acceptable(stream) -> { + StoryPhotoFrame(null, ref?.width ?: 0, ref?.height ?: 0) { StoryStreamGate(stream, streamHost) } + } - ref.mime.startsWith("video/") -> StoryVideoCard(ref, model) + // Words alone, set large on a tinted band, the way every other + // messenger draws one. + ref == null -> { + TextStoryCard(story.caption) + } - else -> StoryImage(ref, model) - } + ref.mime.startsWith("video/") -> { + StoryPhotoFrame(null, ref.width, ref.height) { StoryVideoCard(ref, model) } + } + + else -> { + StoryImage(ref, model) } } @@ -398,10 +404,12 @@ private fun StoryActions( modifier = Modifier.testTag("story-tally"), ) { tally.entries.sortedBy { it.key }.forEach { (emoji, count) -> - Text("$emoji $count", fontSize = 13.sp, color = Palette.SecondaryText) + Text("$emoji $count", fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } } + Spacer(Modifier.height(Space.sm)) } + MyStoryControls(story, model) return } @@ -447,7 +455,7 @@ private fun StoryActions( stringResource(R.string.common_mute), fontSize = 13.sp, maxLines = 1, - color = Palette.SecondaryText, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.clickable { muting = true }.testTag("story-mute"), ) // Reporting a story, which Android could not do. iOS has had it since @@ -459,7 +467,7 @@ private fun StoryActions( stringResource(R.string.social_story_report), fontSize = 13.sp, maxLines = 1, - color = Palette.SecondaryText, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier .clickable { reporting = true } @@ -546,6 +554,161 @@ private fun StoryActions( } } +/** What the stories screens say about taking one down or changing its words. */ +object StoryWords { + /** DESIGN item 25, word for word, before a story comes down. */ + const val TAKE_DOWN_WARNING = + "This removes it from your friends' phones when their app gets the message. " + + "Anyone who already saw it may have kept a copy, and older versions of the app " + + "keep it until it expires." + + private const val TODAYS_AUDIENCE = + "It went to your story audience as it is now, since this story was posted before " + + "the app kept who it went to." + + /** After a take-down: done, anyone the message has not reached, and which audience was used. */ + fun takenDown(change: Session.StoryChange): String { + val total = change.reached + change.missed + val reach = + if (change.missed == 0) { + "Taken down." + } else { + "Taken down here. ${change.missed} of $total could not be reached right now, so " + + "their copy stays until it expires." + } + return if (change.asPosted) reach else "$reach $TODAYS_AUDIENCE" + } + + /** Whether [draft] may be sent as a story's new words: never empty, and not what it says already. */ + fun canSave( + draft: String, + current: String, + ): Boolean = draft.isNotBlank() && draft.trim() != current + + /** After an edit: nothing to say unless somebody still sees the old words. */ + fun edited(change: Session.StoryChange): String? { + val total = change.reached + change.missed + val reach = + if (change.missed == 0) { + null + } else { + "Changed here. ${change.missed} of $total could not be reached right now, so they " + + "still see the old words." + } + return if (change.asPosted) reach else listOfNotNull(reach, TODAYS_AUDIENCE).joinToString(" ") + } +} + +/** + * My own story: change its words, or take it down for everyone it went to + * (§6.15 for stories, §6.35), in one row. + */ +@Composable +private fun MyStoryControls( + story: Vault.Story, + model: MessengerViewModel, +) { + var editing by remember(story.id) { mutableStateOf(false) } + var takingDown by remember(story.id) { mutableStateOf(false) } + Row(horizontalArrangement = Arrangement.spacedBy(Space.xs), modifier = Modifier.fillMaxWidth()) { + KitSecondaryButton( + text = if (story.textOnly) "Edit words" else "Edit caption", + onClick = { editing = true }, + modifier = Modifier.weight(1f), + tag = "story-edit", + ) + KitSecondaryButton( + text = "Take down", + onClick = { takingDown = true }, + modifier = Modifier.weight(1f), + tag = "story-takedown", + danger = true, + ) + } + + if (takingDown) { + AlertDialog( + onDismissRequest = { takingDown = false }, + title = { Text(if (story.set != null) "Take down this story and all its pictures?" else "Take down this story?") }, + text = { Text(StoryWords.TAKE_DOWN_WARNING, fontSize = 14.sp, modifier = Modifier.testTag("story-takedown-warning")) }, + confirmButton = { + TextButton( + onClick = { + takingDown = false + model.takeDownStory(story) + }, + modifier = Modifier.testTag("story-takedown-confirm"), + ) { Text("Take down", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { TextButton(onClick = { takingDown = false }) { Text("Cancel") } }, + ) + } + + if (editing) { + var draft by remember(story.id) { mutableStateOf(story.caption) } + AlertDialog( + onDismissRequest = { editing = false }, + title = { Text(if (story.textOnly) "Edit your story" else "Edit the caption") }, + text = { + StoryCaptionEditor( + draft = draft, + onDraft = { draft = it }, + onTakeDownInstead = { + editing = false + takingDown = true + }, + ) + }, + confirmButton = { + TextButton( + onClick = { + editing = false + model.editStoryCaption(story, draft.trim()) + }, + enabled = StoryWords.canSave(draft, story.caption), + modifier = Modifier.testTag("story-edit-save"), + ) { Text("Save") } + }, + dismissButton = { TextButton(onClick = { editing = false }) { Text("Cancel") } }, + ) + } +} + +/** + * The words being edited, and what an edit cannot do: leave a story empty + * (§6.15). Taking it down is offered instead. + */ +@Composable +internal fun StoryCaptionEditor( + draft: String, + onDraft: (String) -> Unit, + onTakeDownInstead: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) { + OutlinedTextField( + value = draft, + onValueChange = onDraft, + modifier = Modifier.fillMaxWidth().testTag("story-edit-field"), + ) + Text( + "Everyone who has it sees that it was edited.", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (draft.isBlank()) { + Text( + "An edit cannot leave it empty. To remove it, take it down.", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag("story-edit-empty"), + ) + TextButton(onClick = onTakeDownInstead, modifier = Modifier.testTag("story-edit-takedown")) { + Text("Take down instead") + } + } + } +} + /** * Caption + a deliberate Post for a picked story image, at the shell level so * whichever screen picked it (the strip or the Stories tab) gets the same @@ -594,25 +757,7 @@ fun StoryComposerDialog( title = { Text(stringResource(R.string.social_new_story)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) { - image?.let { - androidx.compose.foundation.layout.Box(contentAlignment = Alignment.Center) { - androidx.compose.foundation.Image( - bitmap = it, - contentDescription = stringResource(R.string.social_story_preview_desc), - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(Radius.small)), - ) - if (draft.duration > 0) { - Text( - stringResource(R.string.social_story_video_seconds, draft.duration.toInt()), - fontSize = 22.sp, - color = androidx.compose.ui.graphics.Color.White, - ) - } - } - } + image?.let { StoryComposerPreview(it, draft.width, draft.height, draft.duration) } androidx.compose.material3.OutlinedTextField( value = caption.value, onValueChange = { caption.value = it }, @@ -640,7 +785,7 @@ fun StoryComposerDialog( fontSize = 12.sp, color = if (streamOk) { - Palette.SecondaryText + MaterialTheme.colorScheme.onSurfaceVariant } else { MaterialTheme.colorScheme.error }, @@ -674,7 +819,7 @@ fun StoryComposerDialog( Text( stringResource(R.string.social_story_group_note), fontSize = 12.sp, - color = Palette.SecondaryText, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { Row(horizontalArrangement = Arrangement.spacedBy(Space.xs)) { @@ -729,7 +874,7 @@ fun StoryComposerDialog( } }, fontSize = 12.sp, - color = Palette.SecondaryText, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -757,6 +902,29 @@ fun StoryComposerDialog( ) } +/** + * The picked picture as friends will see it in their feed (DESIGN item 27): + * the same shape and the same bars as the feed card, scaled to the composer. + */ +@Composable +internal fun StoryComposerPreview( + image: androidx.compose.ui.graphics.ImageBitmap, + width: Int, + height: Int, + durationSeconds: Double, +) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.testTag("story-preview")) { + StoryPhotoFrame(image, width, height) + if (durationSeconds > 0) { + Text( + "▶ ${durationSeconds.toInt()}s", + fontSize = 22.sp, + color = androidx.compose.ui.graphics.Color.White, + ) + } + } +} + /** * The story cards themselves, everyone's stories, or one honest message when * there are none. @@ -780,14 +948,14 @@ fun ColumnScope.StoriesCards( Icon( KitIcon.Stories, contentDescription = null, - tint = Palette.SecondaryText, + tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(Space.xxl), ) Text(stringResource(R.string.social_stories_empty_title), fontSize = 16.sp, fontWeight = FontWeight.Medium) Text( stringResource(R.string.social_stories_empty_body), fontSize = 14.sp, - color = Palette.SecondaryText, + color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = androidx.compose.ui.text.style.TextAlign.Center, ) } @@ -842,12 +1010,16 @@ private fun StoryVideoCard( Text( stringResource(R.string.social_video_unavailable), fontSize = 14.sp, - color = Palette.SecondaryText, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else -> { - Text(stringResource(R.string.common_loading), fontSize = 14.sp, color = Palette.SecondaryText) + Text( + stringResource(R.string.common_loading), + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } @@ -873,27 +1045,22 @@ private fun StoryImage( if (bitmap.value == null) failed.value = true } val loaded = bitmap.value - when { - loaded != null -> { - androidx.compose.foundation.Image( - bitmap = loaded.asImageBitmap(), - contentDescription = stringResource(R.string.social_story_image_desc), - contentScale = androidx.compose.ui.layout.ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } - - failed.value -> { - Text( - stringResource(R.string.social_image_unavailable), - fontSize = 14.sp, - color = Palette.SecondaryText, - ) - } - - else -> { - Text(stringResource(R.string.common_loading), fontSize = 14.sp, color = Palette.SecondaryText) - } + // The decoded picture's own shape when there is one: the envelope's size + // is the sender's claim, the pixels are not. + StoryPhotoFrame( + bitmap = loaded?.asImageBitmap(), + photoWidth = loaded?.width ?: ref.width, + photoHeight = loaded?.height ?: ref.height, + ) { + Text( + if (failed.value) { + stringResource(R.string.social_image_unavailable) + } else { + stringResource(R.string.common_loading) + }, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoryFrame.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoryFrame.kt new file mode 100644 index 0000000..eb4db2f --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoryFrame.kt @@ -0,0 +1,119 @@ +package io.flamenet.messenger.app + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import io.flamenet.messenger.R + +/** + * How a story's photo sits in its feed card (DESIGN item 27): all of it, + * never cut. The card takes the photo's own shape between two wide and one + * high, and four wide and five high, and is never taller than 560dp; past + * either, the photo is fitted inside and plain bars fill the rest, beside a + * tall photo or above and below a wide one. The iPhone uses the same numbers. + */ +object StoryFrame { + /** The widest a card gets: twice as wide as it is tall. */ + const val MIN_TALL = 0.5f + + /** The tallest: four wide by five high. */ + const val MAX_TALL = 1.25f + + /** And never taller than this, for a tablet or a phone held sideways. */ + const val MAX_HEIGHT_DP = 560f + + /** A card, and the photo fitted inside it, in dp. */ + data class Frame( + val width: Float, + val height: Float, + val photoWidth: Float, + val photoHeight: Float, + ) { + /** Bars to the left and right: the photo is narrower than the card. */ + val barsBeside: Boolean get() = width - photoWidth > 0.5f + + /** Bars above and below: the photo is wider than the card's shape. */ + val barsAbove: Boolean get() = height - photoHeight > 0.5f + } + + /** + * The card for a photo of [photoWidth] by [photoHeight] pixels shown + * [width] dp wide. Only the photo's shape matters, never its pixel count, + * and a photo whose size is not known is treated as square. + */ + fun frame( + width: Float, + photoWidth: Int, + photoHeight: Int, + ): Frame { + val tall = if (photoWidth > 0 && photoHeight > 0) photoHeight.toFloat() / photoWidth else 1f + val height = minOf(width * tall.coerceIn(MIN_TALL, MAX_TALL), MAX_HEIGHT_DP) + // As large as fits in width by height at the photo's own shape. + val fitted = minOf(width, height / tall) + return Frame(width, height, fitted, fitted * tall) + } + + /** The width of a card in the stories feed, which pads the screen on both sides. */ + @Composable + fun feedCardWidthDp(): Float = LocalConfiguration.current.screenWidthDp - 2 * Space.md.value + + /** The card for [photoWidth] by [photoHeight] as the feed lays it out on this phone. */ + @Composable + fun inFeed( + photoWidth: Int, + photoHeight: Int, + ): Frame = frame(feedCardWidthDp(), photoWidth, photoHeight) +} + +/** + * A story's picture in the shape its feed card has on this phone, scaled to + * whatever width it is given, so the composer shows exactly what friends will + * see. [bitmap] is fitted whole; the bars take the theme's quiet surface. + */ +@Composable +fun StoryPhotoFrame( + bitmap: ImageBitmap?, + photoWidth: Int, + photoHeight: Int, + modifier: Modifier = Modifier, + /** Drawn in place of the picture while it loads, fails, or is a video. */ + instead: (@Composable BoxScope.() -> Unit)? = null, +) { + val frame = StoryFrame.inFeed(photoWidth, photoHeight) + Box( + modifier = + modifier + .fillMaxWidth() + .aspectRatio(frame.width / frame.height) + .clip(RoundedCornerShape(Radius.small)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .testTag("story-photo-frame"), + contentAlignment = Alignment.Center, + ) { + if (bitmap != null) { + Image( + bitmap = bitmap, + contentDescription = stringResource(R.string.social_story_image_desc), + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize().testTag("story-photo"), + ) + } else { + instead?.invoke(this) + } + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/TodayData.kt b/app/src/main/kotlin/io/flamenet/messenger/app/TodayData.kt new file mode 100644 index 0000000..296a21f --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/TodayData.kt @@ -0,0 +1,38 @@ +package io.flamenet.messenger.app + +import android.content.Context +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.io.File + +/** + * What the Today tab keeps on this phone: the last good Today file and the + * cities picked for the weather. Public data and a preference, both kept out + * of backups like everything else, and both cleared on sign-out and by a wipe + * (DESIGN 32). + */ +object TodayData { + const val PREFS = "today" + const val KEY_CITIES = "weather.cities" + + /** Under `filesDir`. */ + const val CACHE_DIR = "today" + + private val _cleared = MutableStateFlow(0) + + /** Moves on every clear, so a screen holding the data in memory lets go of it too. */ + val cleared: StateFlow<Int> = _cleared.asStateFlow() + + /** + * Delete the cached file and the city picks. The count moves first, so a + * fetch still on its way sees the clear before the files go, and drops + * what it brings back. + */ + fun forget(context: Context) { + _cleared.update { it + 1 } + File(context.filesDir, CACHE_DIR).deleteRecursively() + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().commit() + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/TodayKeys.kt b/app/src/main/kotlin/io/flamenet/messenger/app/TodayKeys.kt new file mode 100644 index 0000000..c8b58e5 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/TodayKeys.kt @@ -0,0 +1,18 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.TodayFile + +/** + * The keys a Today file (SPEC §8.12) may be signed with, as base64 public keys. + */ +object TodayKeys { + /** + * FlameNet's production signing key, added at release when that key is + * made. Empty until then, so a release build trusts no Today file at all + * and the tab says the relay's page could not be checked. + */ + val PRODUCTION: List<String> = emptyList() + + /** Every key this build accepts: production, plus the dev key in a debug build only. */ + val trusted: TodayFile.Keys get() = TodayFile.Keys(PRODUCTION + TodayDevKey.KEYS) +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/TodayModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/TodayModel.kt new file mode 100644 index 0000000..3944c57 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/TodayModel.kt @@ -0,0 +1,150 @@ +package io.flamenet.messenger.app + +import android.app.Application +import android.content.Context +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import io.flamenet.client.RelayClient +import io.flamenet.client.TodayFeed +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +/** + * What the Today tab and the directory behind it draw (SPEC §8.12, DESIGN 5). + * + * The Today file comes from the home relay through [TodayFeed]. Everything + * the person chooses here, the cities and where they are in the directory, + * stays on the phone and is never sent anywhere. + */ +class TodayModel( + app: Application, +) : AndroidViewModel(app) { + data class Ui( + /** What the feed holds and how the last answer went; null until read. */ + val snapshot: TodayFeed.Snapshot? = null, + /** Asking the relay right now, with nothing held to show meanwhile. */ + val loading: Boolean = false, + /** The cities picked on this phone, by `TodayFile.City.key`. Never sent. */ + val cities: Set<String> = emptySet(), + /** The city picker is showing in place of the page. */ + val choosingCities: Boolean = false, + val directory: Directory? = null, + val directoryOpen: Boolean = false, + /** The category being browsed, or null for the top of the tree. */ + val category: String? = null, + val query: String = "", + ) + + private val prefs = app.getSharedPreferences(TodayData.PREFS, Context.MODE_PRIVATE) + private val _ui = MutableStateFlow(Ui(cities = savedCities())) + val ui: StateFlow<Ui> = _ui.asStateFlow() + + private var feed: TodayFeed? = null + + init { + // Sign-out and a wipe clear what is on disk (DESIGN 32); this lets go + // of what is in memory, so the next person to sign in starts empty. + val seen = TodayData.cleared.value + viewModelScope.launch { + TodayData.cleared.collect { now -> + if (now != seen) { + feed = null + // The picks went with it. + _ui.value = Ui(directory = _ui.value.directory) + } + } + } + } + + private fun savedCities(): Set<String> = prefs.getStringSet(TodayData.KEY_CITIES, null).orEmpty().toSet() + + /** + * The tab was opened, or the app came back to it. Shows what is held at + * once, and asks the relay when the hour is up. + */ + fun opened(baseUrl: String?) { + loadDirectory() + val f = baseUrl?.let(::feedFor) ?: return + // Sign-out or a wipe while this is out: nothing it brings back is + // shown (DESIGN 32), and the feed writes nothing back. + val asked = TodayData.cleared.value + viewModelScope.launch { + val held = withContext(Dispatchers.IO) { f.current() } + val due = withContext(Dispatchers.IO) { f.isDue() } + if (TodayData.cleared.value != asked) return@launch + _ui.update { it.copy(snapshot = held, loading = due && held.bundle == null) } + if (!due) return@launch + val fresh = withContext(Dispatchers.IO) { f.refresh() } + if (TodayData.cleared.value != asked) return@launch + _ui.update { it.copy(snapshot = fresh, loading = false) } + } + } + + private fun feedFor(baseUrl: String): TodayFeed { + feed?.takeIf { it.url == TodayFeed.urlFor(baseUrl) }?.let { return it } + // The app's ordinary client, TodayFeed's default, so a bridge in use + // carries this request like every other. + return TodayFeed( + baseUrl = baseUrl, + dir = File(getApplication<Application>().filesDir, TodayData.CACHE_DIR), + keys = TodayKeys.trusted, + userAgent = "FlamenetMessenger/${RelayClient.CLIENT_VERSION} (Android)", + epoch = { TodayData.cleared.value }, + ).also { feed = it } + } + + fun openCityPicker() = _ui.update { it.copy(choosingCities = true) } + + fun closeCityPicker() = _ui.update { it.copy(choosingCities = false) } + + /** Remember which cities to show. Stored on this phone only. */ + fun chooseCities(keys: Set<String>) { + prefs.edit().putStringSet(TodayData.KEY_CITIES, keys.toSet()).apply() + _ui.update { it.copy(cities = keys.toSet(), choosingCities = false) } + } + + private fun loadDirectory() { + if (_ui.value.directory != null) return + viewModelScope.launch { + val loaded = withContext(Dispatchers.IO) { runCatching { Directory.load(getApplication()) }.getOrNull() } + _ui.update { it.copy(directory = loaded) } + } + } + + fun openDirectory() { + loadDirectory() + _ui.update { it.copy(directoryOpen = true, category = null, query = "") } + } + + fun browse(category: String?) = _ui.update { it.copy(category = category, query = "") } + + fun search(query: String) = _ui.update { it.copy(query = query) } + + /** + * Back, from wherever the directory is: a search clears, a category goes + * to its parent, the top closes the directory. False when there was + * nothing to go back from. + */ + fun back(): Boolean { + val now = _ui.value + if (!now.directoryOpen) return false + when { + now.query.isNotEmpty() -> _ui.update { it.copy(query = "") } + now.category != null -> { + val parent = now.directory?.category(now.category)?.parent?.takeIf { it.isNotEmpty() } + _ui.update { it.copy(category = parent) } + } + else -> _ui.update { it.copy(directoryOpen = false) } + } + return true + } + + fun closeDirectory() = _ui.update { it.copy(directoryOpen = false, category = null, query = "") } + +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/TodayScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/TodayScreen.kt new file mode 100644 index 0000000..22ec106 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/TodayScreen.kt @@ -0,0 +1,496 @@ +package io.flamenet.messenger.app + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import io.flamenet.client.TodayFeed +import io.flamenet.client.TodayFile +import java.text.NumberFormat + +/** + * The Today tab (SPEC §8.12): one public file from the home relay, and the + * way into the small-web directory. + * + * The file is asked for when the tab opens and when the app comes back to it, + * at most once an hour, and the copy on disk is what shows in between. + */ +@Composable +internal fun ColumnScope.TodayScreen( + model: MessengerViewModel, + phase: MessengerViewModel.Phase.SignedIn, +) { + val today: TodayModel = viewModel() + val ui by today.ui.collectAsStateWithLifecycle() + val context = LocalContext.current + val baseUrl = remember(phase.relay) { model.homeRelayBaseUrl() } + + // Opening the tab, and coming back to the app while it shows. The + // observer is told ON_START as soon as it is added, which is the opening. + val owner = androidx.lifecycle.compose.LocalLifecycleOwner.current + DisposableEffect(owner, baseUrl) { + val observer = + androidx.lifecycle.LifecycleEventObserver { _, event -> + if (event == androidx.lifecycle.Lifecycle.Event.ON_START) today.opened(baseUrl) + } + owner.lifecycle.addObserver(observer) + onDispose { owner.lifecycle.removeObserver(observer) } + } + + if (ui.directoryOpen) { + DirectoryScreen(model, phase, today, ui) + return + } + + val cities = ui.snapshot?.bundle?.weather.orEmpty() + if (ui.choosingCities && cities.isNotEmpty()) { + BackHandler { today.closeCityPicker() } + CityPickerPage( + cities = cities, + chosen = ui.cities, + onDone = { today.chooseCities(it) }, + onCancel = { today.closeCityPicker() }, + ) + return + } + + var openProblem by remember { mutableStateOf<String?>(null) } + TodayPage( + ui = ui, + relay = phase.relay, + fahrenheit = TodayText.fahrenheit(androidx.core.text.util.LocalePreferences.getTemperatureUnit()), + problem = openProblem, + onOpen = { url -> openProblem = if (openLink(context, url)) null else NO_BROWSER }, + onChooseCities = { today.openCityPicker() }, + onBrowse = { today.openDirectory() }, + ) +} + +internal const val NO_BROWSER = "There's no browser on this phone to open that link." + +/** + * Open [url] in whatever the phone uses for web links. Only http and https, + * and only because somebody tapped it. False when nothing could open it. + */ +internal fun openLink( + context: Context, + url: String, +): Boolean { + if (!Directory.isWebAddress(url)) return false + val view = Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE) + return runCatching { context.startActivity(view) }.isSuccess +} + +/** + * The page itself, from plain values, so it can be drawn without a relay. + */ +@Composable +internal fun ColumnScope.TodayPage( + ui: TodayModel.Ui, + relay: String, + fahrenheit: Boolean, + problem: String? = null, + /** For the forecast's "today" in each city; a test pins it. */ + now: java.time.Instant = java.time.Instant.now(), + onOpen: (String) -> Unit, + onChooseCities: () -> Unit, + onBrowse: () -> Unit, +) { + val snapshot = ui.snapshot + val bundle = snapshot?.bundle?.takeIf { snapshot.status != TodayFeed.Status.NOT_OFFERED } + PortalHeader( + title = "Today", + subtitle = snapshot?.takeIf { bundle != null }?.let { TodayText.subtitle(it) }, + ) + + val status = + when { + snapshot == null || (ui.loading && bundle == null) -> "Getting today's page from your server." + else -> TodayText.status(snapshot) + } + if (status != null || problem != null) { + PortalBody { + status?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.testTag("today-status")) + } + problem?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + } + } + + if (bundle != null) { + if (bundle.headlines.isNotEmpty()) { + PortalBand("Headlines") + for (h in bundle.headlines) { + LinkRow(h.title, h.source.ifEmpty { null }, h.url, onOpen, "today-headline") + } + } + + if (bundle.markets.isNotEmpty()) { + PortalBand("Markets") + PortalBody { + Text( + TodayText.MARKETS_CAVEAT, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag("markets-caveat"), + ) + } + for (m in bundle.markets) MarketRow(m) + } + + if (bundle.weather.isNotEmpty()) { + PortalBand("Weather") + WeatherSection(bundle.weather, ui.cities, fahrenheit, now, onChooseCities) + } + + bundle.site?.let { site -> + PortalBand("Site of the day") + LinkRow( + title = site.title, + detail = site.description, + url = site.url, + onOpen = onOpen, + tag = "today-site", + // The file's own word on who wrote the line above it. + label = site.description?.let { bundle.siteNote }, + ) + } + + bundle.post?.let { post -> + PortalBand("From the FlameNet blog") + LinkRow( + title = post.title, + detail = listOfNotNull(post.description, post.by?.let { "By $it" }).joinToString("\n").ifEmpty { null }, + url = post.url, + onOpen = onOpen, + tag = "today-post", + ) + } + } + + PortalBand("The small web") + PortalBody { + val count = ui.directory?.sites?.size + Text( + if (count != null) { + "${NumberFormat.getIntegerInstance().format(count)} sites from the FlameNet directory, " + + "kept on this phone. Browse and search them, even offline." + } else { + "Sites from the FlameNet directory, kept on this phone. Browse and search them, even offline." + }, + style = MaterialTheme.typography.bodyMedium, + ) + KitPrimaryButton( + text = "Browse the small web", + onClick = onBrowse, + modifier = Modifier.fillMaxWidth(), + tag = "today-directory", + ) + } + + if (bundle != null) { + PortalBand() + PortalBody(spacing = Space.xs) { + for (note in bundle.notes) { + Text(note, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text( + TodayText.sameForEveryone(relay), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag("today-same-for-everyone"), + ) + } + } +} + +/** + * A link a person can open: its words, then the host it goes to, always, + * so a title cannot pass for a destination. + */ +@Composable +internal fun LinkRow( + title: String, + detail: String?, + url: String, + onOpen: (String) -> Unit, + tag: String, + label: String? = null, + trailing: (@Composable () -> Unit)? = null, +) { + val host = TodayText.host(url) + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .clickable(onClickLabel = "Open in your browser") { onOpen(url) } + .semantics { contentDescription = "$title, opens $host" } + .padding(start = Space.lg, end = if (trailing != null) Space.xs else Space.lg, top = Space.md, bottom = Space.md) + .testTag(tag), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + detail?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + } + label?.let { + Text(it, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 3) + } + Text(host, fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + trailing?.invoke() + } + Box( + Modifier + .fillMaxWidth() + .height(Kit.hairline) + .background(MaterialTheme.colorScheme.outlineVariant), + ) + } +} + +@Composable +private fun MarketRow(m: TodayFile.Market) { + val tint = + when { + m.change.startsWith("+") -> if (LocalPortalDark.current) Palette.NightSuccess else Palette.Teal + m.change.startsWith("-") || m.change.startsWith("\u2212") -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurface + } + PortalRow(height = 52.dp, modifier = Modifier.testTag("today-market")) { + Text( + m.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Column(horizontalAlignment = Alignment.End) { + Text(m.last, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + if (m.change.isNotEmpty() || m.percent.isNotEmpty()) { + Text( + listOf(m.change, TodayText.percent(m.percent).let { if (it.isEmpty()) "" else "($it)" }) + .filter { it.isNotEmpty() } + .joinToString(" "), + fontSize = 12.sp, + color = tint, + ) + } + } + } +} + +@Composable +private fun WeatherSection( + cities: List<TodayFile.City>, + chosen: Set<String>, + fahrenheit: Boolean, + now: java.time.Instant, + onChooseCities: () -> Unit, +) { + val shown = cities.filter { it.key in chosen } + if (shown.isEmpty()) { + PortalBody { + Text( + if (chosen.isEmpty()) { + "Pick the cities you'd like to see. Your choice stays on this phone." + } else { + "None of the cities you picked are in today's page." + }, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag("weather-prompt"), + ) + KitSecondaryButton( + text = "Choose cities", + onClick = onChooseCities, + modifier = Modifier.fillMaxWidth(), + tag = "weather-choose", + ) + } + return + } + for (city in shown) CityBlock(city, fahrenheit, now) + PortalBody { + KitSecondaryButton( + text = "Change cities", + onClick = onChooseCities, + modifier = Modifier.fillMaxWidth(), + tag = "weather-choose", + ) + } +} + +@Composable +private fun CityBlock( + city: TodayFile.City, + fahrenheit: Boolean, + now: java.time.Instant, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = Space.lg, vertical = Space.md) + .testTag("weather-city"), + verticalArrangement = Arrangement.spacedBy(Space.xs), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + city.name, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + city.now?.let { current -> + Text( + listOfNotNull(TodayText.degrees(current.celsius, fahrenheit), TodayText.sky(current.sky)).joinToString(", "), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + val days = TodayText.ahead(city, now) + if (days.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) { + for (day in days) { + Column( + modifier = + Modifier.weight(1f).semantics { + contentDescription = + "${TodayText.weekday(day.date)}, high ${TodayText.degrees(day.high, fahrenheit)}, " + + "low ${TodayText.degrees(day.low, fahrenheit)}" + + (TodayText.sky(day.sky)?.let { ", ${it.lowercase()}" } ?: "") + }, + ) { + Text(TodayText.weekday(day.date), fontSize = 12.sp, fontWeight = FontWeight.Bold) + Text( + "${TodayText.bare(day.high, fahrenheit)} ${TodayText.bare(day.low, fahrenheit)}", + fontSize = 12.sp, + ) + TodayText.sky(day.sky)?.let { + Text(it, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2) + } + } + } + // Fewer than five days left keeps the columns the same width. + repeat(TodayFile.MAX_DAYS - days.size) { Spacer(Modifier.weight(1f)) } + } + } + } + Box( + Modifier + .fillMaxWidth() + .height(Kit.hairline) + .background(MaterialTheme.colorScheme.outlineVariant), + ) +} + +/** + * Which cities to show, from the file's own list. A page of the tab rather + * than a dialog, so the filter field takes focus only when it is tapped. The + * choice is kept on this phone and goes nowhere: every phone gets every city. + */ +@Composable +internal fun ColumnScope.CityPickerPage( + cities: List<TodayFile.City>, + chosen: Set<String>, + onDone: (Set<String>) -> Unit, + onCancel: () -> Unit, +) { + var picked by remember { mutableStateOf(chosen) } + var filter by remember { mutableStateOf("") } + PortalHeader(title = "Cities", subtitle = "For the weather on Today") { + PortalHeaderIcon( + icon = KitIcon.Close, + contentDescription = "Cancel", + onClick = onCancel, + modifier = Modifier.testTag("cities-cancel"), + ) + } + PortalBody { + Text( + "Your choice stays on this phone. Every phone gets the same list.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = filter, + onValueChange = { filter = it }, + placeholder = { Text("Find a city", fontSize = 14.sp) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("city-filter"), + ) + KitPrimaryButton( + text = "Done", + onClick = { onDone(picked) }, + modifier = Modifier.fillMaxWidth(), + tag = "cities-done", + ) + } + val folded = Directory.fold(filter) + for (city in cities) { + if (folded.isNotEmpty() && folded !in Directory.fold(city.name)) continue + val on = city.key in picked + PortalRow( + modifier = + Modifier + .clickable { picked = if (on) picked - city.key else picked + city.key } + .testTag("city-${city.key}"), + ) { + Checkbox(checked = on, onCheckedChange = null) + Text(city.name, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + Text(city.country, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/TodayText.kt b/app/src/main/kotlin/io/flamenet/messenger/app/TodayText.kt new file mode 100644 index 0000000..f81f76a --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/TodayText.kt @@ -0,0 +1,151 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.TodayFeed +import io.flamenet.client.TodayFile +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.format.TextStyle +import java.util.Locale + +/** + * The words and figures on the Today tab, as plain functions of the file. + */ +object TodayText { + const val NOT_OFFERED = "Today's page is not offered by this relay." + const val MARKETS_CAVEAT = "Prices are delayed. Not advice." + + /** "Sunday, October 4, 2026", the day a file was made for, in the reader's language. */ + fun day( + date: String, + locale: Locale = Locale.getDefault(), + ): String = + runCatching { LocalDate.parse(date).format(DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)) } + .getOrDefault(date) + + /** "From Sunday, October 4, 2026", for a copy that is not the relay's latest answer. */ + fun from( + date: String, + locale: Locale = Locale.getDefault(), + ): String = "From " + day(date, locale) + + /** + * The date under the page's title: the plain day for what the relay is + * serving now, "From <day>" for the copy kept on the phone, as on the + * iPhone (DESIGN 4). Null with no page to show. + */ + fun subtitle( + snapshot: TodayFeed.Snapshot, + locale: Locale = Locale.getDefault(), + ): String? { + val bundle = snapshot.bundle ?: return null + return when (snapshot.status) { + TodayFeed.Status.NEVER, TodayFeed.Status.CURRENT -> day(bundle.date, locale) + else -> from(bundle.date, locale) + } + } + + /** + * The line above the sections, or null when what is shown is what the + * relay is serving now. With a kept copy shown, the header says which day + * it is from and this line says only why. + */ + fun status( + snapshot: TodayFeed.Snapshot, + locale: Locale = Locale.getDefault(), + ): String? { + val held = snapshot.bundle != null + return when (snapshot.status) { + TodayFeed.Status.NEVER, TodayFeed.Status.CURRENT -> { + null + } + + TodayFeed.Status.NOT_OFFERED -> { + NOT_OFFERED + } + + TodayFeed.Status.UNREACHABLE -> { + if (held) "Couldn't reach your server just now." else "Couldn't reach your server. Today's page will be here once it can be fetched." + } + + TodayFeed.Status.FAILED -> { + if (held) "Your server couldn't send a newer page just now." else "Your server couldn't send today's page just now." + } + + TodayFeed.Status.NOT_READY -> { + if (held) "Your server doesn't have a newer page yet." else "Your server doesn't have today's page yet." + } + + TodayFeed.Status.REFUSED -> { + if (held) "The newest page from your server could not be checked, so it isn't shown." else "Your server sent a page that could not be checked, so it isn't shown." + } + } + } + + /** + * Whether the person reads Fahrenheit: their own regional setting if they + * made one, else what their region uses. [unit] is what + * `LocalePreferences.getTemperatureUnit()` answers. + */ + fun fahrenheit(unit: String): Boolean = unit == "fahrenhe" + + /** Whole degrees in the person's unit, with the unit. */ + fun degrees( + celsius: Int, + fahrenheit: Boolean, + ): String = if (fahrenheit) "${Math.round(celsius * 9.0 / 5.0 + 32.0)}°F" else "$celsius°C" + + /** The figure alone, where the unit is already on the line. */ + fun bare( + celsius: Int, + fahrenheit: Boolean, + ): String = if (fahrenheit) "${Math.round(celsius * 9.0 / 5.0 + 32.0)}°" else "$celsius°" + + fun sky(sky: TodayFile.Sky?): String? = + when (sky) { + TodayFile.Sky.CLEAR -> "Clear" + TodayFile.Sky.PARTLY -> "Partly cloudy" + TodayFile.Sky.CLOUDY -> "Cloudy" + TodayFile.Sky.FOG -> "Fog" + TodayFile.Sky.DRIZZLE -> "Drizzle" + TodayFile.Sky.RAIN -> "Rain" + TodayFile.Sky.SNOW -> "Snow" + TodayFile.Sky.STORM -> "Storms" + null -> null + } + + /** "Sun", in the reader's language. */ + fun weekday( + date: String, + locale: Locale = Locale.getDefault(), + ): String = + runCatching { LocalDate.parse(date).dayOfWeek.getDisplayName(TextStyle.SHORT, locale) } + .getOrDefault(date) + + /** + * The days still ahead in the city itself, today included. A file made + * in another time zone, or read a day late, starts with days already gone. + * A zone this phone does not know shows the days as given (DESIGN 42). + */ + fun ahead( + city: TodayFile.City, + now: Instant = Instant.now(), + ): List<TodayFile.Day> { + val zone = runCatching { ZoneId.of(city.zone) }.getOrNull() ?: return city.days + val today = now.atZone(zone).toLocalDate() + return city.days.filter { day -> runCatching { !LocalDate.parse(day.date).isBefore(today) }.getOrDefault(false) } + } + + /** "+0.18" as the file writes it, shown as "+0.18%". No arithmetic. */ + fun percent(pct: String): String = if (pct.isEmpty() || pct.endsWith("%")) pct else "$pct%" + + /** The host a link goes to, shown beside it. */ + fun host(url: String): String = runCatching { java.net.URI(url).host }.getOrNull()?.lowercase(Locale.ROOT) ?: url + + /** What the foot of the page says about the file itself. */ + fun sameForEveryone(relay: String): String = + "Everyone on $relay gets this same page. Fetching it sends nothing about you, " + + "and the cities you pick stay on this phone." +} diff --git a/app/src/main/res/values-es/strings_chat.xml b/app/src/main/res/values-es/strings_chat.xml index e2b041a..3916874 100644 --- a/app/src/main/res/values-es/strings_chat.xml +++ b/app/src/main/res/values-es/strings_chat.xml @@ -306,4 +306,6 @@ <string name="chat_forward_to">Reenviar a</string> <string name="chat_forward_note">Esto envía solo el texto. Quien lo reciba ve que se reenvió, y lo ve como enviado por ti.</string> <string name="chat_band_parental_controls">Control parental</string> + <string name="chat_forward_note_card">Esto envía la tarjeta sola y abierta, sin importar hasta qué día estaba sellada. Quien la reciba ve que se reenvió, y la ve como enviada por ti.</string> + <string name="chat_forward_note_event">Esto envía una copia del evento contigo como organizador. Las respuestas de hasta ahora se quedan aquí. Quien lo reciba ve que se reenvió.</string> </resources> diff --git a/app/src/main/res/values-es/strings_main.xml b/app/src/main/res/values-es/strings_main.xml index 3ca66ea..6e82a11 100644 --- a/app/src/main/res/values-es/strings_main.xml +++ b/app/src/main/res/values-es/strings_main.xml @@ -139,4 +139,8 @@ <string name="main_identity_checking">Comprobando qué usa este servidor…</string> <string name="main_identity_shared">Una sola cuenta de Flamenet, la misma en todos los servidores de Flamenet.</string> <string name="main_identity_local">Este servidor tiene sus propias cuentas.</string> + <string name="main_today">Hoy</string> + <string name="main_files_card">Tarjeta</string> + <string name="main_files_event">Evento</string> + <string name="main_files_game">Juego</string> </resources> diff --git a/app/src/main/res/values-es/strings_settings.xml b/app/src/main/res/values-es/strings_settings.xml index 165e577..4342e8f 100644 --- a/app/src/main/res/values-es/strings_settings.xml +++ b/app/src/main/res/values-es/strings_settings.xml @@ -101,7 +101,7 @@ <string name="set_network_paste_hint">Pega la línea del puente que te dieron. Es una sola línea larga que empieza con obfs4; cópiala completa, incluida la parte después de cert=.</string> <string name="set_network_explanation">Algunas redes bloquean Flamenet por completo, o lo reconocen y lo hacen más lento. Un puente es una máquina en otra parte con la que tu app se comunica en su lugar, de una forma que no se parece a nada en particular: ni a Flamenet, ni a una videollamada, ni a una página web. El puente pasa tu tráfico a tu servidor.\n\nHay dos cosas que conviene dejar claras. Tu servidor ve exactamente lo mismo que veía antes, así que esto no le oculta nada a quien lo administra. Y quien administra el puente puede ver que te conectas a él, aunque no lo que dices: tus mensajes siguen cifrados de extremo a extremo todo el camino, como siempre.\n\nSi Flamenet ya funciona donde estás, no necesitas esto.</string> <string name="set_parental_title">Control parental</string> - <string name="set_parental_intro">Un padre o una madre pone un PIN en este teléfono. Mientras la supervisión está activada, agregar contactos pide el PIN, y se pueden desactivar las historias y las llamadas, todo aplicado en este dispositivo. Nada sobre la supervisión se envía a ningún servidor. Úsalo junto con Family Link, que controla el teléfono en sí.</string> + <string name="set_parental_intro">Un padre o una madre pone un PIN en este teléfono. Mientras la supervisión está activada, agregar contactos pide el PIN, se pueden desactivar las historias y las llamadas, y la pestaña Hoy y su directorio de sitios web quedan ocultos, todo aplicado en este dispositivo. Nada sobre la supervisión se envía a ningún servidor. Úsalo junto con Family Link, que controla el teléfono en sí.</string> <string name="set_parental_pin_placeholder">PIN parental (al menos 4 dígitos)</string> <string name="set_parental_repeat_pin">Repite el PIN</string> <string name="set_parental_turn_on">Activar supervisión</string> @@ -111,7 +111,7 @@ <string name="set_parental_gate_contacts">Pedir el PIN para agregar un contacto</string> <string name="set_parental_gate_stories">Ocultar historias</string> <string name="set_parental_gate_calls">Desactivar llamadas</string> - <string name="set_parental_calls_off_note">Con las llamadas desactivadas, este teléfono no hace llamadas ni suena, y quien llama recibe la señal de ocupado.</string> + <string name="set_parental_calls_off_note">Con las llamadas desactivadas, este teléfono no hace llamadas ni suena, y quien llama recibe la señal de ocupado. La pestaña Hoy y su directorio de sitios web siguen ocultos mientras la supervisión está activada.</string> <string name="set_parental_turn_off">Desactivar supervisión</string> <string name="set_calls_title">Llamadas</string> <string name="set_calls_new_call">Llamada nueva</string> diff --git a/app/src/main/res/values-es/strings_social.xml b/app/src/main/res/values-es/strings_social.xml index 2a7e4c5..efae6b9 100644 --- a/app/src/main/res/values-es/strings_social.xml +++ b/app/src/main/res/values-es/strings_social.xml @@ -169,4 +169,5 @@ <string name="social_unsealed_body">La clave de entrega de %1$s estaba desactualizada, así que este mensaje no se pudo enviar sellado. Está cifrado de todos modos; lo que cambió es que el servidor sabe que lo enviaste tú. Normalmente se arregla solo cuando te responde.</string> <string name="social_address_changed_title">Tu dirección cambió</string> <string name="social_address_changed_body">Este dispositivo antes era %1$s. Al iniciar sesión hace un momento salió una dirección distinta. Eso pasa si cambiaste tu frase de acceso. Si no la cambiaste, el servidor de cuentas le dio a este dispositivo una identidad distinta, y quienes tenían tu dirección anterior ya no pueden contactarte.</string> + <string name="social_address_qr_hello_desc">Tu enlace de saludo como código QR</string> </resources> diff --git a/app/src/main/res/values/strings_chat.xml b/app/src/main/res/values/strings_chat.xml index 4cb9af2..856dbed 100644 --- a/app/src/main/res/values/strings_chat.xml +++ b/app/src/main/res/values/strings_chat.xml @@ -297,4 +297,6 @@ <string name="chat_forward_to">Forward to</string> <string name="chat_forward_note">This sends the words on their own. Whoever receives it sees that it was forwarded, and sees it from you.</string> <string name="chat_band_parental_controls">Parental controls</string> + <string name="chat_forward_note_card">This sends the card on its own, open, whatever day it was sealed until. Whoever receives it sees that it was forwarded, and sees it from you.</string> + <string name="chat_forward_note_event">This sends a copy of the event with you as its organizer. The answers so far stay here. Whoever receives it sees that it was forwarded.</string> </resources> diff --git a/app/src/main/res/values/strings_main.xml b/app/src/main/res/values/strings_main.xml index 0b87198..8707b31 100644 --- a/app/src/main/res/values/strings_main.xml +++ b/app/src/main/res/values/strings_main.xml @@ -151,4 +151,8 @@ <string name="main_identity_checking">Checking what this server uses…</string> <string name="main_identity_shared">One Flamenet account, the same one on every Flamenet relay.</string> <string name="main_identity_local">This server keeps its own accounts.</string> + <string name="main_today">Today</string> + <string name="main_files_card">Card</string> + <string name="main_files_event">Event</string> + <string name="main_files_game">Game</string> </resources> diff --git a/app/src/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml index 96175d0..d39d355 100644 --- a/app/src/main/res/values/strings_settings.xml +++ b/app/src/main/res/values/strings_settings.xml @@ -112,7 +112,7 @@ <!-- Settings > Parental controls (ParentalControlsSection.kt) --> <string name="set_parental_title">Parental controls</string> - <string name="set_parental_intro">A parent sets a PIN on this phone. While supervision is on, adding contacts asks for the PIN, and stories and calls can be switched off, all enforced on this device. Nothing about supervision is sent to any server. Use it alongside Family Link, which governs the phone itself.</string> + <string name="set_parental_intro">A parent sets a PIN on this phone. While supervision is on, adding contacts asks for the PIN, stories and calls can be switched off, and the Today tab and its directory of websites are hidden, all enforced on this device. Nothing about supervision is sent to any server. Use it alongside Family Link, which governs the phone itself.</string> <string name="set_parental_pin_placeholder">Parent PIN (at least 4 digits)</string> <string name="set_parental_repeat_pin">Repeat PIN</string> <string name="set_parental_turn_on">Turn on supervision</string> @@ -122,7 +122,7 @@ <string name="set_parental_gate_contacts">Ask the PIN to add a contact</string> <string name="set_parental_gate_stories">Hide stories</string> <string name="set_parental_gate_calls">Turn off calls</string> - <string name="set_parental_calls_off_note">With calls off, this phone neither places calls nor rings, a caller is answered busy.</string> + <string name="set_parental_calls_off_note">With calls off, this phone neither places calls nor rings, a caller is answered busy. The Today tab and its directory of websites stay hidden while supervision is on.</string> <string name="set_parental_turn_off">Turn off supervision</string> <!-- Home > Calls (CallsScreen.kt) --> diff --git a/app/src/main/res/values/strings_social.xml b/app/src/main/res/values/strings_social.xml index 7775429..366ff2f 100644 --- a/app/src/main/res/values/strings_social.xml +++ b/app/src/main/res/values/strings_social.xml @@ -197,4 +197,5 @@ <string name="social_address_changed_title">Your address changed</string> <!-- %1$s is the old address, fnid@relay. --> <string name="social_address_changed_body">This device used to be %1$s. Signing in just now produced a different address. That happens if you changed your passphrase. If you did not, the account server has given this device a different identity, and people who had your old address can no longer reach you.</string> + <string name="social_address_qr_hello_desc">Your hello link as a QR code</string> </resources> diff --git a/app/src/release/kotlin/io/flamenet/messenger/app/TodayDevKey.kt b/app/src/release/kotlin/io/flamenet/messenger/app/TodayDevKey.kt new file mode 100644 index 0000000..02a8ad0 --- /dev/null +++ b/app/src/release/kotlin/io/flamenet/messenger/app/TodayDevKey.kt @@ -0,0 +1,9 @@ +package io.flamenet.messenger.app + +/** + * A release build trusts no dev key. The debug source set carries the one + * used for testing. + */ +internal object TodayDevKey { + val KEYS: List<String> = emptyList() +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/ASnoozedChatHoldsItsNotificationsTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/ASnoozedChatHoldsItsNotificationsTest.kt new file mode 100644 index 0000000..7b07720 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ASnoozedChatHoldsItsNotificationsTest.kt @@ -0,0 +1,68 @@ +package io.flamenet.messenger.app + +import io.flamenet.e2e.Vault +import io.flamenet.games.GameType +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A snoozed chat holds its notifications, an ordinary message's as well as a + * game's (owner, 0.32), and what arrives still counts as unread. + */ +class ASnoozedChatHoldsItsNotificationsTest { + private val sam = Vault.threadForUser(2) + private val ada = Vault.threadForUser(3) + private val club = Vault.threadForGroup("g1") + + private fun due( + touched: Set<String>, + notice: Session.Notice? = null, + gameNews: Session.Notice? = null, + snoozed: Set<String>, + ) = NotificationSettings.due(touched, notice, gameNews, snoozed) + + @Test + fun `a message in a snoozed chat says nothing`() { + assertNull(due(setOf(sam), snoozed = setOf(sam))) + assertNull(due(setOf(club), snoozed = setOf(club))) + assertNull(due(setOf(sam), notice = Session.Notice.Card(2, null), snoozed = setOf(sam))) + assertNull(due(setOf(sam, club), snoozed = setOf(sam, club))) + } + + @Test + fun `the rest of the poll is still said`() { + assertEquals(NotificationSettings.Due(1, null), due(setOf(sam, ada), snoozed = setOf(sam))) + assertEquals(NotificationSettings.Due(2, null), due(setOf(sam, ada), snoozed = emptySet())) + val card = Session.Notice.Card(3, null) + assertEquals(NotificationSettings.Due(1, card), due(setOf(ada), notice = card, snoozed = setOf(sam))) + } + + @Test + fun `a game's news waits for the snooze too, and is said when nothing else is`() { + val move = Session.Notice.GameTurn(2, GameType.CHESS) + assertNull(due(emptySet(), gameNews = move, snoozed = setOf(sam))) + assertEquals(NotificationSettings.Due(1, move), due(emptySet(), gameNews = move, snoozed = setOf(ada))) + // A message from somebody else takes its place. + assertEquals(NotificationSettings.Due(1, null), due(setOf(ada), gameNews = move, snoozed = emptySet())) + // Unless that chat is snoozed. + assertEquals(NotificationSettings.Due(1, move), due(setOf(ada), gameNews = move, snoozed = setOf(ada))) + assertNull(due(emptySet(), snoozed = emptySet())) + } + + @Test + fun `the service asks before it posts, for every kind`() { + val service = File("src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt").readText() + // `loud`, not `touched`: requests and this account's own copies count + // as unread and never as an alert (spec 14.5 and 17.2), so they come + // out of the set before anything is raised. The service still asks + // before it posts, which is what this test is about. + assertTrue(service.contains("val loud = touched - s.quietThreads"), "quiet threads are not taken out") + assertTrue(service.contains("NotificationSettings.due(loud, notice, gameNews, snoozed)"), "the service posts without asking") + assertTrue(service.contains("s.snoozedThreads()")) + assertFalse(service.contains("gameNotice"), "only a game's news is held again") + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/AStoredAccountOpensOfflineTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/AStoredAccountOpensOfflineTest.kt new file mode 100644 index 0000000..ef4e9c6 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/AStoredAccountOpensOfflineTest.kt @@ -0,0 +1,32 @@ +package io.flamenet.messenger.app + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * With the relay unreachable at launch, a stored account opens to its chats + * and the saved Today page from what is on the phone, and connects in the + * background (DESIGN 47). Sign-in shows only with no stored credentials. + */ +class AStoredAccountOpensOfflineTest { + private val vm = File("src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt").readText() + + private val restore = vm.substringAfter("private fun restore() =").substringBefore("\n /**") + + @Test + fun `the chats open before the relay is asked anything`() { + val shown = restore.indexOf("_phase.value = restored.second") + assertTrue(shown > 0, "the restore no longer sets the phase where this test looks") + val before = restore.substring(0, shown) + assertFalse(before.contains(".roster()"), "the chats wait on the relay for the contact list") + assertFalse(before.contains(".namedGroups()"), "the chats wait on the relay for the groups") + } + + @Test + fun `only missing credentials mean signed out`() { + assertTrue(restore.contains("if (stored == null || credential == null)")) + assertTrue(restore.contains("restored.second ?: Phase.SignedOut()")) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/AddToCalendarTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/AddToCalendarTest.kt new file mode 100644 index 0000000..25f9267 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/AddToCalendarTest.kt @@ -0,0 +1,63 @@ +package io.flamenet.messenger.app + +import android.content.Intent +import android.provider.CalendarContract +import io.flamenet.e2e.Content +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * "Add to calendar" hands the event to the system's own add-event sheet + * (§6.32) and asks for nothing: no calendar permission, nothing read back. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class AddToCalendarTest { + private val picnic = + Content.Event( + id = "e", + rev = 1, + title = "Picnic", + start = 1_767_312_000, + end = 1_767_319_200, + tz = "America/New_York", + place = "Riverside park", + note = "Bring a blanket", + ) + + @Test + fun `a timed event is offered as an insert, with its times, place and note`() { + val intent = EventCalendar.intent(picnic)!! + assertEquals(Intent.ACTION_INSERT, intent.action) + assertEquals(CalendarContract.Events.CONTENT_URI, intent.data) + assertEquals("Picnic", intent.getStringExtra(CalendarContract.Events.TITLE)) + assertEquals(1_767_312_000_000L, intent.getLongExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, 0)) + assertEquals(1_767_319_200_000L, intent.getLongExtra(CalendarContract.EXTRA_EVENT_END_TIME, 0)) + assertFalse(intent.getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, true)) + assertEquals("America/New_York", intent.getStringExtra(CalendarContract.Events.EVENT_TIMEZONE)) + assertEquals("Riverside park", intent.getStringExtra(CalendarContract.Events.EVENT_LOCATION)) + assertEquals("Bring a blanket", intent.getStringExtra(CalendarContract.Events.DESCRIPTION)) + } + + @Test + fun `an all-day event is offered as whole days`() { + val intent = EventCalendar.intent(Content.Event(id = "e", rev = 1, title = "Fair", day = "2026-07-04"))!! + assertTrue(intent.getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false)) + assertFalse(intent.hasExtra(CalendarContract.Events.EVENT_TIMEZONE)) + assertNull(intent.getStringExtra(CalendarContract.Events.EVENT_LOCATION)) + } + + @Test + fun `the app asks for no calendar permission`() { + val manifest = File("src/main/AndroidManifest.xml").readText() + assertFalse("READ_CALENDAR" in manifest, "the app could read the calendar") + assertFalse("WRITE_CALENDAR" in manifest, "the app could write the calendar without the person") + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/CardAndEventScreensTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/CardAndEventScreensTest.kt new file mode 100644 index 0000000..af96c27 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/CardAndEventScreensTest.kt @@ -0,0 +1,122 @@ +package io.flamenet.messenger.app + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import io.flamenet.e2e.ChatEvents +import io.flamenet.e2e.Content +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import kotlin.test.assertEquals + +/** + * What the screen shows for a card and an event (§6.31 and §6.32): a sealed + * card's front and the day it opens and none of its words, and an event's + * answers with the line saying everyone in the chat sees them. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w375dp-h812dp-normal-long-notround-any-440dpi-keyshidden-nonav") +class CardAndEventScreensTest { + @get:Rule val compose = createAndroidComposeRule<ComponentActivity>() + + private fun show(content: @Composable () -> Unit) = + compose.setContent { + MessengerTheme { + Surface(modifier = Modifier.fillMaxSize()) { Column { content() } } + } + } + + private val now = System.currentTimeMillis() + private val sealed = + Content.Card(design = "birthday", msg = "the surprise is a puppy", to = "Alice", from = "Sam", open = now / 1000 + 3 * 86_400) + + @Test + fun `a sealed card shows its front and the day it opens, and none of its words`() { + var told: String? = null + var opened = false + val line = Session.Line(text = "", mine = false, at = now, id = "m-1", card = sealed) + show { CardBubble(line, sealed, forName = "Alice", onOpen = { opened = true }, onSealed = { told = it }, onLongPress = {}) } + + compose.onNodeWithText("Happy Birthday", useUnmergedTree = true).assertExists() + compose.onNodeWithText("Opens on", substring = true, useUnmergedTree = true).assertExists() + compose.onNodeWithText("puppy", substring = true, useUnmergedTree = true).assertDoesNotExist() + compose.onNodeWithText("puppy", substring = true).assertDoesNotExist() + + compose.onNodeWithTag("card-m-1").performClick() + assertEquals(CardArt.day(sealed.opensAtMillis(now)!!), told, "a tap on a sealed card should say when it opens") + assertEquals(false, opened, "a sealed card opened on a tap") + } + + @Test + fun `the sender's own copy says whom it is sealed for, and opens`() { + var opened = false + val line = Session.Line(text = "", mine = true, at = now, id = "m-2", card = sealed) + show { CardBubble(line, sealed, forName = "Alice", onOpen = { opened = true }, onSealed = {}, onLongPress = {}) } + + compose.onNodeWithText("Sealed until", substring = true, useUnmergedTree = true).assertExists() + compose.onNodeWithText("for Alice", substring = true, useUnmergedTree = true).assertExists() + compose.onNodeWithTag("card-m-2").performClick() + assertEquals(true, opened) + } + + @Test + fun `an open card shows its words inside`() { + show { CardInsideDialog(sealed.copy(open = null)) {} } + compose.onNodeWithText("Dear Alice,", useUnmergedTree = true).assertExists() + compose.onNodeWithText("the surprise is a puppy", useUnmergedTree = true).assertExists() + compose.onNodeWithText("From Sam", useUnmergedTree = true).assertExists() + } + + private val picnic = + Content.Event(id = "e-1", rev = 1, title = "Picnic", start = now / 1000 + 86_400, place = "Riverside park") + + @Test + fun `an event says who sees an answer and takes one`() { + var answered: String? = null + val state = ChatEvents.State(picnic, organizer = 2, answers = mapOf(2L to ChatEvents.Answer("yes", 1))) + val line = Session.Line(text = "", mine = false, at = now, id = "m-3", event = state) + show { + EventBubble( + line, + state, + myUserId = 1, + nameOf = { if (it == 2L) "Sam" else "user $it" }, + onAnswer = { answered = it }, + onAddToCalendar = {}, + onLongPress = {}, + ) + } + + compose.onNodeWithText("Everyone in this chat sees your answer.", useUnmergedTree = true).assertExists() + compose.onNodeWithText("Organized by Sam", useUnmergedTree = true).assertExists() + compose.onNodeWithText("Going 1: Sam", useUnmergedTree = true).assertExists() + compose.onNodeWithText("Riverside park", useUnmergedTree = true).assertExists() + compose.onNodeWithTag("event-add-to-calendar").assertExists() + compose.onNodeWithTag("rsvp-maybe").performClick() + assertEquals("maybe", answered) + } + + @Test + fun `a cancelled event offers no answers and no calendar`() { + val state = ChatEvents.State(picnic.copy(rev = 2, cancelled = true), organizer = 2) + val line = Session.Line(text = "", mine = false, at = now, id = "m-4", event = state) + show { EventBubble(line, state, myUserId = 1, nameOf = { "Sam" }, onAnswer = {}, onAddToCalendar = {}, onLongPress = {}) } + + compose.onNodeWithTag("event-cancelled", useUnmergedTree = true).assertExists() + compose.onNodeWithText("Cancelled: Picnic", useUnmergedTree = true).assertExists() + compose.onNodeWithTag("rsvp-yes").assertDoesNotExist() + compose.onNodeWithTag("event-add-to-calendar").assertDoesNotExist() + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/CardAndEventWordsTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/CardAndEventWordsTest.kt new file mode 100644 index 0000000..83b3060 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/CardAndEventWordsTest.kt @@ -0,0 +1,224 @@ +package io.flamenet.messenger.app + +import io.flamenet.e2e.ChatEvents +import io.flamenet.e2e.Content +import java.time.ZoneId +import java.util.Locale +import java.util.TimeZone +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What a card or an event says, on the card, in a quote, and on a lock screen + * (§6.31 and §6.32), with the clock, zone and locale passed in. + */ +class CardAndEventWordsTest { + /** The narrow spaces some locales put before AM and PM, made plain for comparing. */ + private fun plain(s: String) = s.replace('\u202F', ' ').replace('\u00A0', ' ') + + private val day = 86_400_000L + private val utc = ZoneId.of("UTC") + private val newYork = ZoneId.of("America/New_York") + private val london = ZoneId.of("Europe/London") + + // -- Cards -----------------------------------------------------------------. + + @Test + fun `the fronts carry the site's own titles`() { + val site = + mapOf( + "birthday" to "Happy Birthday", + "thanks" to "Thank You", + "thinking" to "Thinking of You", + "congrats" to "Congratulations", + "getwell" to "Get Well Soon", + "love" to "With Love", + "season" to "Season's Greetings", + "hello" to "Hello!", + "sympathy" to "With Sympathy", + ) + assertEquals(site.keys.toList(), Content.Card.DESIGNS, "the nine designs, in the site's order") + site.forEach { (d, title) -> + assertEquals(title, CardArt.title(d)) + assertEquals("cards/$d.png", CardArt.asset(d)) + } + assertEquals("Hello!", CardArt.title("anniversary")) + assertEquals("cards/hello.png", CardArt.asset("anniversary")) + } + + @Test + fun `a card's summary names the front, never the words`() { + val card = Content.Card(design = "birthday", msg = "the surprise is a puppy", to = "Sam") + assertEquals("Card: Happy Birthday", CardArt.summary(card)) + val line = Session.Line(text = "", mine = false, at = 0L, card = card) + assertEquals("Card: Happy Birthday", line.summary) + } + + @Test + fun `the seal is described from each side`() { + assertEquals("Opens on May 1, 2026", CardArt.sealedLine(mine = false, opensOn = "May 1, 2026", forName = "Sam")) + assertEquals( + "Sealed until May 1, 2026 for Sam", + CardArt.sealedLine(mine = true, opensOn = "May 1, 2026", forName = "Sam"), + ) + assertTrue("not a lock" in CardArt.SEALED_HONESTY, "the copy claims more than a courtesy") + } + + @Test + fun `a date reads as the reader's calendar writes it`() { + assertEquals("January 1, 2026", CardArt.day(1_767_225_600_000L, TimeZone.getTimeZone("UTC"), Locale.US)) + // The same moment is still December 31 in New York. + assertEquals("December 31, 2025", CardArt.day(1_767_225_600_000L, TimeZone.getTimeZone("America/New_York"), Locale.US)) + } + + // -- Copy, forward, report -------------------------------------------------. + + private val arrived = 1_700_000_000_000L + private val sealedFor3Days = Content.Card(design = "love", msg = "inside", to = "Sam", from = "Pat", open = (arrived + 3 * day) / 1000) + + @Test + fun `a card sealed here gives nothing to copy, report or forward`() { + val received = Session.Line(text = "", mine = false, at = arrived, card = sealedFor3Days) + assertEquals("", copyableWords(received, nowMillis = arrived + day)) + assertFalse(canForward(received, nowMillis = arrived + day)) + // Once open, all three. + assertEquals("Sam\ninside\nPat", copyableWords(received, nowMillis = arrived + 3 * day)) + assertTrue(canForward(received, nowMillis = arrived + 3 * day)) + } + + @Test + fun `the sender's own copy is open to them all along`() { + val sent = Session.Line(text = "", mine = true, at = arrived, card = sealedFor3Days) + assertEquals("Sam\ninside\nPat", copyableWords(sent, nowMillis = arrived)) + assertTrue(canForward(sent, nowMillis = arrived)) + } + + @Test + fun `a report of a sealed card carries what is on the front, not the words`() { + val received = Session.Line(text = "", mine = false, at = System.currentTimeMillis(), card = sealedFor3Days.copy(open = Long.MAX_VALUE)) + assertEquals("Card: With Love", reportableWords(received)) + } + + @Test + fun `a cancelled event is not offered for forwarding`() { + val ev = Content.Event(id = "e", rev = 2, title = "Picnic", start = 1, cancelled = true) + val line = Session.Line(text = "", mine = false, at = 0, event = ChatEvents.State(ev, organizer = 2)) + assertFalse(canForward(line)) + assertEquals("Cancelled: Picnic", line.summary) + assertTrue(canForward(line.copy(event = line.event!!.copy(event = ev.copy(cancelled = false))))) + } + + // -- Event times -------------------------------------------------------------. + + private val picnic = + Content.Event(id = "e", rev = 1, title = "Picnic", start = 1_767_312_000, end = 1_767_319_200, tz = "America/New_York") + + @Test + fun `an event reads in the reader's own zone`() { + // 2026-01-01 19:00 to 21:00 in New York. + assertEquals( + "Thursday, January 1, 2026, 7:00 PM to 9:00 PM", + plain(EventText.whenLine(picnic, newYork, Locale.US)), + ) + // The same event read in London starts after midnight. + assertEquals( + "Friday, January 2, 2026, 12:00 AM to 2:00 AM", + plain(EventText.whenLine(picnic, london, Locale.US)), + ) + } + + @Test + fun `an event over midnight names both days`() { + val late = picnic.copy(end = picnic.start!! + 8 * 3600) + assertEquals( + "Thursday, January 1, 2026, 7:00 PM to Friday, January 2, 2026, 3:00 AM", + plain(EventText.whenLine(late, newYork, Locale.US)), + ) + } + + @Test + fun `the organizer's time is shown only when their clock reads differently`() { + assertEquals("Thu 7:00 PM New York time", EventText.organizerLine(picnic, london, Locale.US)?.let(::plain)) + assertNull(EventText.organizerLine(picnic, newYork, Locale.US), "the reader's own zone repeated") + // A different zone on the same clock is not worth a line. + assertNull(EventText.organizerLine(picnic, ZoneId.of("America/Toronto"), Locale.US)) + assertEquals( + "4:00 PM Los Angeles time", + EventText.organizerLine(picnic.copy(tz = "America/Los_Angeles"), newYork, Locale.US)?.let(::plain), + ) + assertNull(EventText.organizerLine(picnic.copy(tz = null), london, Locale.US)) + assertNull(EventText.organizerLine(picnic.copy(tz = "Mars/Olympus_Mons"), london, Locale.US)) + assertEquals("Buenos Aires", EventText.city("America/Argentina/Buenos_Aires")) + } + + @Test + fun `an all-day event reads as days, wherever it is read`() { + val fair = Content.Event(id = "e", rev = 1, title = "Fair", day = "2026-07-04") + assertEquals("Saturday, July 4, 2026, all day", EventText.whenLine(fair, newYork, Locale.US)) + assertEquals("Saturday, July 4, 2026, all day", EventText.whenLine(fair, ZoneId.of("Asia/Tokyo"), Locale.US)) + assertEquals( + "Saturday, July 4, 2026 to Sunday, July 5, 2026, all day", + EventText.whenLine(fair.copy(days = 2), newYork, Locale.US), + ) + } + + @Test + fun `the calendar is handed the moment, the end and the zone`() { + val span = EventText.calendarSpan(picnic)!! + assertEquals(1_767_312_000_000L, span.beginMillis) + assertEquals(1_767_319_200_000L, span.endMillis) + assertFalse(span.allDay) + assertEquals("America/New_York", span.zone) + assertNull(EventText.calendarSpan(picnic.copy(tz = "Mars/Olympus_Mons"))!!.zone, "a zone the calendar cannot read was passed on") + } + + @Test + fun `an all-day event goes to the calendar as whole days in UTC`() { + val span = EventText.calendarSpan(Content.Event(id = "e", rev = 1, title = "Fair", day = "2026-07-04", days = 2))!! + assertTrue(span.allDay) + assertEquals(java.time.LocalDate.of(2026, 7, 4).atStartOfDay(utc).toInstant().toEpochMilli(), span.beginMillis) + assertEquals(java.time.LocalDate.of(2026, 7, 6).atStartOfDay(utc).toInstant().toEpochMilli(), span.endMillis) + assertNull(span.zone) + } + + // -- The lock screen -----------------------------------------------------------. + + @Test + fun `with previews off a card or an event is just a new message`() { + for (notice in listOf( + Session.Notice.Card(2, opensAtMillis = 5), + Session.Notice.Card(2, opensAtMillis = null), + Session.Notice.Invite(2, "Picnic"), + Session.Notice.Changed(2, "Picnic", cancelled = false), + )) { + assertEquals("New message", NotificationSettings.noticeText(false, notice, "Sam", "May 1, 2026")) + } + } + + @Test + fun `with previews on each says what it is and who sent it`() { + assertEquals( + "Sam sent you a card to open on May 1, 2026", + NotificationSettings.noticeText(true, Session.Notice.Card(2, 5), "Sam", "May 1, 2026"), + ) + assertEquals("Sam sent you a card", NotificationSettings.noticeText(true, Session.Notice.Card(2, null), "Sam", null)) + assertEquals("Sam invited you: Picnic", NotificationSettings.noticeText(true, Session.Notice.Invite(2, "Picnic"), "Sam", null)) + assertEquals( + "Picnic changed", + NotificationSettings.noticeText(true, Session.Notice.Changed(2, "Picnic", cancelled = false), "Sam", null), + ) + assertEquals( + "Picnic was cancelled", + NotificationSettings.noticeText(true, Session.Notice.Changed(2, "Picnic", cancelled = true), "Sam", null), + ) + } + + @Test + fun `with nobody to name the line stays plain`() { + assertEquals("New message", NotificationSettings.noticeText(true, Session.Notice.Card(2, null), " ", null)) + assertEquals("New message", NotificationSettings.noticeText(true, Session.Notice.Invite(2, "Picnic"), null, null)) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/CardsAndEventsInASessionTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/CardsAndEventsInASessionTest.kt new file mode 100644 index 0000000..5721f74 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/CardsAndEventsInASessionTest.kt @@ -0,0 +1,558 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.Conversations +import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayClient +import io.flamenet.e2e.BackupPayload +import io.flamenet.e2e.ChatEvents +import io.flamenet.e2e.Content +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.E2ESession +import io.flamenet.e2e.Envelope +import io.flamenet.e2e.Vault +import io.flamenet.e2e.X25519 +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.json.JSONObject +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Base64 +import javax.crypto.KeyGenerator +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Cards, events and answers through a real Session (§6.31 and §6.32). + * + * Sends go through the real ratchet to a relay double, and what was posted is + * opened again with the receiving identity, so the checks are on the bytes a + * member's phone would read: who they went to, whether they ring, and what + * they say. Arrivals go through the same routing a poll uses. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class CardsAndEventsInASessionTest { + /** A vault on a map, and a way to see whether anything at all was written. */ + private class MapStorage : Vault.Storage { + val records = LinkedHashMap<String, ByteArray>() + + override fun read(name: String) = records[name] + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + + override fun names() = records.keys.toList() + + /** Every record and its sealed bytes, which change on any rewrite. */ + fun snapshot(): Map<String, String> = records.mapValues { Base64.getEncoder().encodeToString(it.value) } + } + + private val me = 1L + private val sam = 2L + private val pat = 3L + private val stranger = 9L + private val gid = "g-1" + private val groupThread = Vault.threadForGroup(gid) + private val samThread = Vault.threadForUser(sam) + + /** Every other member's one device, so whatever is posted can be opened. */ + private val bob = DeviceIdentity.create() + + private lateinit var server: MockWebServer + private lateinit var storage: MapStorage + private lateinit var vault: Vault + private lateinit var identity: DeviceIdentity + private val posts = java.util.Collections.synchronizedList(mutableListOf<JSONObject>()) + + @BeforeTest fun start() { + server = MockWebServer().also { it.dispatcher = relay() }.also { it.start() } + } + + @AfterTest fun stop() = server.shutdown() + + private fun json(body: String) = MockResponse().setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + + private fun bobWire(): String = + JSONObject() + .put("device_id", bob.deviceId) + .put("ik_dh", Base64.getEncoder().encodeToString(bob.identityDHPublic)) + .put("ik_sig", Base64.getEncoder().encodeToString(bob.identitySigningPublic)) + .put("spk_id", bob.signedPreKeyId) + .put("spk", Base64.getEncoder().encodeToString(X25519.rawFromPublicKey(bob.signedPreKey.public))) + .put("spk_sig", Base64.getEncoder().encodeToString(bob.signedPreKeySignature())) + .put("proto", 1) + .toString() + + private fun relay() = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path.orEmpty() + return when { + "/e2e/log/" in path -> { + MockResponse().setResponseCode(404) + } + + "/e2e/groups" in path -> { + json( + """{"groups":[{"gid":"$gid","name":"Saturday","owner":1,""" + + """"members":[{"user_id":1},{"user_id":2},{"user_id":3}]}]}""", + ) + } + + "/e2e/messages" in path && request.method == "POST" -> { + posts += JSONObject(request.body.readUtf8()) + json("""{"ok":true}""") + } + + else -> { + json("""{"devices":[${bobWire()}]}""") + } + } + } + } + + private fun group() = RelayClient.Group(gid, "Saturday", me, listOf(me, sam, pat)) + + private fun session(offTheRecordWith: Long? = null): Session { + identity = DeviceIdentity.create() + storage = MapStorage() + vault = Vault(storage, KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + vault.saveIdentity(identity) + // What a member's phone holds after the group was announced (§6.7). + vault.saveGroupName(gid, "Saturday") + offTheRecordWith?.let { vault.saveOffTheRecord(Vault.threadForUser(it), true) } + return sessionOn(vault) + } + + private fun sessionOn(v: Vault): Session { + val base = server.url("/").toString() + return Session("alice", me, identity, v, RelayClient(base, "alice", "app pass word"), MessengerApi(base, "alice", "app pass word")) + } + + private var nextId = 1L + + private fun from( + user: Long, + wire: String, + ) = Conversations.Received( + id = nextId++, + fromUser = user, + fromDevice = "device-$user", + content = Content.parse(wire), + senderProven = true, + at = System.currentTimeMillis(), + ) + + // -- What a member's phone reads -----------------------------------------. + + private class Posted( + val to: Long, + val silent: Boolean, + val content: Content, + ) + + private var bobSession: E2ESession? = null + private var opened = 0 + + /** Everything posted since the last call, opened as the member's device opens it. */ + private fun drain(): List<Posted> { + val out = mutableListOf<Posted>() + while (opened < posts.size) { + val body = posts[opened++] + val messages = body.getJSONArray("messages") + for (i in 0 until messages.length()) { + val envelope = Envelope.fromPayload(messages.getJSONObject(i).getString("payload")) + val session = bobSession ?: E2ESession.respond(bob, envelope).also { bobSession = it } + out += Posted(body.getLong("to"), body.optBoolean("silent", false), Content.parse(session.decrypt(envelope))) + } + } + return out + } + + private fun picnic( + id: String = "e-1", + rev: Int = 1, + title: String = "Picnic", + cancelled: Boolean = false, + ) = Content.Event( + id = id, + rev = rev, + title = title, + start = 1_767_225_600, + end = 1_767_232_800, + tz = "America/New_York", + place = "Riverside park", + cancelled = cancelled, + ) + + private val birthday = Content.Card(design = "birthday", msg = "Have a lovely day.", to = "Alice", from = "Sam") + + // -- Sending ---------------------------------------------------------------. + + @Test + fun `an event to a group reaches every member but me, and is news`() { + val s = session() + s.sendEvent(null, group(), picnic()) + + val out = drain() + assertEquals(listOf(sam, pat), out.map { it.to }.sorted(), "fan-out missed somebody, or reached me") + for (posted in out) { + assertEquals(Content.Kind.EVENT, posted.content.kind) + assertEquals(gid, posted.content.groupId) + assertEquals("Saturday", posted.content.raw!!.optString("gname")) + assertFalse(posted.silent, "an invitation should reach the person like a message") + } + val line = s.history(groupThread).single() + assertTrue(line.mine) + assertEquals(me, line.event!!.organizer) + assertEquals(out.first().content.messageId, line.id, "the card here and on the wire have different ids") + } + + @Test + fun `an answer goes to every member but me, as silent control traffic, counting up`() { + val s = session() + s.sendEvent(null, group(), picnic()) + drain() + + val now = 1_800_000_000L + assertTrue(s.answerEvent(groupThread, "e-1", Content.Rsvp.YES, null, group(), nowSeconds = now)) + val first = drain() + assertEquals(listOf(sam, pat), first.map { it.to }.sorted()) + for (posted in first) { + assertEquals(Content.Kind.RSVP, posted.content.kind) + assertTrue(posted.content.isControl, "an answer without ctl would draw a bubble on an older build") + assertFalse(posted.content.isRenderable) + assertEquals(Content.Rsvp("e-1", "yes", now), posted.content.rsvp, "an answer is counted by the second it was given") + assertEquals(gid, posted.content.groupId) + assertFalse(posted.content.raw!!.has("gname"), "an answer carries no group name") + assertTrue(posted.silent, "an answer must not ring anybody's phone") + } + + // A second answer in the same second still counts past the first. + assertTrue(s.answerEvent(groupThread, "e-1", Content.Rsvp.NO, null, group(), nowSeconds = now)) + assertTrue(drain().all { it.content.rsvp == Content.Rsvp("e-1", "no", now + 1) }) + assertEquals(ChatEvents.Answer("no", now + 1), s.eventState(groupThread, "e-1")!!.answers[me]) + } + + @Test + fun `a revision goes out under a fresh mid and folds into the same card`() { + val s = session() + s.sendEvent(sam, null, picnic()) + val invite = drain().single() + + assertTrue(s.reviseEvent(samThread, "e-1", sam, null) { it.copy(title = "Barbecue") }) + val change = drain().single() + assertNotEquals(invite.content.messageId, change.content.messageId, "a revision reused the first mid") + assertEquals(2, change.content.event!!.rev) + assertEquals("Barbecue", change.content.event!!.title) + + assertTrue(s.reviseEvent(samThread, "e-1", sam, null) { it.copy(cancelled = true) }) + val cancel = drain().single().content.event!! + assertEquals(3, cancel.rev) + assertTrue(cancel.cancelled) + assertEquals("Barbecue", cancel.title, "a cancel carries the whole event") + + val line = s.history(samThread).single() + assertEquals(invite.content.messageId, line.id, "the card lost the mid replies and reactions point at") + assertTrue(line.event!!.event.cancelled) + } + + @Test + fun `nobody but the organizer can change an event, here or from elsewhere`() { + val s = session() + s.absorbForTesting(listOf(from(sam, Content.event(picnic(id = "e-s"), "m-1")))) + assertFalse(s.reviseEvent(samThread, "e-s", sam, null) { it.copy(title = "Mine now") }) + assertEquals(0, posts.size, "a change to somebody else's event was sent") + + s.absorbForTesting(listOf(from(sam, Content.event(picnic(id = "e-g"), "m-g", gid, "Saturday")))) + val hijack = s.absorbForTesting(listOf(from(pat, Content.event(picnic(id = "e-g", rev = 5, title = "Moved"), "m-x", gid)))) + assertTrue(hijack.isEmpty()) + assertEquals("Picnic", s.eventState(groupThread, "e-g")!!.event.title) + + s.absorbForTesting(listOf(from(sam, Content.event(picnic(id = "e-g", rev = 2, cancelled = true), "m-y", gid)))) + assertTrue(s.eventState(groupThread, "e-g")!!.event.cancelled, "the organizer's cancel did not land") + assertEquals(1, s.history(groupThread).size, "a revision drew a second card") + } + + @Test + fun `a stale or repeated revision changes nothing and is not news`() { + val s = session() + s.absorbForTesting(listOf(from(sam, Content.event(picnic(rev = 3, title = "Third"), "m-3")))) + s.takeNotice() + val older = s.absorbForTesting(listOf(from(sam, Content.event(picnic(rev = 2, title = "Second"), "m-2")))) + val again = s.absorbForTesting(listOf(from(sam, Content.event(picnic(rev = 3, title = "Third"), "m-3b")))) + assertTrue(older.isEmpty() && again.isEmpty()) + assertNull(s.takeNotice()) + assertEquals("Third", s.history(samThread).single().event!!.event.title) + } + + // -- Answers ---------------------------------------------------------------. + + @Test + fun `answers fold onto the card, the highest count wins, and they never notify`() { + val s = session() + s.absorbForTesting(listOf(from(sam, Content.event(picnic(), "m-1", gid, "Saturday")))) + s.takeNotice() + s.takeQuietlyChanged() + + val touched = + s.absorbForTesting( + listOf( + from(pat, Content.rsvp(Content.Rsvp("e-1", "yes", 2), gid)), + from(pat, Content.rsvp(Content.Rsvp("e-1", "no", 1), gid)), + from(sam, Content.rsvp(Content.Rsvp("e-1", "maybe", 1), gid)), + ), + ) + assertTrue(touched.isEmpty(), "an answer marked the chat unread, which is what notifies") + assertNull(s.takeNotice(), "an answer had words for the lock screen") + assertEquals(setOf(groupThread), s.takeQuietlyChanged(), "the card was not redrawn") + val state = s.eventState(groupThread, "e-1")!! + assertEquals(ChatEvents.Answer("yes", 2), state.answers[pat], "an older answer arriving late won") + assertEquals(listOf(sam), state.who("maybe")) + } + + @Test + fun `an answer that arrives before its event is applied when the event lands`() { + val s = session() + s.absorbForTesting(listOf(from(pat, Content.rsvp(Content.Rsvp("e-1", "yes", 1), gid)))) + assertNull(s.eventState(groupThread, "e-1")) + s.absorbForTesting(listOf(from(sam, Content.event(picnic(), "m-1", gid, "Saturday")))) + assertEquals(listOf(pat), s.eventState(groupThread, "e-1")!!.who("yes")) + } + + @Test + fun `nothing from somebody outside the group is taken`() { + val s = session() + s.absorbForTesting(listOf(from(sam, Content.event(picnic(), "m-1", gid, "Saturday")))) + s.absorbForTesting( + listOf( + from(stranger, Content.rsvp(Content.Rsvp("e-1", "yes", 1), gid)), + from(stranger, Content.event(picnic(id = "e-x"), "m-x", gid)), + from(stranger, Content.card(birthday, "m-c", gid)), + ), + ) + assertEquals(emptyMap(), s.eventState(groupThread, "e-1")!!.answers) + assertEquals(1, s.history(groupThread).size) + } + + // -- Placement -------------------------------------------------------------. + + @Test + fun `my own card from another device is mine in a group and lands nowhere one-to-one`() { + val s = session() + val oneToOne = s.absorbForTesting(listOf(from(me, Content.card(birthday, "m-own")))) + assertTrue(oneToOne.isEmpty()) + assertTrue(s.history(Vault.threadForUser(me)).isEmpty(), "with no sent copies it has no conversation to land in") + + val inGroup = s.absorbForTesting(listOf(from(me, Content.card(birthday, "m-own-g", gid, "Saturday")))) + assertTrue(inGroup.isEmpty(), "my own card marked my own chat unread") + assertNull(s.takeNotice()) + assertEquals(setOf(groupThread), s.takeQuietlyChanged(), "my own card was not redrawn") + val line = s.history(groupThread).single() + assertTrue(line.mine, "my own card in the group was drawn as somebody else's") + assertEquals(birthday, line.card) + } + + @Test + fun `a card or an event that carries ctl is consumed, never drawn`() { + val s = session() + val touched = + s.absorbForTesting( + listOf( + from(sam, """{"fnc":1,"kind":"card","ctl":1,"mid":"m","card":{"d":"love","msg":"x"}}"""), + from(sam, """{"fnc":1,"kind":"event","ctl":true,"mid":"m2","ev":{"id":"e","rev":1,"title":"T","start":1}}"""), + ), + ) + assertTrue(touched.isEmpty()) + assertTrue(s.history(samThread).isEmpty()) + } + + // -- What the lock screen says -----------------------------------------------. + + @Test + fun `a sealed card names the day it opens and never its words`() { + val s = session() + val opens = System.currentTimeMillis() / 1000 + 3 * 86_400 + s.absorbForTesting(listOf(from(sam, Content.card(birthday.copy(msg = "the surprise is a puppy", open = opens), "m-c")))) + val notice = s.takeNotice() as Session.Notice.Card + assertEquals(sam, notice.from) + assertNotNull(notice.opensAtMillis) + val text = NotificationSettings.noticeText(true, notice, "Sam", CardArt.day(notice.opensAtMillis!!)) + assertTrue(text.startsWith("Sam sent you a card to open on "), text) + assertFalse("puppy" in text) + assertEquals("New message", NotificationSettings.noticeText(false, notice, "Sam", "May 1")) + + val line = s.history(samThread).single() + assertEquals("", copyableWords(line), "Copy would hand over a sealed card's words") + assertFalse(canForward(line), "a sealed card was offered for forwarding") + assertEquals("Card: Happy Birthday", line.summary) + } + + @Test + fun `an open card notifies without a date`() { + val s = session() + s.absorbForTesting(listOf(from(sam, Content.card(birthday, "m-c")))) + val notice = s.takeNotice() as Session.Notice.Card + assertNull(notice.opensAtMillis) + assertEquals("Sam sent you a card", NotificationSettings.noticeText(true, notice, "Sam", null)) + } + + @Test + fun `an invitation and a change notify in their own words`() { + val s = session() + s.absorbForTesting(listOf(from(sam, Content.event(picnic(), "m-1")))) + val invite = s.takeNotice() + assertEquals(Session.Notice.Invite(sam, "Picnic"), invite) + assertEquals("Sam invited you: Picnic", NotificationSettings.noticeText(true, invite!!, "Sam", null)) + + s.absorbForTesting(listOf(from(sam, Content.event(picnic(rev = 2, title = "Barbecue"), "m-2")))) + val change = s.takeNotice() + assertEquals(Session.Notice.Changed(sam, "Picnic", cancelled = false), change, "a change is named as the reader knew it") + assertEquals("Picnic changed", NotificationSettings.noticeText(true, change!!, "Sam", null)) + + s.absorbForTesting(listOf(from(sam, Content.event(picnic(rev = 3, title = "Barbecue", cancelled = true), "m-3")))) + val cancel = s.takeNotice() + assertEquals(Session.Notice.Changed(sam, "Barbecue", cancelled = true), cancel) + assertEquals("Barbecue was cancelled", NotificationSettings.noticeText(true, cancel!!, "Sam", null)) + assertEquals("Cancelled: Barbecue", s.history(samThread).single().summary) + } + + @Test + fun `a rev 1 that takes over from a stand-in organizer is redrawn and never news (DESIGN 36)`() { + val s = session() + // Pat's rev 3 arrived first, so Pat stands in as the organizer. + s.absorbForTesting(listOf(from(pat, Content.event(picnic(rev = 3, title = "Moved"), "m-p", gid, "Saturday")))) + s.takeNotice() + s.takeQuietlyChanged() + + val touched = s.absorbForTesting(listOf(from(sam, Content.event(picnic(rev = 1), "m-s", gid, "Saturday")))) + assertTrue(touched.isEmpty(), "a takeover marked the chat unread") + assertNull(s.takeNotice(), "a takeover said \"Moved changed\"") + assertTrue(groupThread in s.takeQuietlyChanged(), "the card was not redrawn") + val state = s.eventState(groupThread, "e-1")!! + assertEquals(sam, state.organizer) + assertEquals("Picnic", state.event.title) + assertFalse(state.organizerProvisional) + } + + @Test + fun `a card or an event that brings a timer of its own keeps the chat's (DESIGN 46)`() { + val s = session() + val card = Content.card(birthday, "m-c").replaceFirst("{\"fnc\":1,", "{\"fnc\":1,\"secs\":5,") + val event = Content.event(picnic(), "m-e").replaceFirst("{\"fnc\":1,", "{\"fnc\":1,\"secs\":5,") + s.absorbForTesting(listOf(from(sam, card), from(sam, event))) + assertEquals(listOf(0, 0), s.history(samThread).map { it.lifetimeSeconds }, "a card or an event took its own timer") + } + + /** DESIGN 45: what this phone writes keeps to 31 days, as what it reads does. */ + @Test + fun `the event composer keeps to thirty-one days`() { + val composer = java.io.File("src/main/kotlin/io/flamenet/messenger/app/EventUi.kt").readText() + assertTrue( + composer.contains("days = if (allDay && hasEnd) Content.Event.clampDays(days.toLong()) else null"), + "the composer writes days past 31 again", + ) + assertEquals(31, Content.Event.clampDays(60)) + assertNull(Content.Event.clampDays(1)) + } + + @Test + fun `two things in one batch get the plain line`() { + val s = session() + s.absorbForTesting(listOf(from(sam, Content.card(birthday, "m-c")), from(sam, Content.text("and hello", messageId = "m-t")))) + assertNull(s.takeNotice()) + } + + // -- Off the record ----------------------------------------------------------. + + @Test + fun `nothing that arrives in an off-the-record conversation reaches the disk`() { + val s = session(offTheRecordWith = sam) + val before = storage.snapshot() + + s.absorbForTesting(listOf(from(sam, Content.rsvp(Content.Rsvp("e-1", "yes", 1))))) + s.absorbForTesting(listOf(from(sam, Content.card(birthday.copy(open = System.currentTimeMillis() / 1000 + 86_400), "m-c")))) + s.absorbForTesting(listOf(from(sam, Content.event(picnic(), "m-1")))) + s.absorbForTesting(listOf(from(sam, Content.event(picnic(rev = 2, title = "Barbecue"), "m-2")))) + s.absorbForTesting(listOf(from(sam, Content.rsvp(Content.Rsvp("e-1", "maybe", 2))))) + + assertEquals(before, storage.snapshot(), "an off-the-record card, event, revision or answer was written") + val lines = s.history(samThread) + assertEquals(2, lines.size, "the card and the event should still show while the app is open") + assertEquals("Barbecue", lines.last().event!!.event.title) + assertEquals("maybe", lines.last().event!!.answers[sam]?.r) + assertTrue(sessionOn(vault).history(samThread).isEmpty(), "a restart found the conversation") + } + + @Test + fun `what I send in an off-the-record conversation stays off the disk too`() { + val s = session(offTheRecordWith = sam) + s.sendCard(sam, birthday) + s.sendEvent(sam, null, picnic()) + assertTrue(s.answerEvent(samThread, "e-1", "yes", sam, null)) + assertTrue(s.reviseEvent(samThread, "e-1", sam, null) { it.copy(title = "Barbecue") }) + + assertTrue(storage.names().none { it.startsWith("history/") }, "a line reached the vault") + assertEquals(0, vault.outbox().length(), "a queued copy reached the vault") + assertEquals(2, s.history(samThread).size) + assertEquals(4, drain().size, "the sends themselves still went out") + } + + @Test + fun `deleting a card or an event in an off-the-record conversation writes nothing`() { + val s = session(offTheRecordWith = sam) + s.absorbForTesting(listOf(from(sam, Content.card(birthday, "m-c")))) + s.absorbForTesting(listOf(from(sam, Content.event(picnic(), "m-1")))) + val before = storage.snapshot() + val lines = s.history(samThread) + assertEquals(2, lines.size) + // Delete matches on time, side and words, so two lines from one + // millisecond can go together; what matters is that both go. + assertTrue(s.deleteMessage(samThread, lines[0].at, lines[0].mine, lines[0].text), "delete for me found nothing") + lines.drop(1).forEach { s.deleteMessage(samThread, it.at, it.mine, it.text) } + assertTrue(s.history(samThread).isEmpty(), "the lines are still there") + assertEquals(before, storage.snapshot(), "deleting wrote a record naming the conversation") + } + + // -- Backups -----------------------------------------------------------------. + + @Test + fun `a card and an event come back whole through a backup`() { + val s = session() + s.absorbForTesting(listOf(from(sam, Content.card(birthday.copy(open = 1_900_000_000), "m-c")))) + s.absorbForTesting(listOf(from(sam, Content.event(picnic(), "m-1", gid, "Saturday")))) + s.absorbForTesting(listOf(from(pat, Content.rsvp(Content.Rsvp("e-1", "no", 4), gid)))) + s.absorbForTesting(listOf(from(sam, Content.event(picnic(rev = 2, title = "Barbecue"), "m-2", gid)))) + + val file = BackupPayload.toShared(vault.exportPayload()).toString() + val restoredVault = Vault(MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + restoredVault.mergeImportedPayload(BackupPayload.fromShared(JSONObject(file)), nowSeconds = System.currentTimeMillis() / 1000.0) + val restored = sessionOn(restoredVault) + + assertEquals(s.history(samThread).single().card, restored.history(samThread).single().card) + val before = s.history(groupThread).single() + val after = restored.history(groupThread).single() + assertEquals(before.event, after.event) + assertEquals(before.id, after.id) + assertEquals("Barbecue", after.event!!.event.title) + assertEquals(ChatEvents.Answer("no", 4), after.event!!.answers[pat]) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/CardsReadInTheDarkTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/CardsReadInTheDarkTest.kt new file mode 100644 index 0000000..cb836fd --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/CardsReadInTheDarkTest.kt @@ -0,0 +1,63 @@ +package io.flamenet.messenger.app + +import androidx.compose.ui.graphics.Color +import java.io.File +import kotlin.math.pow +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The event, card and poll cards write their secondary lines in the theme's + * onSurfaceVariant, as the game card does (owner, 0.32). The palette's fixed + * grey is 1.7:1 on the dark theme's surface. + */ +class CardsReadInTheDarkTest { + private val dir = "src/main/kotlin/io/flamenet/messenger/app/" + + @Test + fun `no secondary line on an event, a card or a poll is the fixed grey`() { + for (name in listOf("CardUi.kt", "EventUi.kt", "PollUi.kt")) { + val src = File(dir + name).readText() + assertFalse( + Regex("""color\s*=\s*Palette\.SecondaryText\b(?!\.)""").containsMatchIn(src), + "$name writes text in the fixed grey again", + ) + assertTrue(src.contains("color = MaterialTheme.colorScheme.onSurfaceVariant"), "$name has no secondary line at all") + } + val game = File(dir + "GameUi.kt").readText() + assertTrue(game.contains("color = MaterialTheme.colorScheme.onSurfaceVariant"), "the game card is the model here") + } + + /** The stories feed's secondary lines and its empty-state icon, the same way (owner, 0.32). */ + @Test + fun `the stories feed draws nothing in the fixed grey`() { + val feed = File(dir + "StoriesFeed.kt").readText() + val uses = Regex("""Palette\.SecondaryText\b""").findAll(feed).count() + assertTrue(uses == 0, "the stories feed still draws $uses things in the fixed grey") + assertTrue(feed.contains("color = MaterialTheme.colorScheme.onSurfaceVariant")) + } + + @Test + fun `the theme's colour reads on the dark surface, and the fixed grey does not`() { + assertTrue(contrast(Palette.NightSecondary, Palette.InkSurface) >= 4.5, "the dark theme's secondary text is too faint") + assertTrue(contrast(Palette.SecondaryText, Palette.InkSurface) < 3.0, "the old grey was readable after all") + // In the light theme the two are one colour, so nothing changes there. + assertTrue(contrast(Palette.SecondaryText, Color.White) >= 4.5) + } + + /** WCAG 2 contrast ratio. */ + private fun contrast( + a: Color, + b: Color, + ): Double { + val la = luminance(a) + val lb = luminance(b) + return (maxOf(la, lb) + 0.05) / (minOf(la, lb) + 0.05) + } + + private fun luminance(c: Color): Double { + fun lin(v: Float): Double = if (v <= 0.04045f) v / 12.92 else ((v + 0.055) / 1.055).pow(2.4) + return 0.2126 * lin(c.red) + 0.7152 * lin(c.green) + 0.0722 * lin(c.blue) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/ChatGamesTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/ChatGamesTest.kt new file mode 100644 index 0000000..8a10d97 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ChatGamesTest.kt @@ -0,0 +1,619 @@ +package io.flamenet.messenger.app + +import io.flamenet.e2e.Content +import io.flamenet.games.DrawReason +import io.flamenet.games.GamePosition +import io.flamenet.games.GameType +import io.flamenet.games.Games +import io.flamenet.games.MoveResult +import io.flamenet.games.Outcome +import io.flamenet.games.PieceKind +import io.flamenet.games.ReplayResult +import io.flamenet.games.Side +import io.flamenet.games.WinReason +import org.json.JSONArray +import org.json.JSONObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A game as a conversation holds it (§6.33): the receiving rules 1 to 4, the + * cap, every ending, this person's own moves, the words on the card and the + * history row. + */ +class ChatGamesTest { + private val id = "g-1" + + private fun chess( + me: Side, + vararg log: String, + startedByMe: Boolean = true, + ) = ChatGames.State(id, GameType.CHESS, me, log.toList(), startedByMe) + + private fun move( + mine: Side, + vararg log: String, + type: String = "chess", + ) = Content.Game(id, type, mine.wire, log.toList()) + + private fun end( + mine: Side, + ending: String, + vararg log: String, + ) = Content.Game(id, "chess", mine.wire, log.toList(), ending) + + private fun take( + current: ChatGames.State?, + game: Content.Game, + ) = ChatGames.receive(current, if (game.ending == null) Content.Kind.GAME_MOVE else Content.Kind.GAME_END, game) + + private fun stateOf(received: ChatGames.Received): ChatGames.State = + when (received) { + is ChatGames.Received.Moved -> received.state + is ChatGames.Received.Ended -> received.state + is ChatGames.Received.Offered -> received.state + is ChatGames.Received.Quiet -> received.state + is ChatGames.Received.OutOfStep -> received.state + ChatGames.Received.Ignored -> throw AssertionError("ignored") + } + + // -- Rule 1: a game this chat does not hold ------------------------------------. + + @Test + fun `rule 1, an unknown game is rebuilt from a move, on the side the sender does not play`() { + val got = assertIs<ChatGames.Received.Moved>(take(null, move(Side.FIRST, "e2e4"))) + val state = got.state + assertEquals(Side.SECOND, state.me) + assertEquals(GameType.CHESS, state.type) + assertEquals(listOf("e2e4"), state.log) + assertFalse(state.startedByMe) + assertTrue(state.myMove) + assertEquals("Your move. Playing it accepts the game", ChatGames.status(state)) + } + + @Test + fun `rule 1, an unknown game is rebuilt from an ending too`() { + val got = assertIs<ChatGames.Received.Ended>(take(null, end(Side.SECOND, ChatGames.RESIGN, "e2e4", "e7e5"))) + assertEquals(Side.FIRST, got.state.me) + assertEquals(ChatGames.RESIGN, got.state.ending) + assertEquals(Side.SECOND, got.state.endingBy) + assertEquals("They resigned. You won", ChatGames.result(got.state)) + } + + @Test + fun `rule 1, an unknown game whose log does not replay is out of step from the start`() { + val bad = assertIs<ChatGames.Received.OutOfStep>(take(null, move(Side.SECOND, "e2e4", "e7e9"))) + assertTrue(bad.state.outOfStep) + assertEquals(emptyList(), bad.state.log, "a log that does not replay was kept") + assertEquals("Your boards disagree", ChatGames.status(bad.state)) + + val tooLong = Content.Game(id, "tictactoe", "second", listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "1")) + assertIs<ChatGames.Received.OutOfStep>(ChatGames.receive(null, Content.Kind.GAME_MOVE, tooLong)) + } + + // -- Rules 2 and 3: logs that are not news, or do not agree ------------------. + + @Test + fun `rule 2, a duplicate or a stale log changes nothing`() { + val known = chess(Side.FIRST, "e2e4", "e7e5", "g1f3") + assertEquals(ChatGames.Received.Ignored, take(known, move(Side.SECOND, "e2e4", "e7e5"))) + assertEquals(ChatGames.Received.Ignored, take(known, move(Side.SECOND, "e2e4", "e7e5", "g1f3"))) + assertEquals(ChatGames.Received.Ignored, take(known, move(Side.SECOND))) + } + + @Test + fun `rule 2, a log no longer than mine that disagrees puts the game out of step`() { + val known = chess(Side.FIRST, "e2e4", "e7e5") + for (theirs in listOf(move(Side.SECOND, "d2d4"), move(Side.SECOND, "e2e4", "c7c5"))) { + val got = assertIs<ChatGames.Received.OutOfStep>(take(known, theirs), theirs.log.toString()) + assertTrue(got.state.outOfStep) + assertEquals(known.log, got.state.log, "the known game was not kept") + } + } + + @Test + fun `rule 3, a longer log that does not start with mine puts the game out of step`() { + val known = chess(Side.FIRST, "e2e4") + val got = assertIs<ChatGames.Received.OutOfStep>(take(known, move(Side.SECOND, "d2d4", "d7d5"))) + assertEquals(listOf("e2e4"), got.state.log) + } + + // -- Rule 4: what a peer adds -------------------------------------------------. + + @Test + fun `rule 4, their move is taken and it is then my move`() { + val got = assertIs<ChatGames.Received.Moved>(take(chess(Side.FIRST, "e2e4"), move(Side.SECOND, "e2e4", "e7e5"))) + assertEquals(listOf("e2e4", "e7e5"), got.state.log) + assertTrue(got.state.myMove) + assertTrue(got.state.accepted, "their first move accepts the game") + assertEquals("Your move", ChatGames.status(got.state)) + } + + @Test + fun `rule 4, a peer adding a move for my side is rejected and the known game kept`() { + val mine = chess(Side.FIRST, "e2e4", "e7e5") + val forMe = assertIs<ChatGames.Received.OutOfStep>(take(mine, move(Side.SECOND, "e2e4", "e7e5", "g1f3"))) + assertEquals(listOf("e2e4", "e7e5"), forMe.state.log, "a move for my side was played") + assertTrue(forMe.state.outOfStep) + + // Theirs, then mine as well, in one envelope. + val two = assertIs<ChatGames.Received.OutOfStep>(take(chess(Side.FIRST, "e2e4"), move(Side.SECOND, "e2e4", "e7e5", "g1f3", "b8c6"))) + assertEquals(listOf("e2e4"), two.state.log) + } + + @Test + fun `rule 4, a peer claiming my side is rejected`() { + val got = assertIs<ChatGames.Received.OutOfStep>(take(chess(Side.FIRST, "e2e4"), move(Side.FIRST, "e2e4", "e7e5"))) + assertEquals(listOf("e2e4"), got.state.log) + } + + @Test + fun `rule 4, a malformed or illegal move puts the game out of step`() { + val known = chess(Side.FIRST, "e2e4") + for (bad in listOf("e7e9", "E7E5", "e7e4", "", "e7e5 ")) { + val got = assertIs<ChatGames.Received.OutOfStep>(take(known, move(Side.SECOND, "e2e4", bad)), "took \"$bad\"") + assertEquals(listOf("e2e4"), got.state.log) + } + } + + @Test + fun `a different game under the same id is out of step`() { + assertIs<ChatGames.Received.OutOfStep>(take(chess(Side.FIRST, "e2e4"), move(Side.SECOND, "11-15", "23-19", type = "checkers"))) + } + + @Test + fun `a game out of step or finished takes nothing more`() { + val outOfStep = chess(Side.FIRST, "e2e4").copy(outOfStep = true) + assertEquals(ChatGames.Received.Ignored, take(outOfStep, move(Side.SECOND, "e2e4", "e7e5"))) + + val mated = chess(Side.FIRST, "f2f3", "e7e5", "g2g4", "d8h4") + assertTrue(mated.finished) + assertEquals(ChatGames.Received.Ignored, take(mated, end(Side.SECOND, ChatGames.RESIGN, "f2f3", "e7e5", "g2g4", "d8h4"))) + + val resigned = chess(Side.FIRST, "e2e4", "e7e5").copy(ending = ChatGames.RESIGN, endingBy = Side.FIRST) + assertEquals(ChatGames.Received.Ignored, take(resigned, move(Side.SECOND, "e2e4", "e7e5", "g1f3", "b8c6"))) + assertEquals(ChatGames.Received.Ignored, take(resigned, end(Side.SECOND, ChatGames.DRAW_OFFER, "e2e4", "e7e5"))) + } + + // -- The cap ---------------------------------------------------------------------. + + /** + * A legal chess game [plies] long that is still going: no captures, a + * pawn move at least every ninety plies, and never a move that ends it. + * The cap has to be what refuses a longer log, not some illegal move in it. + */ + private fun longChessGame(plies: Int): List<String> { + var position: GamePosition = Games.start(GameType.CHESS) + val log = mutableListOf<String>() + var quiet = 0 + while (log.size < plies) { + val board = position.board + val pieces = board.cells.count { it != null } + + fun pawn(move: String) = board.cells[board.squares.indexOf(move.substring(0, 2))]?.kind == PieceKind.PAWN + val legal = Games.legalMoves(position) + val (pawns, others) = legal.partition(::pawn) + val order = if (quiet < 90) others + pawns else pawns + others + var chosen: Pair<String, GamePosition>? = null + for (capture in listOf(false, true)) { + for (candidate in order) { + val next = (Games.apply(position, candidate) as? MoveResult.Applied)?.position ?: continue + if ((next.board.cells.count { it != null } != pieces) != capture) continue + if (Games.outcome(next).isOver) continue + chosen = candidate to next + break + } + if (chosen != null) break + } + val (played, next) = assertNotNull(chosen, "no move keeps the game going at ply ${log.size}") + quiet = if (pawn(played)) 0 else quiet + 1 + log += played + position = next + } + return log + } + + @Test + fun `the cap, a log longer than the game allows is refused even when every move in it is legal`() { + val long = longChessGame(GameType.CHESS.maxPlies + 1) + // Every move is legal and the game is still going without the cap. + val uncapped = assertIs<ReplayResult.Replayed>(Games.replay(Games.start(GameType.CHESS), long)) + assertEquals(Outcome.Ongoing, Games.outcome(uncapped.position)) + assertEquals(Side.FIRST, GameType.CHESS.sideOfPly(GameType.CHESS.maxPlies)) + + // A known game at the cap, and their one more move: theirs, legal, and refused. + val atCap = chess(Side.SECOND, *long.take(GameType.CHESS.maxPlies).toTypedArray()) + assertFalse(atCap.finished) + val got = assertIs<ChatGames.Received.OutOfStep>(take(atCap, move(Side.FIRST, *long.toTypedArray()))) + assertEquals(GameType.CHESS.maxPlies, got.state.log.size) + + // Nor is it rebuilt as a new game. + assertIs<ChatGames.Received.OutOfStep>(take(null, move(Side.FIRST, *long.toTypedArray()))) + + // And I cannot play past it either. + val mineAtCap = chess(Side.FIRST, *long.take(GameType.CHESS.maxPlies).toTypedArray()) + assertTrue(long.last() in Games.legalMoves(mineAtCap.position!!)) + assertNull(ChatGames.play(mineAtCap, long.last())) + } + + // -- Endings ------------------------------------------------------------------------. + + @Test + fun `a resignation ends the game for whoever sent it`() { + val playing = chess(Side.FIRST, "e2e4", "e7e5") + val got = assertIs<ChatGames.Received.Ended>(take(playing, end(Side.SECOND, ChatGames.RESIGN, "e2e4", "e7e5"))) + assertTrue(got.state.finished) + assertEquals(Side.SECOND, got.state.endingBy) + assertEquals("They resigned. You won", ChatGames.status(got.state)) + assertFalse(got.state.myMove) + assertEquals(emptyList(), got.state.myMoves) + } + + @Test + fun `a draw is offered, accepted or declined, and an answer to no offer is ignored`() { + val playing = chess(Side.FIRST, "e2e4", "e7e5") + val offered = assertIs<ChatGames.Received.Offered>(take(playing, end(Side.SECOND, ChatGames.DRAW_OFFER, "e2e4", "e7e5"))).state + assertEquals(Side.SECOND, offered.offerBy) + assertEquals("They offered a draw", ChatGames.status(offered)) + assertTrue(ChatGames.canEnd(offered, ChatGames.DRAW_ACCEPT)) + assertTrue(ChatGames.canEnd(offered, ChatGames.DRAW_DECLINE)) + assertFalse(ChatGames.canEnd(offered, ChatGames.DRAW_OFFER), "an offer while theirs is open") + assertEquals(ChatGames.Received.Ignored, take(offered, end(Side.SECOND, ChatGames.DRAW_OFFER, "e2e4", "e7e5"))) + + // Their accept or decline of an offer I never made. + assertEquals(ChatGames.Received.Ignored, take(playing, end(Side.SECOND, ChatGames.DRAW_ACCEPT, "e2e4", "e7e5"))) + assertEquals(ChatGames.Received.Ignored, take(playing, end(Side.SECOND, ChatGames.DRAW_DECLINE, "e2e4", "e7e5"))) + + // Mine, once I have moved, then their answers. + assertFalse(ChatGames.canEnd(playing, ChatGames.DRAW_OFFER), "an offer on my own move") + val moved = chess(Side.FIRST, "e2e4", "e7e5", "g1f3") + assertTrue(ChatGames.canEnd(moved, ChatGames.DRAW_OFFER)) + val mine = ChatGames.ended(moved, ChatGames.DRAW_OFFER) + assertEquals(Side.FIRST, mine.offerBy) + assertEquals("You offered a draw", ChatGames.status(mine)) + val answer = arrayOf("e2e4", "e7e5", "g1f3") + val drawn = assertIs<ChatGames.Received.Ended>(take(mine, end(Side.SECOND, ChatGames.DRAW_ACCEPT, *answer))).state + assertEquals("Draw by agreement", ChatGames.result(drawn)) + assertNull(drawn.offerBy) + val refused = assertIs<ChatGames.Received.Quiet>(take(mine, end(Side.SECOND, ChatGames.DRAW_DECLINE, *answer))).state + assertNull(refused.offerBy) + assertFalse(refused.finished) + } + + @Test + fun `a draw offer counts only from the side that has just moved, at the whole log`() { + // It is their move: an offer now could race their move. + val theirMove = chess(Side.FIRST, "e2e4", "e7e5", "g1f3") + assertEquals(ChatGames.Received.Ignored, take(theirMove, end(Side.SECOND, ChatGames.DRAW_OFFER, "e2e4", "e7e5", "g1f3"))) + // Made after their last move, but I have moved since: it lapsed. + assertEquals(ChatGames.Received.Ignored, take(theirMove, end(Side.SECOND, ChatGames.DRAW_OFFER, "e2e4", "e7e5"))) + // Before the game was accepted there is nothing to offer a draw in. + val waiting = chess(Side.SECOND, "e2e4", startedByMe = false) + assertEquals(ChatGames.Received.Ignored, take(waiting, end(Side.FIRST, ChatGames.DRAW_OFFER, "e2e4"))) + } + + @Test + fun `a move answers an open draw offer, on either side`() { + val theirs = chess(Side.FIRST, "e2e4", "e7e5").copy(offerBy = Side.SECOND) + assertNull(assertNotNull(ChatGames.play(theirs, "g1f3")).offerBy) + val mine = chess(Side.FIRST, "e2e4").copy(offerBy = Side.FIRST) + assertNull(stateOf(take(mine, move(Side.SECOND, "e2e4", "e7e5"))).offerBy) + } + + @Test + fun `an ending that comes with their next move takes both`() { + val withMove = end(Side.SECOND, ChatGames.DRAW_OFFER, "e2e4", "e7e5") + val got = assertIs<ChatGames.Received.Offered>(take(chess(Side.FIRST, "e2e4"), withMove)) + assertEquals(listOf("e2e4", "e7e5"), got.state.log) + assertEquals(Side.SECOND, got.state.offerBy) + } + + @Test + fun `only the invitee declines, and only before their first move`() { + // I started and moved first; they decline before playing. + val waiting = chess(Side.FIRST, "e2e4") + val declined = assertIs<ChatGames.Received.Ended>(take(waiting, end(Side.SECOND, ChatGames.DECLINE, "e2e4"))).state + assertEquals("They declined", ChatGames.result(declined)) + // Too late once they have played. + val played = chess(Side.FIRST, "e2e4", "e7e5") + assertEquals(ChatGames.Received.Ignored, take(played, end(Side.SECOND, ChatGames.DECLINE, "e2e4", "e7e5"))) + // And never from the one who started it. + val invited = chess(Side.SECOND, "e2e4", startedByMe = false) + assertEquals(ChatGames.Received.Ignored, take(invited, end(Side.FIRST, ChatGames.DECLINE, "e2e4"))) + } + + @Test + fun `only the starter cancels, and only before the invitee has moved`() { + val invited = chess(Side.SECOND, "e2e4", startedByMe = false) + val cancelled = assertIs<ChatGames.Received.Ended>(take(invited, end(Side.FIRST, ChatGames.CANCEL, "e2e4"))).state + assertEquals("They cancelled", ChatGames.result(cancelled)) + assertEquals("They cancelled", ChatGames.status(cancelled)) + // Too late once I have played, in the log the cancel was sent at. + // The move it carries still counts. + val accepted = chess(Side.SECOND, "e2e4", "e7e5", startedByMe = false) + val late = assertIs<ChatGames.Received.Moved>(take(accepted, end(Side.FIRST, ChatGames.CANCEL, "e2e4", "e7e5", "g1f3"))).state + assertEquals(listOf("e2e4", "e7e5", "g1f3"), late.log) + assertNull(late.ending) + // And never from the one who was asked. + assertEquals(ChatGames.Received.Ignored, take(chess(Side.FIRST), end(Side.SECOND, ChatGames.CANCEL))) + } + + @Test + fun `a cancel sent before my first move counts even when my move went first here`() { + // DESIGN item 23: judged on the log the gend carries. They cancelled + // at e2e4 while my answer was on its way; their phone already shows + // it cancelled and ignores my move, so mine has to agree. + val answered = chess(Side.SECOND, "e2e4", "e7e5", startedByMe = false) + val cancelled = assertIs<ChatGames.Received.Ended>(take(answered, end(Side.FIRST, ChatGames.CANCEL, "e2e4"))).state + assertEquals("They cancelled", ChatGames.result(cancelled)) + } + + @Test + fun `a decline for a game this phone never saw rebuilds it already ended`() { + val got = assertIs<ChatGames.Received.Ended>(take(null, end(Side.SECOND, ChatGames.DECLINE, "e2e4"))).state + assertTrue(got.startedByMe, "only the invitee declines, so this phone started it") + assertEquals(Side.FIRST, got.me) + assertEquals("They declined", ChatGames.result(got)) + } + + // -- This person's own moves and endings -------------------------------------------. + + @Test + fun `I play only my own legal moves, and never past the end`() { + val start = chess(Side.FIRST) + assertEquals(listOf("e2e4"), ChatGames.play(start, "e2e4")!!.log) + assertNull(ChatGames.play(start, "e7e5"), "a move for the other side") + assertNull(ChatGames.play(start, "e2e5"), "an illegal move") + assertNull(ChatGames.play(chess(Side.SECOND), "e2e4"), "a move out of turn") + assertNull(ChatGames.play(chess(Side.FIRST).copy(outOfStep = true), "e2e4")) + assertNull(ChatGames.play(chess(Side.FIRST).copy(ending = ChatGames.CANCEL, endingBy = Side.FIRST), "e2e4")) + val mated = chess(Side.FIRST, "f2f3", "e7e5", "g2g4", "d8h4") + assertNull(ChatGames.play(mated, "e2e4")) + } + + @Test + fun `what I may send depends on where the game is`() { + fun allowed(state: ChatGames.State) = + listOf( + ChatGames.RESIGN, + ChatGames.DRAW_OFFER, + ChatGames.DRAW_ACCEPT, + ChatGames.DRAW_DECLINE, + ChatGames.DECLINE, + ChatGames.CANCEL, + ).filter { ChatGames.canEnd(state, it) } + .toSet() + + // Started by me: until they play, I can only take it back. + assertEquals(setOf(ChatGames.CANCEL), allowed(chess(Side.FIRST))) + assertEquals(setOf(ChatGames.CANCEL), allowed(chess(Side.FIRST, "e2e4"))) + // A draw is offered only on their move, after one of mine (DESIGN item 22). + assertEquals(setOf(ChatGames.RESIGN), allowed(chess(Side.FIRST, "e2e4", "e7e5"))) + assertEquals(setOf(ChatGames.RESIGN, ChatGames.DRAW_OFFER), allowed(chess(Side.FIRST, "e2e4", "e7e5", "g1f3"))) + // Asked by them: until I play, I can only decline. + assertEquals(setOf(ChatGames.DECLINE), allowed(chess(Side.SECOND, "e2e4", startedByMe = false))) + assertEquals(setOf(ChatGames.RESIGN, ChatGames.DRAW_OFFER), allowed(chess(Side.SECOND, "e2e4", "e7e5", startedByMe = false))) + // Their offer open. + assertEquals( + setOf(ChatGames.RESIGN, ChatGames.DRAW_ACCEPT, ChatGames.DRAW_DECLINE), + allowed(chess(Side.FIRST, "e2e4", "e7e5").copy(offerBy = Side.SECOND)), + ) + // Mine open: nothing to answer, no second offer. + assertEquals(setOf(ChatGames.RESIGN), allowed(chess(Side.FIRST, "e2e4", "e7e5", "g1f3").copy(offerBy = Side.FIRST))) + // Over, or out of step: nothing at all. + assertEquals(emptySet(), allowed(chess(Side.FIRST, "f2f3", "e7e5", "g2g4", "d8h4"))) + assertEquals(emptySet(), allowed(chess(Side.FIRST, "e2e4", "e7e5").copy(outOfStep = true))) + } + + @Test + fun `what goes on the wire is my side, the whole log, and the ending`() { + val state = chess(Side.SECOND, "e2e4", "e7e5", startedByMe = false) + assertEquals(Content.Game(id, "chess", "second", listOf("e2e4", "e7e5")), ChatGames.wire(state)) + assertEquals(Content.Game(id, "chess", "second", listOf("e2e4", "e7e5"), "resign"), ChatGames.wire(state, ChatGames.RESIGN)) + } + + // -- Words ----------------------------------------------------------------------------. + + @Test + fun `every natural end has words, from either side`() { + val wins = + mapOf( + WinReason.CHECKMATE to ("Checkmate. You won" to "Checkmate. They won"), + WinReason.NO_MOVES to ("You won. They have no moves left" to "They won. You have no moves left"), + WinReason.MORE_DISCS to ("You won with more discs" to "They won with more discs"), + WinReason.THREE_IN_A_ROW to ("You won with three in a row" to "They won with three in a row"), + ) + assertEquals(WinReason.entries.toSet(), wins.keys) + for ((reason, words) in wins) { + assertEquals(words.first, ChatGames.outcomeWords(Outcome.Win(Side.FIRST, reason), Side.FIRST)) + assertEquals(words.second, ChatGames.outcomeWords(Outcome.Win(Side.SECOND, reason), Side.FIRST)) + } + val draws = + mapOf( + DrawReason.STALEMATE to "Draw by stalemate", + DrawReason.INSUFFICIENT_MATERIAL to "Draw by insufficient material", + DrawReason.FIFTY_MOVES to "Draw by fifty moves", + DrawReason.FORTY_MOVES to "Draw by forty moves each without a capture", + DrawReason.REPETITION to "Draw by repetition", + DrawReason.EQUAL_DISCS to "Draw by equal discs", + DrawReason.FULL_BOARD to "Draw by full board", + ) + assertEquals(DrawReason.entries.toSet(), draws.keys) + for ((reason, words) in draws) assertEquals(words, ChatGames.outcomeWords(Outcome.Draw(reason), Side.SECOND)) + assertNull(ChatGames.outcomeWords(Outcome.Ongoing, Side.FIRST)) + } + + @Test + fun `the engines' own ends reach the card`() { + fun result( + type: GameType, + me: Side, + vararg log: String, + ) = ChatGames.result(ChatGames.State(id, type, me, log.toList(), startedByMe = true)) + + assertEquals("Checkmate. They won", result(GameType.CHESS, Side.FIRST, "f2f3", "e7e5", "g2g4", "d8h4")) + assertEquals( + "Draw by repetition", + result(GameType.CHESS, Side.FIRST, "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8"), + ) + assertEquals( + "Draw by stalemate", + result( + GameType.CHESS, + Side.SECOND, + "e2e3", "a7a5", "d1h5", "a8a6", "h5a5", "h7h5", "h2h4", "a6h6", "a5c7", "f7f6", + "c7d7", "e8f7", "d7b7", "d8d3", "b7b8", "d3h7", "b8c8", "f7g6", "c8e6", + ), + ) + assertEquals("They won with three in a row", result(GameType.TICTACTOE, Side.SECOND, "1", "4", "2", "5", "3")) + assertEquals("Draw by full board", result(GameType.TICTACTOE, Side.FIRST, "1", "2", "3", "5", "4", "6", "8", "7", "9")) + assertEquals("You won with more discs", result(GameType.REVERSI, Side.FIRST, "d3", "c3", "b3", "d2", "e1", "d6", "d7", "e3", "f4")) + assertNull(result(GameType.CHECKERS, Side.FIRST, "11-15")) + } + + @Test + fun `endings say who, or only what when a backup did not say who`() { + val base = chess(Side.FIRST, "e2e4", "e7e5") + fun said( + ending: String, + by: Side?, + ) = ChatGames.result(base.copy(ending = ending, endingBy = by)) + + assertEquals("You resigned. They won", said(ChatGames.RESIGN, Side.FIRST)) + assertEquals("They resigned. You won", said(ChatGames.RESIGN, Side.SECOND)) + assertEquals("Resigned", said(ChatGames.RESIGN, null)) + assertEquals("Draw by agreement", said(ChatGames.DRAW_ACCEPT, null)) + assertEquals("You declined", said(ChatGames.DECLINE, Side.FIRST)) + assertEquals("They declined", said(ChatGames.DECLINE, Side.SECOND)) + assertEquals("Declined", said(ChatGames.DECLINE, null)) + assertEquals("You cancelled", said(ChatGames.CANCEL, Side.FIRST)) + assertEquals("They cancelled", said(ChatGames.CANCEL, Side.SECOND)) + assertEquals("Cancelled", said(ChatGames.CANCEL, null)) + } + + @Test + fun `the line under the board`() { + assertEquals("Your move", ChatGames.status(chess(Side.FIRST, "e2e4", "e7e5"))) + assertEquals("Their move", ChatGames.status(chess(Side.FIRST, "e2e4", "e7e5", "g1f3"))) + assertEquals("Waiting for them to play or decline", ChatGames.status(chess(Side.FIRST, "e2e4"))) + assertEquals("Waiting for them to play or decline", ChatGames.status(chess(Side.SECOND))) + assertEquals("Your move. Playing it accepts the game", ChatGames.status(chess(Side.SECOND, "e2e4", startedByMe = false))) + assertEquals("Your boards disagree", ChatGames.status(chess(Side.FIRST, "e2e4").copy(outOfStep = true))) + } + + @Test + fun `names and sides read as the games call them`() { + assertEquals(listOf("Chess", "Checkers", "Reversi", "Tic-tac-toe"), GameType.entries.map(ChatGames::name)) + assertEquals("tic-tac-toe", ChatGames.lowerName(GameType.TICTACTOE)) + assertEquals( + listOf("white", "black", "dark", "light", "black", "white", "X", "O"), + GameType.entries.flatMap { type -> Side.entries.map { ChatGames.sideName(type, it) } }, + ) + } + + // -- On a history row ------------------------------------------------------------. + + @Test + fun `a game goes onto a history row and comes back whole`() { + val state = + ChatGames.State( + id = id, + type = GameType.REVERSI, + me = Side.SECOND, + log = listOf("d3", "c3"), + startedByMe = true, + ending = ChatGames.RESIGN, + endingBy = Side.FIRST, + offerBy = Side.SECOND, + outOfStep = true, + ) + val row = ChatGames.toRow(state, JSONObject().put("mine", true)) + val game = row.getJSONObject(ChatGames.ROW_GAME) + assertEquals(listOf("id", "log", "me", "r", "rby", "t"), game.keys().asSequence().sorted().toList()) + assertEquals(state, ChatGames.fromRow(row)) + + // Cleared state leaves no key behind. + val calm = ChatGames.toRow(state.copy(offerBy = null, outOfStep = false, ending = null, endingBy = null), row) + assertFalse(calm.has(ChatGames.ROW_OFFER)) + assertFalse(calm.has(ChatGames.ROW_OUT_OF_STEP)) + assertFalse(calm.getJSONObject(ChatGames.ROW_GAME).has("r")) + assertFalse(calm.getJSONObject(ChatGames.ROW_GAME).has("rby")) + } + + @Test + fun `a row from a backup without who ended it still reads`() { + val row = + JSONObject() + .put("mine", false) + .put( + ChatGames.ROW_GAME, + JSONObject() + .put("id", "g-9") + .put("t", "chess") + .put("me", "first") + .put("log", JSONArray(listOf("e2e4"))) + .put("r", "resign"), + ) + val state = assertNotNull(ChatGames.fromRow(row)) + assertEquals(ChatGames.RESIGN, state.ending) + assertNull(state.endingBy) + assertFalse(state.startedByMe) + assertEquals("Resigned", ChatGames.result(state)) + } + + @Test + fun `a row that holds no readable game gives none`() { + fun row(game: String) = JSONObject().put(ChatGames.ROW_GAME, JSONObject(game)) + assertNull(ChatGames.fromRow(JSONObject().put("text", "hi"))) + assertNull(ChatGames.fromRow(row("""{"id":"","t":"chess","me":"first","log":[]}"""))) + assertNull(ChatGames.fromRow(row("""{"id":"g","t":"go","me":"first","log":[]}"""))) + assertNull(ChatGames.fromRow(row("""{"id":"g","t":"chess","me":"white","log":[]}"""))) + assertNull(ChatGames.fromRow(row("""{"id":"g","t":"chess","me":"first","log":["e2e4",1]}"""))) + // An ending this build does not know is not taken as one. + assertNull(ChatGames.fromRow(row("""{"id":"g","t":"chess","me":"first","log":[],"r":"surrender"}"""))!!.ending) + } + + @Test + fun `a game whose id is a space comes back off its row (DESIGN 36)`() { + val row = JSONObject().put(ChatGames.ROW_GAME, JSONObject("""{"id":" ","t":"chess","me":"first","log":["e2e4"]}""")) + assertEquals(" ", ChatGames.fromRow(row)?.id, "a game taken off the wire vanished at the next read") + } + + // -- Shapes and timing (DESIGN 37) ----------------------------------------------. + + @Test + fun `a move with no log or an empty one, and an ending with no log, are dropped`() { + // Each would make a board out of nothing, or end one at no position. + assertEquals(ChatGames.Received.Ignored, ChatGames.receive(null, Content.Kind.GAME_MOVE, Content.Game(id, "chess", "first", null))) + assertEquals(ChatGames.Received.Ignored, ChatGames.receive(null, Content.Kind.GAME_MOVE, move(Side.FIRST))) + assertEquals( + ChatGames.Received.Ignored, + ChatGames.receive(null, Content.Kind.GAME_END, Content.Game(id, "chess", "second", null, ChatGames.DECLINE)), + ) + val playing = chess(Side.FIRST, "e2e4", "e7e5") + assertEquals(ChatGames.Received.Ignored, take(playing, move(Side.SECOND))) + assertEquals( + ChatGames.Received.Ignored, + ChatGames.receive(playing, Content.Kind.GAME_END, Content.Game(id, "chess", "second", null, ChatGames.RESIGN)), + "a resignation at no position ended the game", + ) + // An ending with an empty log is a real one: a decline before any move. + val waiting = chess(Side.FIRST) + assertIs<ChatGames.Received.Ended>(take(waiting, end(Side.SECOND, ChatGames.DECLINE))) + } + + @Test + fun `a draw accept counts only at my whole log`() { + val mine = ChatGames.ended(chess(Side.FIRST, "e2e4", "e7e5", "g1f3"), ChatGames.DRAW_OFFER) + assertEquals(Side.FIRST, mine.offerBy) + assertEquals(ChatGames.Received.Ignored, take(mine, end(Side.SECOND, ChatGames.DRAW_ACCEPT, "e2e4", "e7e5"))) + assertEquals(ChatGames.Received.Ignored, take(mine, end(Side.SECOND, ChatGames.DRAW_ACCEPT, "e2e4"))) + assertEquals(ChatGames.Received.Ignored, take(mine, end(Side.SECOND, ChatGames.DRAW_ACCEPT))) + assertIs<ChatGames.Received.Ended>(take(mine, end(Side.SECOND, ChatGames.DRAW_ACCEPT, "e2e4", "e7e5", "g1f3"))) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/DeleteForMeOffTheRecordTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/DeleteForMeOffTheRecordTest.kt new file mode 100644 index 0000000..f880d24 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/DeleteForMeOffTheRecordTest.kt @@ -0,0 +1,83 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayClient +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Vault +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import javax.crypto.KeyGenerator +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * "Delete for me" works where the line lives (§6.28). + * + * An off-the-record line is held in memory, so that is where it is deleted. + * Deleting through the vault would leave it on screen and write the + * conversation's history record, empty, to disk: a record naming the person + * for a conversation whose one promise is that nothing about it is written. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class DeleteForMeOffTheRecordTest { + private class MapStorage : Vault.Storage { + val records = mutableMapOf<String, ByteArray>() + + override fun read(name: String) = records[name] + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + + override fun names() = records.keys.toList() + } + + private val storage = MapStorage() + private val vault = Vault(storage, KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + + private fun session(): Session { + val identity = DeviceIdentity.create() + vault.saveIdentity(identity) + return Session("alice", 1, identity, vault, RelayClient("http://127.0.0.1:9/", "a", "b"), MessengerApi("http://127.0.0.1:9/", "a", "b")) + } + + @Test + fun `deleting an off-the-record message removes it and writes nothing`() { + val thread = Vault.threadForUser(99) + vault.saveOffTheRecord(thread, on = true) + val s = session() + s.record(thread, "meet at nine", mine = false, author = 99, at = 1_000) + s.record(thread, "see you there", mine = true, at = 2_000) + val before = storage.records.mapValues { it.value.toList() } + + assertTrue(s.deleteMessage(thread, at = 1_000, mine = false, text = "meet at nine")) + + assertEquals(listOf("see you there"), s.history(thread).map { it.text }, "the line is still on screen") + assertFalse(storage.names().any { it.startsWith("history/") }, "a history record reached the disk") + assertEquals(before, storage.records.mapValues { it.value.toList() }, "deleting wrote something") + } + + @Test + fun `deleting an ordinary message still deletes it from the vault`() { + val thread = Vault.threadForUser(98) + val s = session() + s.record(thread, "keep", mine = true, at = 1_000) + s.record(thread, "drop", mine = true, at = 2_000) + + assertTrue(s.deleteMessage(thread, at = 2_000, mine = true, text = "drop")) + assertFalse(s.deleteMessage(thread, at = 2_000, mine = true, text = "drop"), "deleted twice") + + assertEquals(listOf("keep"), vault.history(thread).let { a -> (0 until a.length()).map { a.getJSONObject(it).getString("text") } }) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/DirectoryShareTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/DirectoryShareTest.kt new file mode 100644 index 0000000..815d2cd --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/DirectoryShareTest.kt @@ -0,0 +1,165 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayClient +import io.flamenet.e2e.Content +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.json.JSONObject +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import javax.crypto.KeyGenerator +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * What sending a directory site to a chat puts on the wire (SPEC §6.12, + * DESIGN 5): an ordinary text through the ordinary send path, carrying a + * preview built from the entry, and nothing fetched to make it. + * + * The site's address points at a server of its own here, so any fetch of the + * page, by the share or by anything under it, is counted. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class DirectoryShareTest { + private lateinit var relay: MockWebServer + private lateinit var site: MockWebServer + + private class NoKeyLog : okhttp3.mockwebserver.QueueDispatcher() { + override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse = + if ("/e2e/log/" in request.path.orEmpty()) { + MockResponse().setResponseCode(404) + } else { + super.dispatch(request) + } + } + + @BeforeTest fun start() { + relay = MockWebServer().also { + it.dispatcher = NoKeyLog() + it.start() + } + site = MockWebServer().also { it.start() } + } + + @AfterTest fun stop() { + relay.shutdown() + site.shutdown() + } + + private class MapStorage : Vault.Storage { + private val records = mutableMapOf<String, ByteArray>() + + override fun read(name: String) = records[name] + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + + override fun names() = records.keys.toList() + } + + private fun session(): Session { + val identity = DeviceIdentity.create() + val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + val vault = Vault(MapStorage(), key).also { it.saveIdentity(identity) } + val base = relay.url("/").toString() + return Session( + handle = "alice", + userId = 1, + identity = identity, + vault = vault, + relay = RelayClient(base, "alice", "app pass word"), + api = MessengerApi(base, "alice", "app pass word"), + ) + } + + private val peer = 99L + + @Test + fun `a share is one ordinary text with the entry's preview, and nothing is fetched`() { + val s = session() + relay.enqueue(MockResponse().setResponseCode(200).setHeader("Content-Type", "application/json").setBody("""{"devices":[]}""")) + val url = site.url("/").toString() + val entry = Directory.Site("A small site", url, "FlameNet's own line about it.", listOf("arts")) + + val sent = DirectoryShare.send(s, peer, entry, withPreview = true) + + // Nobody has a device, so it waits in the outbox exactly as it would go. + assertFalse(sent.reachedSomeone) + val plaintext = s.vault.outbox().getJSONObject(0).getString("text") + val o = JSONObject(plaintext) + assertEquals(setOf("fnc", "kind", "mid", "caption", "lnk"), o.keys().asSequence().toSet()) + assertEquals(1, o.getInt("fnc")) + assertEquals("text", o.getString("kind")) + assertEquals(url, o.getString("caption")) + val lnk = o.getJSONObject("lnk") + assertEquals(setOf("url", "title", "desc"), lnk.keys().asSequence().toSet()) + assertEquals(url, lnk.getString("url")) + assertEquals("A small site", lnk.getString("title")) + assertEquals("", lnk.getString("desc"), "the description went into the preview") + assertFalse(plaintext.contains("own line"), "the directory's words about the site were sent") + // The text path's own order: fnc, kind, mid, caption, then lnk. + assertTrue(plaintext.startsWith("{\"fnc\":1,\"kind\":\"text\",\"mid\":\""), plaintext) + assertTrue(plaintext.indexOf("\"caption\":") < plaintext.indexOf("\"lnk\":"), plaintext) + + // A receiver reads it as a text with a preview it does not fetch. + val received = Content.parse(plaintext) + assertEquals(url, received.text) + assertEquals(Content.LinkPreview(url, "A small site", ""), received.link) + assertTrue(received.isRenderable) + + // The page itself was never asked for. + assertEquals(0, site.requestCount, "something fetched the shared page") + // And the copy in my own thread says the same. + val line = s.history(Vault.threadForUser(peer)).single() + assertEquals(url, line.text) + assertEquals(Content.LinkPreview(url, "A small site", ""), line.link) + assertTrue(line.mine) + } + + /** Previews off means the address alone, as if it had been typed (DESIGN 18). */ + @Test + fun `with link previews off the address goes alone`() { + val s = session() + relay.enqueue(MockResponse().setResponseCode(200).setHeader("Content-Type", "application/json").setBody("""{"devices":[]}""")) + val url = site.url("/").toString() + DirectoryShare.send(s, peer, Directory.Site("A small site", url, "A line.", emptyList()), withPreview = false) + val o = JSONObject(s.vault.outbox().getJSONObject(0).getString("text")) + assertEquals(setOf("fnc", "kind", "mid", "caption"), o.keys().asSequence().toSet()) + assertEquals(url, o.getString("caption")) + assertEquals(0, site.requestCount) + } + + /** The send path the screen calls is this one, with nothing between that fetches. */ + @Test + fun `the directory's send goes straight to the share, never through the preview fetcher`() { + val vm = + File("src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt").readText() + val share = vm.substringAfter("fun shareSite(", "").substringBefore("\n }\n") + assertTrue(share.isNotEmpty(), "shareSite is gone from the view model") + assertTrue(share.contains("DirectoryShare.send("), "shareSite does not use DirectoryShare.send") + assertTrue(share.contains("mediaSettings.linkPreviews"), "shareSite ignores the link preview setting") + for (fetch in listOf("linkPreviewFor", "LinkPreviewFetcher")) { + assertFalse(share.contains(fetch), "shareSite reaches $fetch, which fetches the page") + } + val shareFile = File("src/main/kotlin/io/flamenet/messenger/app/DirectoryShare.kt").readText() + assertFalse(shareFile.contains("LinkPreviewFetcher"), "DirectoryShare fetches the page") + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/DirectoryTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/DirectoryTest.kt new file mode 100644 index 0000000..b2a7ebf --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/DirectoryTest.kt @@ -0,0 +1,125 @@ +package io.flamenet.messenger.app + +import io.flamenet.e2e.Content +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The small-web directory shipped in the app (DESIGN 5): the file itself, + * browsing it, searching it, and what a share of one site would say. + */ +class DirectoryTest { + /** The very file the APK carries, read from the source tree. */ + private val shipped: Directory by lazy { + val file = File("src/main/assets/${Directory.ASSET}") + assertTrue(file.isFile, "the directory is not shipped at src/main/assets/${Directory.ASSET}") + Directory.parse(file.readText()) + } + + private fun site(title: String) = shipped.sites.single { it.title == title } + + @Test + fun `the shipped file loads whole`() { + assertEquals(2964, shipped.sites.size) + assertEquals(90, shipped.categories.size) + assertEquals(15, shipped.top.size) + assertTrue(shipped.note!!.startsWith("Descriptions here were drafted"), "the note that labels the descriptions is missing") + assertEquals("https://flamenet.io/directory/", shipped.from) + } + + @Test + fun `every shipped site can be opened, and stays inside its bounds`() { + for (s in shipped.sites) { + assertTrue(Directory.isWebAddress(s.url), s.url) + assertTrue(s.title.length <= Directory.MAX_TITLE, s.title) + assertTrue((s.description?.length ?: 0) <= Directory.MAX_DESC, s.title) + assertTrue(s.description == null || s.description!!.isNotBlank(), "an empty description on ${s.title}") + } + // `d` is optional: absent, never empty. + assertEquals(126, shipped.sites.count { it.description == null }) + } + + @Test + fun `browsing starts at the top of the tree, in the file's order`() { + assertEquals("arts-humanities", shipped.top.first().id) + assertEquals("Arts & Humanities", shipped.top.first().name) + assertTrue(shipped.children("arts-humanities").any { it.id == "architecture" }) + assertTrue(shipped.children("architecture").isEmpty()) + } + + @Test + fun `a category lists its own sites by title, and counts everything under it once`() { + val architecture = shipped.sitesIn("architecture") + assertEquals(45, architecture.size) + assertEquals(listOf("ArchDaily", "Archidose", "ArchiExpo Virtual Fair"), architecture.take(3).map { it.title }) + assertEquals(349, shipped.count("arts-humanities")) + assertEquals(45, shipped.count("architecture")) + // A site filed in two places is in both lists. + val smithsonian = site("Smithsonian Magazine") + assertTrue(smithsonian in shipped.sitesIn("arts-humanities")) + assertTrue(smithsonian in shipped.sitesIn("magazines")) + } + + @Test + fun `a site with no category is found by search only`() { + val loose = shipped.sites.filter { it.categories.isEmpty() } + assertEquals(26, loose.size) + val inSomeCategory = shipped.categories.flatMap { shipped.sitesIn(it.id) }.toSet() + for (s in loose) { + assertTrue(s !in inSomeCategory, "${s.title} turned up while browsing") + assertTrue(s in shipped.search(s.title), "${s.title} cannot be found by its own name") + } + } + + @Test + fun `search matches titles first, then hosts, then descriptions`() { + assertEquals("ArchDaily", shipped.search("archdaily").first().title) + assertEquals("Smithsonian Magazine", shipped.search("smithsonianmag").first().title) + val architecture = shipped.search("architecture") + assertTrue(architecture.first().title.lowercase().contains("architecture"), architecture.first().title) + assertTrue(architecture.any { it.title == "ArchDaily" }, "a description match was missed") + } + + @Test + fun `search ignores case and accents and needs every word`() { + assertTrue(shipped.search("APPETIT").any { it.title == "Epicurious" }, "an accent kept a match out") + assertTrue(shipped.search("bon appetit recipes").any { it.title == "Epicurious" }) + assertTrue(shipped.search("bon appetit zzzzzz").isEmpty()) + assertEquals("cafe muller", Directory.fold(" Café Müller ")) + } + + @Test + fun `an empty search finds nothing, and a broad one stops at the limit`() { + assertTrue(shipped.search(" ").isEmpty()) + assertEquals(Directory.SEARCH_LIMIT, shipped.search("e").size) + } + + @Test + fun `a share is the address and a preview of the title, never the description`() { + val archdaily = site("ArchDaily") + assertTrue(archdaily.description!!.isNotEmpty(), "the test needs a site with a description") + assertEquals("https://www.archdaily.com/", DirectoryShare.text(archdaily)) + assertEquals( + Content.LinkPreview("https://www.archdaily.com/", "ArchDaily", ""), + DirectoryShare.preview(archdaily), + ) + } + + @Test + fun `a site that cannot be opened or has no title is left out of the file`() { + val d = + Directory.parse( + """{"v":1,"made":"2026-10-04","from":"https://flamenet.io/directory/", + "cats":[{"id":"a","name":"A","parent":""}], + "sites":[{"t":"Good","u":"https://good.example/","c":["a","missing"]}, + {"t":"Script","u":"javascript:alert(1)","c":["a"]}, + {"t":"","u":"https://untitled.example/","c":["a"]}, + {"t":"File","u":"file:///etc/hosts","c":["a"]}]}""", + ) + assertEquals(listOf("Good"), d.sites.map { it.title }) + assertEquals(listOf("a"), d.sites.single().categories, "a category that does not exist was kept") + assertEquals(null, d.note) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/GameBoardTapsTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/GameBoardTapsTest.kt new file mode 100644 index 0000000..55e9337 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/GameBoardTapsTest.kt @@ -0,0 +1,99 @@ +package io.flamenet.messenger.app + +import io.flamenet.games.GameType +import io.flamenet.games.Games +import io.flamenet.games.Piece +import io.flamenet.games.PieceKind +import io.flamenet.games.Side +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * How taps on a board become moves (§6.33): worked out from the engine's own + * legal moves, so whatever is sent is exactly a string the engine offered. A + * checkers jump is entered square by square, a promotion asks which piece, + * and pass is never a square. + */ +class GameBoardTapsTest { + private fun movesAt( + type: GameType, + setup: String, + ): List<String> = Games.legalMoves(assertNotNull(Games.position(type, setup), setup)) + + @Test + fun `checkers, a multi-jump is entered square by square and sent as one move`() { + val moves = movesAt(GameType.CHECKERS, "B:W14,22,23:B9") + assertEquals(setOf("9x18x25", "9x18x27"), moves.toSet()) + val pick = MoveSelection(GameType.CHECKERS, moves) + assertEquals(emptySet(), pick.targets(emptyList()), "nothing is marked before a piece is chosen") + assertEquals(MoveSelection.Next.Select(listOf("9")), pick.tap(emptyList(), "9")) + assertEquals(setOf("18"), pick.targets(listOf("9"))) + assertEquals(MoveSelection.Next.Select(listOf("9", "18")), pick.tap(listOf("9"), "18")) + assertEquals(setOf("25", "27"), pick.targets(listOf("9", "18"))) + assertEquals(MoveSelection.Next.Play("9x18x27"), pick.tap(listOf("9", "18"), "27")) + } + + @Test + fun `checkers, a shorter capture beside a longer one is played at once`() { + val moves = movesAt(GameType.CHECKERS, "B:W14,16,23,26:B9,12") + val pick = MoveSelection(GameType.CHECKERS, moves) + assertEquals(MoveSelection.Next.Play("12x19"), pick.tap(listOf("12"), "19")) + assertEquals(MoveSelection.Next.Select(listOf("12")), pick.tap(listOf("9"), "12"), "another piece is chosen instead") + assertEquals(MoveSelection.Next.Select(emptyList()), pick.tap(listOf("9"), "9"), "the same piece again lets go") + assertEquals(MoveSelection.Next.Select(emptyList()), pick.tap(emptyList(), "30"), "a square with no move of mine") + } + + @Test + fun `chess, a promotion asks which piece`() { + val moves = movesAt(GameType.CHESS, "8/4P3/8/8/8/8/k7/7K w - - 0 1") + val pick = MoveSelection(GameType.CHESS, moves) + assertEquals(setOf("e8"), pick.targets(listOf("e7"))) + assertEquals(MoveSelection.Next.Choose(listOf("e7e8q", "e7e8r", "e7e8b", "e7e8n")), pick.tap(listOf("e7"), "e8")) + assertEquals(setOf("g1", "g2", "h2"), pick.targets(listOf("h1"))) + assertEquals(MoveSelection.Next.Play("h1g1"), pick.tap(listOf("h1"), "g1")) + } + + @Test + fun `chess, tapping another of my pieces chooses it, and an empty square lets go`() { + val pick = MoveSelection(GameType.CHESS, Games.legalMoves(Games.start(GameType.CHESS))) + assertEquals(MoveSelection.Next.Select(listOf("e2")), pick.tap(emptyList(), "e2")) + assertEquals(setOf("e3", "e4"), pick.targets(listOf("e2"))) + assertEquals(MoveSelection.Next.Select(listOf("g1")), pick.tap(listOf("e2"), "g1")) + assertEquals(MoveSelection.Next.Select(emptyList()), pick.tap(listOf("g1"), "g1")) + assertEquals(MoveSelection.Next.Select(emptyList()), pick.tap(listOf("e2"), "e5")) + assertEquals(MoveSelection.Next.Play("e2e4"), pick.tap(listOf("e2"), "e4")) + } + + @Test + fun `reversi and tic-tac-toe play the tapped square, and pass is never a square`() { + val reversi = MoveSelection(GameType.REVERSI, Games.legalMoves(Games.start(GameType.REVERSI))) + assertEquals(setOf("d3", "c4", "f5", "e6"), reversi.targets(emptyList())) + assertEquals(MoveSelection.Next.Play("d3"), reversi.tap(emptyList(), "d3")) + assertEquals(MoveSelection.Next.Select(emptyList()), reversi.tap(emptyList(), "a1")) + + val passOnly = movesAt(GameType.REVERSI, "ox------/--------/--------/--------/--------/--------/--------/-------- x") + assertEquals(listOf("pass"), passOnly) + assertEquals(emptySet(), MoveSelection(GameType.REVERSI, passOnly).targets(emptyList())) + + val ttt = MoveSelection(GameType.TICTACTOE, Games.legalMoves(Games.start(GameType.TICTACTOE))) + assertEquals((1..9).map { it.toString() }.toSet(), ttt.targets(emptyList())) + assertEquals(MoveSelection.Next.Play("5"), ttt.tap(emptyList(), "5")) + } + + @Test + fun `every piece is said in words for TalkBack`() { + assertEquals("white knight", describe(GameType.CHESS, Piece(Side.FIRST, PieceKind.KNIGHT))) + assertEquals("black pawn", describe(GameType.CHESS, Piece(Side.SECOND, PieceKind.PAWN))) + assertEquals("dark man", describe(GameType.CHECKERS, Piece(Side.FIRST, PieceKind.MAN))) + assertEquals("light king", describe(GameType.CHECKERS, Piece(Side.SECOND, PieceKind.KING))) + assertEquals("black disc", describe(GameType.REVERSI, Piece(Side.FIRST, PieceKind.DISC))) + assertEquals("O", describe(GameType.TICTACTOE, Piece(Side.SECOND, PieceKind.MARK))) + } + + @Test + fun `the move list numbers each turn`() { + assertEquals("1. e2e4 e7e5\n2. g1f3", moveList(listOf("e2e4", "e7e5", "g1f3"))) + assertEquals("", moveList(emptyList())) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/GameScreensTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/GameScreensTest.kt new file mode 100644 index 0000000..eb0742a --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/GameScreensTest.kt @@ -0,0 +1,345 @@ +package io.flamenet.messenger.app + +import android.graphics.Bitmap +import android.graphics.Canvas +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import io.flamenet.client.RelayClient +import io.flamenet.games.GameType +import io.flamenet.games.Games +import io.flamenet.games.Side +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * What a game looks like and how it is played on the screen (§6.33): each + * board in both themes, the second player's board turned round, a jump and a + * promotion entered by taps, the lone Pass, the controls in one row, a + * finished game and one whose boards disagree. Pictures go to + * build/screenshots/games. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w375dp-h812dp-normal-long-notround-any-440dpi-keyshidden-nonav") +class GameScreensTest { + @get:Rule val compose = createAndroidComposeRule<ComponentActivity>() + + private val out = File("build/screenshots/games").also { it.mkdirs() } + + private val moves = mutableListOf<String>() + private val endings = mutableListOf<String>() + private var newGames = 0 + + private fun state( + type: GameType, + me: Side, + log: List<String> = emptyList(), + startedByMe: Boolean = true, + ) = ChatGames.State("g-1", type, me, log, startedByMe) + + private fun theme(dark: Boolean) { + DisplaySettings(compose.activity).appearance = if (dark) DisplaySettings.Appearance.DARK else DisplaySettings.Appearance.LIGHT + } + + private fun show( + game: ChatGames.State, + dark: Boolean = false, + ) = setContent(dark) { + GameBubble( + game, + peerName = "Sam", + onMove = { moves += it }, + onEnd = { endings += it }, + onNewGame = { newGames++ }, + onLongPress = {}, + ) + } + + /** What is on the screen now. A test sets content once, so later screens swap in through this. */ + private var screen by mutableStateOf<(@Composable ColumnScope.() -> Unit)?>(null) + private var shown = false + + private fun setContent( + dark: Boolean, + content: @Composable ColumnScope.() -> Unit, + ) { + theme(dark) + screen = content + if (!shown) { + shown = true + compose.setContent { + // Keyed on the screen, so the theme reads the appearance again + // and nothing remembered carries over from the last one. + key(screen) { + MessengerTheme { + Surface(modifier = Modifier.fillMaxSize()) { Column(Modifier.padding(16.dp)) { screen?.invoke(this) } } + } + } + } + } + compose.waitForIdle() + } + + /** Draw the window, as the app's other screenshots do. */ + private fun shoot(name: String) { + compose.waitForIdle() + var shot: Bitmap? = null + compose.activityRule.scenario.onActivity { activity -> + val view = activity.window.decorView + val bitmap = Bitmap.createBitmap(view.width.coerceAtLeast(1), view.height.coerceAtLeast(1), Bitmap.Config.ARGB_8888) + view.draw(Canvas(bitmap)) + shot = bitmap + } + val file = File(out, "$name.png") + file.outputStream().use { shot!!.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("wrote ${file.absolutePath}") + } + + private fun square(name: String) = compose.onNodeWithTag("square-$name", useUnmergedTree = true) + + private fun said(node: SemanticsNodeInteraction): String = + node.fetchSemanticsNode().config[SemanticsProperties.ContentDescription].joinToString() + + private fun top(name: String) = square(name).getUnclippedBoundsInRoot().top + + private fun left(name: String) = square(name).getUnclippedBoundsInRoot().left + + // -- Each board ---------------------------------------------------------------------. + + @Test + fun `chess, a piece chosen shows where it can go, and the board says so in words`() { + for (dark in listOf(false, true)) { + moves.clear() + show(state(GameType.CHESS, Side.FIRST, listOf("e2e4", "e7e5")), dark) + square("g1").performClick() + assertEquals("g1, white knight, selected", said(square("g1"))) + assertEquals("f3, empty, can move here", said(square("f3"))) + assertEquals("e5, black pawn", said(square("e5"))) + assertTrue(top("a8") < top("a1"), "white's board has black at the top") + shoot(if (dark) "chess-dark" else "chess-light") + square("f3").performClick() + assertEquals(listOf("g1f3"), moves) + } + } + + @Test + fun `chess, a promotion asks which piece and sends exactly that move`() { + val log = listOf("b2b4", "a7a5", "f2f3", "a5b4", "g2g4", "b4b3", "a2a3", "b3c2", "a3a4") + show(state(GameType.CHESS, Side.SECOND, log, startedByMe = false)) + assertTrue(top("a1") < top("a8"), "black's board is not turned round") + square("c2").performClick() + square("b1").performClick() + compose.onNodeWithText("Promote to").assertExists() + compose.onNodeWithTag("promote-n", useUnmergedTree = true).assertExists() + compose.onNodeWithTag("promote-q", useUnmergedTree = true).performClick() + assertEquals(listOf("c2b1q"), moves) + } + + @Test + fun `checkers, the second player's board turned round, and a double jump entered square by square`() { + val log = listOf("9-14", "22-17", "6-9", "23-19", "9-13", "19-16", "13x22") + val position = (Games.replay(GameType.CHECKERS, log) as io.flamenet.games.ReplayResult.Replayed).position + assertTrue("25x18x9" in Games.legalMoves(position)) + for (dark in listOf(false, true)) { + moves.clear() + show(state(GameType.CHECKERS, Side.SECOND, log, startedByMe = false), dark) + assertTrue(top("1") < top("32"), "light's board is not turned round") + square("25").performClick() + square("18").performClick() + assertEquals("25, light man, selected", said(square("25"))) + assertEquals("18, empty, selected", said(square("18"))) + assertEquals("9, empty, can move here", said(square("9"))) + assertEquals(emptyList(), moves, "half a jump was sent") + shoot(if (dark) "checkers-dark" else "checkers-light") + square("9").performClick() + assertEquals(listOf("25x18x9"), moves) + } + } + + @Test + fun `reversi marks the legal squares, and a forced pass is a lone button`() { + for (dark in listOf(false, true)) { + show(state(GameType.REVERSI, Side.FIRST), dark) + assertEquals("d3, empty, can move here", said(square("d3"))) + assertEquals("d4, white disc", said(square("d4"))) + compose.onNodeWithTag("game-pass").assertDoesNotExist() + shoot(if (dark) "reversi-dark" else "reversi-light") + } + moves.clear() + val toPass = listOf("c4", "e3", "f4", "c5", "d2", "f2", "c6", "d6", "e6", "c1", "g1", "g5", "g3", "b6", "a7") + show(state(GameType.REVERSI, Side.SECOND, toPass)) + assertEquals("a8, empty", said(square("a8"))) + shoot("reversi-pass") + compose.onNodeWithTag("game-pass").performClick() + assertEquals(listOf("pass"), moves) + } + + @Test + fun `tic-tac-toe takes a tapped cell`() { + for (dark in listOf(false, true)) { + moves.clear() + show(state(GameType.TICTACTOE, Side.FIRST, listOf("5", "1")), dark) + assertEquals("5, X", said(square("5"))) + assertEquals("1, O", said(square("1"))) + shoot(if (dark) "tictactoe-dark" else "tictactoe-light") + square("9").performClick() + assertEquals(listOf("9"), moves) + } + } + + /** DESIGN 30: tic-tac-toe is never turned round, so a cell is in the same place on both phones. */ + @Test + fun `tic-tac-toe keeps its cells in place for the second player too`() { + show(state(GameType.TICTACTOE, Side.SECOND, listOf("5"), startedByMe = false)) + assertTrue(top("1") < top("7"), "the second player's tic-tac-toe board is turned upside down") + assertTrue(left("1") < left("3"), "the second player's tic-tac-toe board is turned round") + assertEquals(false, ChatGames.turnedRound(state(GameType.TICTACTOE, Side.SECOND))) + for (type in listOf(GameType.CHESS, GameType.CHECKERS, GameType.REVERSI)) { + assertEquals(true, ChatGames.turnedRound(state(type, Side.SECOND)), "$type") + assertEquals(false, ChatGames.turnedRound(state(type, Side.FIRST)), "$type") + } + } + + // -- The card around the board ----------------------------------------------------. + + @Test + fun `an invitation is accepted by playing, or declined`() { + show(state(GameType.CHESS, Side.SECOND, listOf("e2e4"), startedByMe = false)) + compose.onNodeWithText("Chess with Sam", useUnmergedTree = true).assertExists() + status().assertTextIs("Your move. Playing it accepts the game") + compose.onNodeWithText("You play black", useUnmergedTree = true).assertExists() + shoot("invite") + compose.onNodeWithTag("game-resign").assertDoesNotExist() + compose.onNodeWithTag("game-decline").performClick() + assertEquals(listOf(ChatGames.DECLINE), endings) + } + + @Test + fun `the starter can take it back until the first answer`() { + show(state(GameType.TICTACTOE, Side.FIRST, listOf("5"))) + status().assertTextIs("Waiting for them to play or decline") + compose.onNodeWithTag("game-cancel").performClick() + assertEquals(listOf(ChatGames.CANCEL), endings) + } + + @Test + fun `resigning asks first, and an offer is answered from one row`() { + show(state(GameType.CHESS, Side.FIRST, listOf("e2e4", "e7e5"))) + compose.onNodeWithTag("game-resign").performClick() + compose.onNodeWithText("Resign this game?").assertExists() + compose.onNodeWithTag("game-resign-confirm").performClick() + assertEquals(listOf(ChatGames.RESIGN), endings) + + endings.clear() + show(state(GameType.CHESS, Side.FIRST, listOf("e2e4", "e7e5")).copy(offerBy = Side.SECOND)) + status().assertTextIs("They offered a draw") + val accept = compose.onNodeWithTag("game-draw-accept").getUnclippedBoundsInRoot() + val decline = compose.onNodeWithTag("game-draw-decline").getUnclippedBoundsInRoot() + assertEquals(accept.top, decline.top, "Accept and Decline are not in one row") + shoot("draw-offer") + compose.onNodeWithTag("game-draw-accept").performClick() + assertEquals(listOf(ChatGames.DRAW_ACCEPT), endings) + } + + @Test + fun `a finished game says how it ended and offers nothing more`() { + show(state(GameType.CHESS, Side.FIRST, listOf("f2f3", "e7e5", "g2g4", "d8h4"))) + status().assertTextIs("Checkmate. They won") + for (tag in listOf("game-resign", "game-draw-offer", "game-cancel", "game-decline", "game-pass", "game-new")) { + compose.onNodeWithTag(tag).assertDoesNotExist() + } + compose.onNodeWithTag("game-moves-toggle").performClick() + compose.onNodeWithTag("game-moves", useUnmergedTree = true).assertTextIs("1. f2f3 e7e5\n2. g2g4 d8h4") + shoot("finished") + square("e2").performClick() + assertEquals(emptyList(), moves, "a finished board took a tap") + } + + @Test + fun `boards that disagree say so and offer a new game`() { + show(state(GameType.CHESS, Side.FIRST, listOf("e2e4", "e7e5")).copy(outOfStep = true)) + status().assertTextIs("Your boards disagree") + compose.onNodeWithTag("game-resign").assertDoesNotExist() + shoot("out-of-step") + compose.onNodeWithTag("game-new").performClick() + assertEquals(1, newGames) + } + + @Test + fun `starting a game picks the game and who goes first`() { + var started: Pair<GameType, Boolean>? = null + setContent(dark = false) { NewGameDialog(onDismiss = {}, onStart = { type, first -> started = type to first }) } + compose.onNodeWithTag("new-game-reversi").performClick() + compose.onNodeWithTag("new-game-second").performClick() + val first = compose.onNodeWithTag("new-game-first").getUnclippedBoundsInRoot() + val second = compose.onNodeWithTag("new-game-second").getUnclippedBoundsInRoot() + assertEquals(first.top, second.top, "the two choices are not in one row") + compose.onNodeWithTag("new-game-start").performClick() + assertEquals(GameType.REVERSI to false, started) + } + + // -- In a conversation --------------------------------------------------------------. + + private fun chat(group: Boolean) = + MessengerViewModel.Phase.Chat( + threadId = if (group) "group:g-1" else "user:33", + title = if (group) "Saturday" else "Sam", + peerId = if (group) null else 33, + group = if (group) RelayClient.Group("g-1", "Saturday", 31, listOf(31, 32, 33)) else null, + lines = + if (group) { + emptyList() + } else { + val game = state(GameType.CHESS, Side.SECOND, listOf("e2e4"), startedByMe = false) + listOf(Session.Line("", mine = false, at = 1_790_000_000_000, id = "m-g", author = 33, game = game)) + }, + ) + + @Test + fun `games are offered in a one-to-one chat and nowhere else`() { + val app = androidx.test.core.app.ApplicationProvider.getApplicationContext<android.app.Application>() + val model = MessengerViewModel(app) + setContent(dark = false) { ChatScreen(model, chat(group = false), error = null) } + compose.onNodeWithTag("game-g-1").assertExists() + compose.onNodeWithTag("attach").performClick() + compose.onNodeWithTag("attach-game").assertExists() + shoot("chat-with-game") + + setContent(dark = false) { ChatScreen(model, chat(group = true), error = null) } + compose.onNodeWithTag("attach").performClick() + compose.onNodeWithTag("attach-card").assertExists() + compose.onNodeWithTag("attach-game").assertDoesNotExist() + } + + private fun status() = compose.onNodeWithTag("game-status", useUnmergedTree = true) + + private fun SemanticsNodeInteraction.assertTextIs(expected: String) { + val text = fetchSemanticsNode().config[SemanticsProperties.Text].joinToString { it.text } + assertEquals(expected, text) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/GamesInASessionTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/GamesInASessionTest.kt new file mode 100644 index 0000000..88f1218 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/GamesInASessionTest.kt @@ -0,0 +1,626 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.Conversations +import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayClient +import io.flamenet.e2e.BackupPayload +import io.flamenet.e2e.Content +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Vault +import io.flamenet.games.GameType +import io.flamenet.games.Outcome +import io.flamenet.games.Piece +import io.flamenet.games.PieceKind +import io.flamenet.games.Side +import io.flamenet.games.WinReason +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.json.JSONArray +import org.json.JSONObject +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.Instant +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong +import javax.crypto.KeyGenerator +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Two phones playing each game to its end through real Sessions (§6.33). + * + * Every start, move and ending goes through the real ratchet to a relay + * double that keeps one inbox per device, and the other phone collects it + * with an ordinary poll, so what is checked is what two people's phones would + * hold: the same log, the same ending, and a notice for each move. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class GamesInASessionTest { + /** A vault on a map, so a test can see whether anything was written. */ + private class MapStorage : Vault.Storage { + val records = LinkedHashMap<String, ByteArray>() + + override fun read(name: String) = records[name] + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + + override fun names() = records.keys.toList() + } + + private lateinit var server: MockWebServer + + /** Every envelope the relay holds, each for one device. */ + private val held = Collections.synchronizedList(mutableListOf<JSONObject>()) + + /** Every send as the relay took it. */ + private val posts = Collections.synchronizedList(mutableListOf<JSONObject>()) + private val nextEnvelope = AtomicLong(1) + private val phones = ConcurrentHashMap<Long, Phone>() + + /** While set, the relay refuses every send. */ + @Volatile private var refuseSends = false + + private val gid = "g-1" + + @BeforeTest fun start() { + server = MockWebServer().also { it.dispatcher = relay() }.also { it.start() } + } + + @AfterTest fun stop() = server.shutdown() + + private fun newKey() = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + + /** One person's phone: an account, a device, a vault and a Session. */ + private inner class Phone( + val user: Long, + val name: String, + offTheRecordWith: Long? = null, + ) { + val identity: DeviceIdentity = DeviceIdentity.create() + val storage = MapStorage() + val vault = + Vault(storage, newKey()).also { v -> + v.saveIdentity(identity) + v.saveGroupName(gid, "Saturday") + offTheRecordWith?.let { v.saveOffTheRecord(Vault.threadForUser(it), true) } + } + val session = sessionOn(vault) + + init { + phones[user] = this + } + + fun sessionOn(v: Vault): Session { + val base = server.url("/").toString() + return Session(name, user, identity, v, RelayClient(base, name, "app pass word"), MessengerApi(base, name, "app pass word")) + } + + fun threadWith(other: Phone) = Vault.threadForUser(other.user) + + fun game( + other: Phone, + id: String, + ) = assertNotNull(session.gameState(threadWith(other), id), "$name holds no game $id") + } + + private fun json(body: String) = MockResponse().setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + + private fun relay() = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val url = request.requestUrl ?: return MockResponse().setResponseCode(400) + // Every route sits under the relay's REST prefix, /api/flamenet/v1. + val path = url.encodedPath.substringAfter("/flamenet/v1") + return when { + path.startsWith("/e2e/log") -> { + MockResponse().setResponseCode(404) + } + + path == "/e2e/messages" && request.method == "POST" -> { + accept(JSONObject(request.body.readUtf8())) + } + + path == "/e2e/messages" -> { + inbox(url.queryParameter("device_id").orEmpty(), url.queryParameter("after")?.toLongOrNull() ?: 0L) + } + + path.startsWith("/e2e/devices/") || path.startsWith("/e2e/keys/") -> { + val phone = path.substringAfterLast('/').toLongOrNull()?.let { phones[it] } + val devices = JSONArray().also { a -> phone?.let { a.put(it.identity.registrationWire()) } } + json(JSONObject().put("devices", devices).toString()) + } + + path.startsWith("/e2e/groups") -> { + json("""{"groups":[{"gid":"$gid","name":"Saturday","owner":1,"members":[{"user_id":1},{"user_id":2}]}]}""") + } + + else -> { + json("{}") + } + } + } + } + + /** A send: one envelope into each named device's inbox, sealed or not. */ + private fun accept(body: JSONObject): MockResponse { + if (refuseSends) return MockResponse().setResponseCode(500) + posts += body + val fromDevice = body.optString("from_device") + val fromUser = phones.values.firstOrNull { it.identity.deviceId == fromDevice }?.user ?: 0L + val messages = body.getJSONArray("messages") + for (i in 0 until messages.length()) { + val m = messages.getJSONObject(i) + held += + JSONObject() + .put("id", nextEnvelope.getAndIncrement()) + .put("to_device", m.getString("to_device")) + .put("from_user", fromUser) + .put("from_device", fromDevice) + .put("payload", m.getString("payload")) + .put("created_at", Instant.now().toString()) + .put("envelope_id", m.optString("envelope_id")) + } + return json("""{"ok":true}""") + } + + private fun inbox( + device: String, + after: Long, + ): MockResponse { + val out = JSONArray() + synchronized(held) { held.filter { it.getString("to_device") == device && it.getLong("id") > after }.forEach { out.put(it) } } + return json(JSONObject().put("messages", out).toString()) + } + + // -- Playing ----------------------------------------------------------------------. + + private fun begin( + starter: Phone, + invitee: Phone, + type: GameType, + starterGoesFirst: Boolean, + ): String { + starter.session.startGame(invitee.user, type, starterGoesFirst) + assertFalse(posts.last().optBoolean("silent", false), "a new game should reach the person like a message") + val mine = starter.session.history(starter.threadWith(invitee)).last() + val id = assertNotNull(mine.game).id + assertTrue(mine.mine) + assertTrue(mine.game!!.startedByMe) + assertEquals("Game: " + ChatGames.name(type), mine.summary) + + assertEquals(setOf(invitee.threadWith(starter)), invitee.session.receive(), "an invitation did not count as unread") + val theirs = invitee.session.history(invitee.threadWith(starter)).last() + assertEquals(id, theirs.game!!.id) + assertEquals(mine.id, theirs.id, "the card here and there have different ids, so replies would miss") + assertFalse(theirs.mine) + assertFalse(theirs.game!!.startedByMe) + assertEquals(mine.game!!.me.opponent, theirs.game!!.me) + assertEquals(Session.Notice.GameInvite(starter.user, type), invitee.session.takeNotice(), "an invitation had no words of its own") + return id + } + + /** + * The whole of [log], each move by whoever plays it, collected by the + * other phone before the next. Returns both phones' games at the end. + */ + private fun playOut( + starter: Phone, + invitee: Phone, + type: GameType, + starterGoesFirst: Boolean, + log: List<String>, + ): Pair<ChatGames.State, ChatGames.State> { + val id = begin(starter, invitee, type, starterGoesFirst) + val first = if (starterGoesFirst) starter else invitee + val second = if (starterGoesFirst) invitee else starter + for ((ply, move) in log.withIndex()) { + val mover = if (type.sideOfPly(ply) == Side.FIRST) first else second + val other = if (mover === first) second else first + val before = mover.game(other, id) + assertTrue(before.myMove, "ply $ply is not ${mover.name}'s to play") + assertFalse(other.game(mover, id).myMove, "ply $ply is ${other.name}'s too") + if (move == "pass") assertEquals(listOf("pass"), before.myMoves, "a pass that was not forced") + assertTrue(move in before.myMoves, "$move is not legal at ply $ply: ${before.myMoves}") + + assertTrue(mover.session.playGameMove(mover.threadWith(other), id, move, other.user), "$move did not go out") + assertFalse(posts.last().optBoolean("silent", false), "a move has to wake the other phone (DESIGN item 15)") + assertEquals(log.take(ply + 1), mover.game(other, id).log) + + assertEquals(emptySet(), other.session.receive(), "$move counted as unread for ${other.name} (DESIGN 29)") + assertTrue(other.threadWith(mover) in other.session.takeQuietlyChanged(), "$move did not redraw ${other.name}'s board") + val after = other.game(mover, id) + assertEquals(log.take(ply + 1), after.log, "the boards differ after $move") + assertNull(other.session.takeNotice(), "$move was worded like a message") + val notice = other.session.takeGameNews() + if (after.finished) { + assertEquals(Session.Notice.GameOver(mover.user, type, ChatGames.result(after)!!), notice) + } else { + assertEquals(Session.Notice.GameTurn(mover.user, type), notice) + } + } + val a = starter.game(invitee, id) + val b = invitee.game(starter, id) + assertTrue(a.finished && b.finished, "the game did not end") + assertEquals(a.outcome, b.outcome, "the two phones disagree on how it ended") + assertEquals(1, starter.session.history(starter.threadWith(invitee)).size, "a move drew a bubble") + assertEquals(1, invitee.session.history(invitee.threadWith(starter)).size, "a move drew a bubble") + return a to b + } + + // -- Each game to its end -------------------------------------------------------. + + @Test + fun `tic-tac-toe to three in a row, and the loser hears how`() { + val alice = Phone(1, "alice") + val bob = Phone(2, "bob") + val (a, b) = playOut(alice, bob, GameType.TICTACTOE, starterGoesFirst = true, log = listOf("1", "4", "2", "5", "3")) + assertEquals(Outcome.Win(Side.FIRST, WinReason.THREE_IN_A_ROW), a.outcome) + assertEquals("You won with three in a row", ChatGames.result(a)) + assertEquals("They won with three in a row", ChatGames.result(b)) + } + + @Test + fun `chess with a promotion, to checkmate, the invitee moving first`() { + val alice = Phone(1, "alice") + val bob = Phone(2, "bob") + // Alice asks Bob to go first, so he plays white and his first move accepts. + // Black queens on b1, then mates on h4. + val log = + listOf( + "b2b4", "a7a5", "f2f3", "a5b4", "g2g4", "b4b3", "a2a3", "b3c2", "a3a4", "c2b1q", + "g1h3", "e7e5", "h3g1", "d8h4", + ) + val (a, b) = playOut(alice, bob, GameType.CHESS, starterGoesFirst = false, log = log) + assertEquals(Side.SECOND, a.me) + assertEquals(Outcome.Win(Side.SECOND, WinReason.CHECKMATE), a.outcome) + assertEquals("Checkmate. You won", ChatGames.result(a)) + assertEquals("Checkmate. They won", ChatGames.result(b)) + for (state in listOf(a, b)) { + val board = state.position!!.board + assertEquals(Piece(Side.SECOND, PieceKind.QUEEN), board.cells[board.squares.indexOf("b1")], "the promotion did not happen") + } + } + + @Test + fun `checkers with jumps, double jumps and a king, to a side with no move`() { + val alice = Phone(1, "alice") + val bob = Phone(2, "bob") + val log = + listOf( + "9-14", "22-17", "6-9", "23-19", "9-13", "19-16", "13x22", "25x18x9", "5x14", "24-20", + "12x19", "29-25", "19-24", "28x19", "11-15", "26-22", "15x24", "32-28", "14-17", "21x14", + "10x17x26", "30x23", "7-10", "28x19", "2-7", "19-15", "10x19x26", "31x22", "7-11", "27-23", + "11-16", "20x11", "8x15", "22-17", "15-19", "23x16", "1-6", "25-21", "3-8", "16-12", + "8-11", "17-13", "4-8", "12x3", "6-10", "13-9", "10-14", "3-8", "14-18", "8x15x22", + ) + val (a, b) = playOut(alice, bob, GameType.CHECKERS, starterGoesFirst = true, log = log) + assertEquals(Outcome.Win(Side.SECOND, WinReason.NO_MOVES), a.outcome) + assertEquals("They won. You have no moves left", ChatGames.result(a)) + assertEquals("You won. They have no moves left", ChatGames.result(b)) + } + + @Test + fun `reversi with a forced pass, to the end`() { + val alice = Phone(1, "alice") + val bob = Phone(2, "bob") + // Bob starts and asks Alice to go first; at ply 15 he has no square and passes. + val log = listOf("c4", "e3", "f4", "c5", "d2", "f2", "c6", "d6", "e6", "c1", "g1", "g5", "g3", "b6", "a7", "pass", "h6") + val (b, a) = playOut(bob, alice, GameType.REVERSI, starterGoesFirst = false, log = log) + assertEquals(Side.FIRST, a.me) + assertEquals(Outcome.Win(Side.FIRST, WinReason.MORE_DISCS), a.outcome) + assertEquals("You won with more discs", ChatGames.result(a)) + assertEquals("They won with more discs", ChatGames.result(b)) + } + + // -- Endings sent by hand ------------------------------------------------------------. + + @Test + fun `a draw offer is shown without a sound, a refusal not at all, and an acceptance ends it for both`() { + val alice = Phone(1, "alice") + val bob = Phone(2, "bob") + val id = begin(alice, bob, GameType.CHESS, starterGoesFirst = true) + assertTrue(alice.session.playGameMove(alice.threadWith(bob), id, "e2e4", bob.user)) + bob.session.receive() + assertTrue(bob.session.playGameMove(bob.threadWith(alice), id, "e7e5", alice.user)) + alice.session.receive() + alice.session.takeGameNews() + // DESIGN item 22: a draw is offered only after one's own move. + assertFalse(alice.session.endGame(alice.threadWith(bob), id, ChatGames.DRAW_OFFER, bob.user), "an offer on her own move") + assertTrue(alice.session.playGameMove(alice.threadWith(bob), id, "g1f3", bob.user)) + bob.session.receive() + bob.session.takeGameNews() + + assertTrue(alice.session.endGame(alice.threadWith(bob), id, ChatGames.DRAW_OFFER, bob.user)) + assertFalse(posts.last().optBoolean("silent", false), "an ending has to wake the other phone") + assertEquals(emptySet(), bob.session.receive(), "a draw offer counted as unread") + val offer = bob.session.takeGameNews() + assertEquals(Session.Notice.GameDrawOffer(alice.user, GameType.CHESS), offer) + assertEquals("Alice offers a draw: Chess", NotificationSettings.noticeText(true, offer!!, "Alice", null)) + assertEquals(Side.FIRST, bob.game(alice, id).offerBy) + assertEquals("They offered a draw", ChatGames.status(bob.game(alice, id))) + assertFalse(ChatGames.canEnd(bob.game(alice, id), ChatGames.DRAW_OFFER)) + + // Moves redraw the same way, so start from nothing redrawn. + alice.session.takeQuietlyChanged() + assertTrue(bob.session.endGame(bob.threadWith(alice), id, ChatGames.DRAW_DECLINE, alice.user)) + assertEquals(emptySet(), alice.session.receive(), "a refused offer marked the chat unread") + assertNull(alice.session.takeNotice() ?: alice.session.takeGameNews(), "a refused offer had words for the lock screen") + assertNull(alice.game(bob, id).offerBy) + assertTrue(alice.threadWith(bob) in alice.session.takeQuietlyChanged(), "the card was not redrawn") + + assertTrue(alice.session.endGame(alice.threadWith(bob), id, ChatGames.DRAW_OFFER, bob.user)) + bob.session.receive() + assertTrue(bob.session.endGame(bob.threadWith(alice), id, ChatGames.DRAW_ACCEPT, alice.user)) + assertEquals(emptySet(), alice.session.receive(), "an ending counted as unread") + assertEquals(Session.Notice.GameOver(bob.user, GameType.CHESS, "Draw by agreement"), alice.session.takeGameNews()) + assertEquals("Draw by agreement", ChatGames.result(alice.game(bob, id))) + assertEquals("Draw by agreement", ChatGames.result(bob.game(alice, id))) + assertFalse(alice.session.playGameMove(alice.threadWith(bob), id, "b1c3", bob.user), "a move after the end") + } + + @Test + fun `a resignation, a decline and a cancel each end the game on both phones`() { + val alice = Phone(1, "alice") + val bob = Phone(2, "bob") + + val resigned = begin(alice, bob, GameType.CHECKERS, starterGoesFirst = true) + assertTrue(alice.session.playGameMove(alice.threadWith(bob), resigned, "11-15", bob.user)) + bob.session.receive() + assertFalse(bob.session.endGame(bob.threadWith(alice), resigned, ChatGames.RESIGN, alice.user), "resigned before accepting") + assertTrue(bob.session.playGameMove(bob.threadWith(alice), resigned, "22-18", alice.user)) + alice.session.receive() + assertTrue(bob.session.endGame(bob.threadWith(alice), resigned, ChatGames.RESIGN, alice.user)) + alice.session.receive() + assertEquals("They resigned. You won", ChatGames.result(alice.game(bob, resigned))) + assertEquals("You resigned. They won", ChatGames.result(bob.game(alice, resigned))) + + val declined = begin(alice, bob, GameType.REVERSI, starterGoesFirst = true) + assertTrue(bob.session.endGame(bob.threadWith(alice), declined, ChatGames.DECLINE, alice.user)) + alice.session.receive() + assertEquals(Session.Notice.GameOver(bob.user, GameType.REVERSI, "They declined"), alice.session.takeGameNews()) + assertEquals("You declined", ChatGames.result(bob.game(alice, declined))) + + val cancelled = begin(alice, bob, GameType.TICTACTOE, starterGoesFirst = false) + assertTrue(alice.session.endGame(alice.threadWith(bob), cancelled, ChatGames.CANCEL, bob.user)) + bob.session.receive() + assertEquals("They cancelled", ChatGames.result(bob.game(alice, cancelled))) + assertFalse(bob.session.playGameMove(bob.threadWith(alice), cancelled, "5", alice.user), "a move on a cancelled game") + assertEquals(3, bob.session.history(bob.threadWith(alice)).size, "three games, three cards") + } + + // -- When things go wrong ------------------------------------------------------------. + + @Test + fun `a move that reaches nobody is taken back, so the boards never disagree over it`() { + val alice = Phone(1, "alice") + val bob = Phone(2, "bob") + val id = begin(alice, bob, GameType.TICTACTOE, starterGoesFirst = true) + refuseSends = true + assertFalse(alice.session.playGameMove(alice.threadWith(bob), id, "5", bob.user)) + assertEquals(emptyList(), alice.game(bob, id).log, "a move that never left was kept") + assertTrue(alice.game(bob, id).myMove) + assertFalse(alice.session.endGame(alice.threadWith(bob), id, ChatGames.CANCEL, bob.user)) + assertNull(alice.game(bob, id).ending, "a cancel that never left was kept") + refuseSends = false + assertTrue(alice.session.playGameMove(alice.threadWith(bob), id, "5", bob.user)) + bob.session.receive() + assertEquals(listOf("5"), bob.game(alice, id).log) + assertEquals(0, alice.vault.outbox().length(), "a move or an ending was queued") + } + + @Test + fun `with somebody who has no device yet the start waits in the outbox and a move is taken back`() { + val alice = Phone(1, "alice") + val nobody = 7L + val thread = Vault.threadForUser(nobody) + alice.session.startGame(nobody, GameType.TICTACTOE, iGoFirst = true) + val card = alice.session.history(thread).single() + assertEquals(Session.Delivery.PENDING, card.delivery) + assertEquals(1, alice.vault.outbox().length(), "the start is queued like a card") + + // DESIGN item 24: a move is never queued. + assertFalse(alice.session.playGameMove(thread, card.game!!.id, "5", nobody)) + assertEquals(emptyList(), alice.session.gameState(thread, card.game!!.id)!!.log) + assertFalse(alice.session.endGame(thread, card.game!!.id, ChatGames.CANCEL, nobody)) + assertNull(alice.session.gameState(thread, card.game!!.id)!!.ending) + assertEquals(1, alice.vault.outbox().length(), "a move or an ending was queued") + assertTrue(posts.isEmpty()) + } + + @Test + fun `a game deleted here comes back from the next move, rebuilt from its log`() { + val alice = Phone(1, "alice") + val bob = Phone(2, "bob") + val id = begin(alice, bob, GameType.TICTACTOE, starterGoesFirst = true) + assertTrue(alice.session.playGameMove(alice.threadWith(bob), id, "5", bob.user)) + bob.session.receive() + assertTrue(bob.session.playGameMove(bob.threadWith(alice), id, "1", alice.user)) + alice.session.receive() + val card = bob.session.history(bob.threadWith(alice)).single() + assertTrue(bob.session.deleteMessage(bob.threadWith(alice), card.at, card.mine, card.text)) + assertTrue(bob.session.history(bob.threadWith(alice)).isEmpty()) + + assertTrue(alice.session.playGameMove(alice.threadWith(bob), id, "9", bob.user)) + assertEquals(emptySet(), bob.session.receive(), "a move that rebuilt the board counted as unread") + val rebuilt = bob.game(alice, id) + assertEquals(listOf("5", "1", "9"), rebuilt.log) + assertEquals(Side.SECOND, rebuilt.me) + assertTrue(rebuilt.myMove) + assertEquals(Session.Notice.GameTurn(alice.user, GameType.TICTACTOE), bob.session.takeGameNews()) + } + + @Test + fun `a game in a group, from another server, or from my own device is never a board`() { + val alice = Phone(1, "alice") + val bob = Phone(2, "bob") + val s = alice.session + var envelope = 1L + + fun from( + user: Long, + wire: String, + address: String? = null, + ) = Conversations.Received( + id = envelope++, + fromUser = user, + fromDevice = "device-$user", + content = Content.parse(wire), + senderProven = true, + at = System.currentTimeMillis(), + fromAddress = address, + ) + + // A group. + val groupThread = Vault.threadForGroup(gid) + s.absorbForTesting( + listOf( + from( + bob.user, + """{"fnc":1,"kind":"game","mid":"m-g","game":{"id":"g","t":"chess","mine":"first"},"gid":"$gid","gname":"Saturday"}""", + ), + ), + ) + val line = s.history(groupThread).single() + assertEquals(Session.UNSUPPORTED, line.text) + assertNull(line.game) + val inGroup = """{"fnc":1,"kind":"gmove","ctl":1,"game":{"id":"g","t":"chess","mine":"first","log":["e2e4"]},"gid":"$gid"}""" + val groupMove = s.absorbForTesting(listOf(from(bob.user, inGroup))) + assertTrue(groupMove.isEmpty()) + assertEquals(1, s.history(groupThread).size) + assertTrue(s.history(Vault.threadForUser(bob.user)).isEmpty(), "a group move started a game one-to-one") + + // Another server. + val address = "ada@relay.example" + val addressThread = Vault.threadForAddress(address) + s.absorbForTesting(listOf(from(0, Content.game(Content.Game("g-x", "chess", "first"), "m-x"), address))) + val far = s.history(addressThread).single() + assertEquals(Session.UNSUPPORTED, far.text) + assertNull(far.game, "a board from another server that no move could answer") + val farMove = Content.gameMove(Content.Game("g-x", "chess", "first", listOf("e2e4"))) + assertTrue(s.absorbForTesting(listOf(from(0, farMove, address))).isEmpty()) + assertEquals(1, s.history(addressThread).size) + + // My own other device: nowhere to put it, so dropped. + assertTrue(s.absorbForTesting(listOf(from(alice.user, Content.game(Content.Game("g-o", "chess", "first"), "m-o")))).isEmpty()) + val ownMove = Content.gameMove(Content.Game("g-o", "chess", "first", listOf("e2e4"))) + assertTrue(s.absorbForTesting(listOf(from(alice.user, ownMove))).isEmpty()) + assertTrue(s.history(Vault.threadForUser(alice.user)).isEmpty()) + assertTrue(s.history(Vault.threadForUser(bob.user)).isEmpty()) + } + + @Test + fun `a decline for a game this phone lost comes back as the game it started, declined`() { + val alice = Phone(1, "alice") + val bob = Phone(2, "bob") + val id = begin(alice, bob, GameType.CHECKERS, starterGoesFirst = true) + val card = alice.session.history(alice.threadWith(bob)).single() + assertTrue(alice.session.deleteMessage(alice.threadWith(bob), card.at, card.mine, card.text)) + + assertTrue(bob.session.endGame(bob.threadWith(alice), id, ChatGames.DECLINE, alice.user)) + assertEquals(emptySet(), alice.session.receive(), "an ending counted as unread") + val back = alice.session.history(alice.threadWith(bob)).single() + assertTrue(back.mine, "the card for a game she started is hers") + assertEquals("They declined", ChatGames.result(back.game!!)) + assertEquals(Session.Notice.GameOver(bob.user, GameType.CHECKERS, "They declined"), alice.session.takeGameNews()) + } + + // -- Off the record and backups -----------------------------------------------------. + + @Test + fun `off the record, a game is played in memory and nothing of it reaches the disk`() { + val bob = Phone(2, "bob") + val alice = Phone(1, "alice", offTheRecordWith = 2) + val id = begin(bob, alice, GameType.TICTACTOE, starterGoesFirst = false) + assertTrue(alice.session.playGameMove(alice.threadWith(bob), id, "5", bob.user)) + bob.session.receive() + assertTrue(bob.session.playGameMove(bob.threadWith(alice), id, "1", alice.user)) + alice.session.receive() + assertTrue(alice.session.playGameMove(alice.threadWith(bob), id, "9", bob.user)) + assertTrue(alice.session.endGame(alice.threadWith(bob), id, ChatGames.DRAW_OFFER, bob.user)) + + assertEquals(listOf("5", "1", "9"), alice.game(bob, id).log) + assertEquals(Side.FIRST, alice.game(bob, id).offerBy) + assertTrue(alice.storage.names().none { it.startsWith("history/") }, "a game reached the vault: ${alice.storage.names()}") + assertEquals(0, alice.vault.outbox().length()) + assertTrue(alice.sessionOn(alice.vault).history(alice.threadWith(bob)).isEmpty(), "a restart found the game") + assertTrue(bob.storage.names().any { it.startsWith("history/") }, "bob's side is on the record and should be kept") + } + + @Test + fun `a game comes back whole through a backup, finished or still going`() { + val alice = Phone(1, "alice") + val bob = Phone(2, "bob") + val over = begin(alice, bob, GameType.CHESS, starterGoesFirst = true) + assertTrue(alice.session.playGameMove(alice.threadWith(bob), over, "e2e4", bob.user)) + bob.session.receive() + assertTrue(bob.session.playGameMove(bob.threadWith(alice), over, "e7e5", alice.user)) + assertTrue(bob.session.endGame(bob.threadWith(alice), over, ChatGames.RESIGN, alice.user)) + alice.session.receive() + val going = begin(alice, bob, GameType.REVERSI, starterGoesFirst = false) + assertTrue(bob.session.playGameMove(bob.threadWith(alice), going, "d3", alice.user)) + alice.session.receive() + + val file = BackupPayload.toShared(alice.vault.exportPayload()).toString() + val restoredVault = Vault(MapStorage(), newKey()) + restoredVault.mergeImportedPayload(BackupPayload.fromShared(JSONObject(file)), nowSeconds = System.currentTimeMillis() / 1000.0) + val restored = alice.sessionOn(restoredVault) + + val before = alice.session.history(alice.threadWith(bob)) + val after = restored.history(alice.threadWith(bob)) + assertEquals(before.map { it.game }, after.map { it.game }) + assertEquals(before.map { it.id }, after.map { it.id }) + assertEquals("They resigned. You won", ChatGames.result(after[0].game!!)) + val resumed = after[1].game!! + assertEquals(listOf("d3"), resumed.log) + assertTrue(resumed.myMove) + assertNotNull(ChatGames.play(resumed, resumed.myMoves.first()), "the restored game cannot go on") + } + + // -- Words on the lock screen -------------------------------------------------------. + + @Test + fun `an invitation is said out loud in its own words, a move and an offer without a sound`() { + val invite = Session.Notice.GameInvite(2, GameType.CHESS) + assertEquals("Bob invited you to play chess", NotificationSettings.noticeText(true, invite, "Bob", null)) + val tictactoe = Session.Notice.GameInvite(2, GameType.TICTACTOE) + assertEquals("Bob invited you to play tic-tac-toe", NotificationSettings.noticeText(true, tictactoe, "Bob", null)) + assertEquals("New message", NotificationSettings.noticeText(false, invite, "Bob", null)) + assertFalse(NotificationSettings.isQuiet(invite), "an invitation came without a sound") + assertFalse(NotificationSettings.isQuiet(Session.Notice.GameOver(2, GameType.CHESS, "They resigned. You won"))) + assertTrue(NotificationSettings.isQuiet(Session.Notice.GameTurn(2, GameType.CHESS))) + assertTrue(NotificationSettings.isQuiet(Session.Notice.GameDrawOffer(2, GameType.CHESS))) + assertFalse(NotificationSettings.isQuiet(null)) + } + + @Test + fun `a move and an ending notify in their own words, and nothing with previews off`() { + val turn = Session.Notice.GameTurn(2, GameType.CHESS) + assertEquals("Your move: chess with Bob", NotificationSettings.noticeText(true, turn, "Bob", null)) + val nameless = Session.Notice.GameTurn(2, GameType.TICTACTOE) + assertEquals("Your move: tic-tac-toe", NotificationSettings.noticeText(true, nameless, null, null)) + val over = Session.Notice.GameOver(2, GameType.CHESS, "Checkmate. They won") + assertEquals("Chess with Bob: Checkmate. They won", NotificationSettings.noticeText(true, over, "Bob", null)) + assertEquals("New message", NotificationSettings.noticeText(false, turn, "Bob", null)) + assertEquals("New message", NotificationSettings.noticeText(false, over, "Bob", null)) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/HelloLinkOpensTheAddScreenTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/HelloLinkOpensTheAddScreenTest.kt new file mode 100644 index 0000000..a7e2dba --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/HelloLinkOpensTheAddScreenTest.kt @@ -0,0 +1,138 @@ +package io.flamenet.messenger.app + +import android.app.Application +import android.content.Intent +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.core.app.ApplicationProvider +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Opening `flamenetmessenger://hi#<address>` (SPEC §6.34) fills in Home's add + * screen and stops there: nothing is looked up, nobody is added and nothing is + * sent until the person chooses to. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w375dp-h812dp-normal-long-notround-any-440dpi-keyshidden-nonav") +class HelloLinkOpensTheAddScreenTest { + @get:Rule val compose = createAndroidComposeRule<ComponentActivity>() + + private val fnid = "fnid:k68p33sqcbnf1mc2jgzkbfqk7xzx3ykbz0fftg49tb751nrnt290" + private val address = "$fnid@relay.flamenet.io" + + private fun model() = MessengerViewModel(ApplicationProvider.getApplicationContext<Application>()) + + private fun home(model: MessengerViewModel) { + val phase = + MessengerViewModel.Phase.SignedIn( + handle = "alice", + userId = 1, + deviceId = "74d869d7-4667-4d97-854b-80ee684aaacc", + relay = "relay.flamenet.io", + status = "Signed in on this device.", + buddiesLoaded = true, + ) + compose.setContent { + MessengerTheme { + Surface(modifier = Modifier.fillMaxSize()) { + Column { SignedIn(model, phase) } + } + } + } + compose.waitForIdle() + } + + @Test + fun `only a VIEW of the app's own scheme is taken for a hello link`() { + val link = "flamenetmessenger://hi#$address" + assertEquals(link, helloLinkIn(Intent(Intent.ACTION_VIEW, Uri.parse(link)))) + val capitals = "FlamenetMessenger://hi#$address" + assertEquals(capitals, helloLinkIn(Intent(Intent.ACTION_VIEW, Uri.parse(capitals)))) + assertNull(helloLinkIn(Intent(Intent.ACTION_VIEW, Uri.parse("https://flamenetmessenger.com/hi#$address")))) + assertNull(helloLinkIn(Intent(Intent.ACTION_MAIN, Uri.parse(link)))) + assertNull(helloLinkIn(Intent(Intent.ACTION_VIEW))) + assertNull(helloLinkIn(null)) + } + + @Test + fun `a hello link fills in the add screen and nothing more`() { + val model = model() + model.openHelloLink("flamenetmessenger://hi#$address") + assertEquals(address, model.helloAddress.value) + + home(model) + + compose.onNodeWithTag("new-contact", useUnmergedTree = true).assert(hasText(address)) + compose.onNodeWithTag("address-recognised", useUnmergedTree = true).assertExists() + // The one way on is a button the person presses. It has not been. + compose.onNodeWithTag("message-address", useUnmergedTree = true).assertExists() + compose.onNodeWithTag("lookup-result", useUnmergedTree = true).assertDoesNotExist() + // Taken by the screen, so it cannot fill the form a second time. + assertNull(model.helloAddress.value) + } + + @Test + fun `a link with no address in it fills in nothing`() { + val model = model() + for (bad in listOf( + "flamenetmessenger://hi", + "flamenetmessenger://hi#", + "flamenetmessenger://hi#nobody@relay.flamenet.io", + "flamenetmessenger://hello#$address", + "flamenetmessenger://hi#$address?then=send", + "flamenetmessenger://hi#<script>alert(1)</script>", + )) { + model.openHelloLink(bad) + assertNull(model.helloAddress.value, bad) + } + home(model) + compose.onNodeWithTag("new-contact", useUnmergedTree = true).assertDoesNotExist() + } + + /** + * What `openHelloLink` may touch, read off the source: the waiting address + * and which screen shows. A send, a lookup, an add or an open chat there + * would act on a link nobody chose to act on. + */ + @Test + fun `opening a link calls nothing that sends, looks up or adds`() { + val vm = File("src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt").readText() + val body = vm.substringAfter("fun openHelloLink(", "").substringBefore("\n }\n") + assertTrue(body.contains("HelloLink.fromLink("), "openHelloLink no longer parses the link") + for (act in listOf("send", "addBuddy", "lookupMember", "openAddressChat", "session", "api.")) { + assertFalse(body.contains(act), "openHelloLink reaches $act") + } + val activity = File("src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt").readText() + assertTrue(activity.contains("if (savedInstanceState == null) openHelloLinkIfAsked(intent)")) + assertTrue( + activity.substringAfter("override fun onNewIntent(").substringBefore("\n }\n").contains("openHelloLinkIfAsked(intent)"), + "a link opened while the app is running is not read", + ) + } + + @Test + fun `the manifest claims the scheme for viewing from a browser`() { + val manifest = File("src/main/AndroidManifest.xml").readText() + val filter = manifest.substringAfter("android.intent.action.VIEW", "").substringBefore("</intent-filter>") + assertTrue(filter.contains("android.intent.category.BROWSABLE"), "a browser cannot hand the link over") + assertTrue(filter.contains("android.intent.category.DEFAULT")) + assertTrue(filter.contains("android:scheme=\"flamenetmessenger\"")) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/HelloLinkTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/HelloLinkTest.kt new file mode 100644 index 0000000..4ae082e --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/HelloLinkTest.kt @@ -0,0 +1,245 @@ +package io.flamenet.messenger.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Reading an address out of a hello link (SPEC §6.34, DESIGN 21), a scanned + * code or a pasted line. Anything a web page or another app can hand over + * reaches this parser, so it is held to the shape both apps agreed on. + */ +class HelloLinkTest { + private val fnid = "fnid:k68p33sqcbnf1mc2jgzkbfqk7xzx3ykbz0fftg49tb751nrnt290" + private val address = "$fnid@relay.flamenet.io" + + @Test + fun `the web link and the app link both carry the address`() { + assertEquals(address, HelloLink.fromLink("https://flamenetmessenger.com/hi#$address")) + assertEquals(address, HelloLink.fromLink("https://flamenetmessenger.com/es/hi#$address")) + assertEquals(address, HelloLink.fromLink("flamenetmessenger://hi#$address")) + } + + @Test + fun `the path is exactly hi or es-hi, in that case and with nothing after it (DESIGN 35)`() { + for (head in listOf( + "https://flamenetmessenger.com/HI", + "https://flamenetmessenger.com/Hi", + "https://flamenetmessenger.com/ES/hi", + "https://flamenetmessenger.com/es/HI", + "https://flamenetmessenger.com/hi/", + "https://flamenetmessenger.com/es/hi/", + "https://flamenetmessenger.com/hi?", + "https://flamenetmessenger.com/es/hi?u=someone", + "flamenetmessenger://hi/", + "flamenetmessenger://hi?u=someone", + )) { + assertNull(HelloLink.fromLink("$head#$address"), head) + } + // The scheme and the host are not the path: any case. + assertEquals(address, HelloLink.fromLink("HTTPS://FLAMENETMESSENGER.COM/es/hi#$address")) + assertEquals(address, HelloLink.fromLink("FLAMENETMESSENGER://HI#$address")) + } + + @Test + fun `a host is ASCII, an address has no spaces, and a fragment stops at 400 (DESIGN 41)`() { + // Java lowercases a Kelvin sign to an ASCII k; it is refused, never folded. + assertNull(HelloLink.canonical("$fnid@\u212Aelay.example")) + assertNull(HelloLink.address("flamenet:$fnid@relay.\u212Aexample")) + assertNull(HelloLink.fromLink("https://flamenetmessenger.com/hi#$fnid@\u212Aelay.example")) + for (spaced in listOf("$fnid @relay.flamenet.io", "$fnid@ relay.flamenet.io", "$fnid@relay. flamenet.io")) { + assertNull(HelloLink.canonical(spaced), spaced) + } + assertNull(HelloLink.fromLink("flamenetmessenger://hi/#$address")) + + val head = "https://flamenetmessenger.com/hi#" + fun fragmentOf(length: Int): String = "$fnid@" + "a".repeat(length - fnid.length - 1 - ".example".length) + ".example" + assertEquals(400, fragmentOf(400).length) + assertEquals(fragmentOf(400), HelloLink.fromLink(head + fragmentOf(400))) + assertNull(HelloLink.fromLink(head + fragmentOf(401)), "a fragment over 400 characters was read") + } + + /** hello-link-v1, "whitespace around the link": trimmed, as a scanned code is. */ + @Test + fun `whitespace around a link is not part of it`() { + assertEquals(address, HelloLink.fromLink(" https://flamenetmessenger.com/hi#$address\n")) + assertEquals(address, HelloLink.fromLink("\tflamenetmessenger://hi#$address ")) + assertNull(HelloLink.fromLink("https://flamenetmessenger.com/hi#$fnid @relay.flamenet.io"), "a space inside is still refused") + } + + @Test + fun `the card's link is the https form, and reads back to the same address`() { + val link = HelloLink.forAddress(address) + assertEquals("https://flamenetmessenger.com/hi#$address", link) + assertEquals(address, HelloLink.fromLink(link!!)) + assertNull(HelloLink.forAddress("not an address")) + } + + @Test + fun `case in the scheme and the host is not a different link`() { + assertEquals(address, HelloLink.fromLink("HTTPS://FlameNetMessenger.com/hi#$address")) + assertEquals(address, HelloLink.fromLink("FlamenetMessenger://HI#$address")) + assertEquals(address, HelloLink.fromLink("flamenetmessenger://hi#$fnid@Relay.FlameNet.IO")) + } + + @Test + fun `the fragment is percent-decoded once`() { + val web = "https://flamenetmessenger.com/hi#" + assertEquals(address, HelloLink.fromLink(web + "fnid%3A${fnid.removePrefix("fnid:")}@relay.flamenet.io")) + assertEquals(address, HelloLink.fromLink(web + "$fnid%40relay.flamenet.io")) + assertEquals(address, HelloLink.fromLink(web + "%66nid:${fnid.removePrefix("fnid:")}@relay.flamenet.io")) + // Once, not twice: an escaped escape stays an escape and is refused. + assertNull(HelloLink.fromLink(web + "$fnid%2540relay.flamenet.io")) + for (broken in listOf("%", "%4", "%zz", "%+4", "%E2%80%8B")) { + assertNull(HelloLink.fromLink(web + address + broken), broken) + } + } + + @Test + fun `a bare or card-prefixed address is an address, trimmed`() { + assertEquals(address, HelloLink.address(" $address\n")) + assertEquals(address, HelloLink.address("flamenet:$address")) + assertEquals(address, HelloLink.address("FLAMENET:$address")) + assertEquals(address, HelloLink.address("https://flamenetmessenger.com/hi#$address")) + assertEquals("$fnid@127.0.0.1", HelloLink.address("$fnid@127.0.0.1")) + } + + @Test + fun `the host is letters, digits, hyphens and dots, with a dot`() { + assertEquals("$fnid@my-relay.example", HelloLink.canonical("$fnid@my-relay.example")) + for (host in listOf("relay", "relay_one.example", "relay.example:443", "relay.example/", "", "rel\u0430y.example", "relay example.com")) { + assertNull(HelloLink.canonical("$fnid@$host"), host) + } + } + + @Test + fun `another scheme or another host is not a hello link`() { + for (head in listOf( + "http://flamenetmessenger.com/hi", + "ftp://flamenetmessenger.com/hi", + "https://evil.example/hi", + "https://flamenetmessenger.com.evil.example/hi", + "https://evil.example/flamenetmessenger.com/hi", + "https://flamenetmessenger.com@evil.example/hi", + "https://www.flamenetmessenger.com/hi", + "https://flamenetmessenger.com:8443/hi", + "https://flamenetmessenger.com/hi?u=someone", + "https://flamenetmessenger.com/hello", + "https://flamenetmessenger.com/hi/x", + "flamenetmessenger://hello", + "flamenetmessenger://hi.evil.example", + "flamenetmessenger:hi", + "flamenet://hi", + "intent://hi", + "javascript:alert(1)//", + )) { + assertNull(HelloLink.fromLink("$head#$address"), head) + } + } + + @Test + fun `a missing or malformed fragment carries nobody`() { + val web = "https://flamenetmessenger.com/hi" + for (fragment in listOf( + "", + "#", + "#fnid:", + "#$fnid", + "#$fnid@", + "#@relay.flamenet.io", + // One character short, one too many. + "#${fnid.dropLast(1)}@relay.flamenet.io", + "#${fnid}x@relay.flamenet.io", + // Letters the alphabet leaves out so they cannot be misread. + "#${fnid.dropLast(1)}i@relay.flamenet.io", + "#${fnid.dropLast(1)}l@relay.flamenet.io", + "#${fnid.dropLast(1)}o@relay.flamenet.io", + "#${fnid.dropLast(1)}u@relay.flamenet.io", + // The key part is a key; capitals are a different string. + "#${fnid.uppercase()}@relay.flamenet.io", + "#${fnid.removePrefix("fnid:")}@relay.flamenet.io", + "#$fnid@relay", + "#$fnid@relay.flamenet.io:443", + "#$fnid@relay.flamenet.io/", + )) { + assertNull(HelloLink.fromLink(web + fragment), fragment) + } + } + + @Test + fun `nothing smuggled into the fragment is taken`() { + val web = "https://flamenetmessenger.com/hi#" + for (attempt in listOf( + "$address?u=someone", + "$address&k=key", + "$address#again", + "$address extra", + " $address", + "$address%0A", + "$address%00", + "$fnid@relay.flamenet.io@evil.example", + "$fnid%40relay.flamenet.io%40evil.example", + "$fnid@evil.example\u0000.relay.flamenet.io", + "$fnid@<script>alert(1)</script>.example", + "$fnid@%3Cscript%3E.example", + "$fnid@relay.flamenet.io\"><img src=x>", + "javascript:alert(1)", + )) { + assertNull(HelloLink.fromLink(web + attempt), attempt) + } + } + + @Test + fun `the scanner reads a hello link, an older card and a bare address`() { + assertEquals( + ScannedAddress(fnid, "relay.flamenet.io"), + parseScannedAddress("https://flamenetmessenger.com/hi#$address"), + ) + assertEquals(ScannedAddress(fnid, "relay.flamenet.io"), parseScannedAddress(address)) + // An older card's handle is not taken (DESIGN 32). + assertEquals(ScannedAddress(fnid, "relay.flamenet.io"), parseScannedAddress("flamenet:$address?u=pat")) + } + + /** + * The address card's code holds the hello link now, so the scanner has to + * read it back: drawn by the app's own encoder, decoded by the decoder the + * scanner uses. + */ + @Test + fun `the card's code reads back through the scanner as the same address`() { + val link = HelloLink.forAddress(address)!! + val m = io.flamenet.e2e.QrCode.encode(link) + val scale = 4 + val quiet = 4 + val side = (m.size + quiet * 2) * scale + val pixels = IntArray(side * side) { 0xFFFFFFFF.toInt() } + for (r in 0 until m.size) { + for (c in 0 until m.size) { + if (!m[r, c]) continue + for (dy in 0 until scale) { + for (dx in 0 until scale) { + pixels[((r + quiet) * scale + dy) * side + (c + quiet) * scale + dx] = 0xFF000000.toInt() + } + } + } + } + val source = com.google.zxing.RGBLuminanceSource(side, side, pixels) + val bitmap = com.google.zxing.BinaryBitmap(com.google.zxing.common.HybridBinarizer(source)) + val text = com.google.zxing.qrcode.QRCodeReader().decode(bitmap).text + assertEquals(link, text) + assertEquals(ScannedAddress(fnid, "relay.flamenet.io"), parseScannedAddress(text)) + } + + @Test + fun `the scanner refuses a code that only looks like an address`() { + for (code in listOf( + "mailto:someone@example.com", + "https://user@example.com/page", + "flamenet:someone@relay.flamenet.io", + "WIFI:S:home;T:WPA;P:secret;;", + "", + )) { + assertNull(parseScannedAddress(code), code) + } + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/HistoryRowsCarryEveryFieldTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/HistoryRowsCarryEveryFieldTest.kt new file mode 100644 index 0000000..64095d8 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/HistoryRowsCarryEveryFieldTest.kt @@ -0,0 +1,225 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayClient +import io.flamenet.e2e.Attachment +import io.flamenet.e2e.BackupPayload +import io.flamenet.e2e.ChatEvents +import io.flamenet.e2e.Content +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Vault +import org.json.JSONArray +import org.json.JSONObject +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import javax.crypto.KeyGenerator +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Every field a history row can hold is carried by the backup, or is listed + * in `BackupPayload.NOT_CARRIED` with a reason; and every one is read back + * into a line. + * + * The row is written by `Session.record` and the backup is translated by hand + * in `BackupPayload`, so nothing ties one to the other: a new field compiles, + * is stored, and is dropped from every backup without a sound. The two lists + * below are read off the source, so adding a parameter or a property fails + * here until somebody says where it goes. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class HistoryRowsCarryEveryFieldTest { + /** What `record()` takes. */ + private val recordParameters = + setOf( + "threadId", "text", "mine", "attachment", "sticker", "author", "at", "delivery", "link", + "messageId", "about", "lifetimeSeconds", "forwarded", "aboutAStory", "card", "event", "game", + ) + + /** What a line holds. */ + private val lineProperties = + setOf( + "text", "mine", "at", "id", "reactions", "privateReactions", "author", "attachment", "sticker", + "delivery", "link", "messageId", "about", "forwarded", "replyToStory", "editedAt", + "lifetimeSeconds", "card", "event", "game", + ) + + private fun session(): String = + File("src/main/kotlin/io/flamenet/messenger/app/Session.kt").also { + assertTrue(it.isFile, "this test reads app/src/main/kotlin/.../Session.kt") + }.readText() + + /** The names in a parameter list that starts at [header], one per line as the code is formatted. */ + private fun parameters( + source: String, + header: String, + ): Set<String> { + val start = source.indexOf(header) + assertTrue(start >= 0, "no `$header` in Session.kt") + val open = source.indexOf('(', start) + var depth = 0 + var i = open + while (true) { + when (source[i]) { + '(' -> depth++ + ')' -> if (--depth == 0) break + } + i++ + } + val withoutBlockComments = Regex("""/\*.*?\*/""", RegexOption.DOT_MATCHES_ALL).replace(source.substring(open + 1, i), "") + return withoutBlockComments + .lines() + .map { it.substringBefore("//") } + .mapNotNull { Regex("""^\s*(?:val\s+|var\s+)?(\w+)\s*:""").find(it)?.groupValues?.get(1) } + .toSet() + } + + @Test + fun `record takes nothing this test does not know about`() { + assertEquals( + recordParameters, + parameters(session(), "fun record("), + "record() changed. Carry the new field in BackupPayload (or list it in NOT_CARRIED with a reason), " + + "read it in history(), and add it to this test.", + ) + } + + @Test + fun `a line holds nothing this test does not know about`() { + assertEquals( + lineProperties, + parameters(session(), "data class Line("), + "Session.Line changed. Say which row key carries the new property and add it to this test.", + ) + } + + private class MapStorage : Vault.Storage { + private val records = mutableMapOf<String, ByteArray>() + + override fun read(name: String) = records[name] + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + + override fun names() = records.keys.toList() + } + + private val vault = Vault(MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + private val thread = Vault.threadForGroup("g-1") + + /** One row with every field a row can carry, written by the real code. */ + private fun everything(): Session { + val identity = DeviceIdentity.create() + vault.saveIdentity(identity) + val s = Session("alice", 1L, identity, vault, RelayClient("http://127.0.0.1:9/", "a", "b"), MessengerApi("http://127.0.0.1:9/", "a", "b")) + s.record( + thread, + "first words", + mine = true, + attachment = + Attachment.Ref( + kind = Attachment.IMAGE, + id = "a".repeat(64), + key = ByteArray(32) { 1 }, + digest = ByteArray(32) { 2 }, + bytes = 4096, + mime = "image/jpeg", + width = 800, + height = 600, + duration = 2.5, + ), + sticker = Session.Sticker("flamenet.retro", "flame"), + author = 7L, + delivery = Session.Delivery.FAILED, + link = Content.LinkPreview.of("https://example.org/", "Title", "Line"), + messageId = "m-1", + about = Content.Reference("m-0", 9L, "earlier"), + lifetimeSeconds = 3600, + forwarded = true, + aboutAStory = true, + card = Content.Card(design = "love", msg = "x", to = "Sam", from = "Pat", open = 1_900_000_000), + event = + ChatEvents.State( + Content.Event(id = "e-1", rev = 2, title = "Picnic", start = 1_767_225_600, tz = "Europe/Paris"), + organizer = 7L, + organizerProvisional = true, + answers = mapOf(9L to ChatEvents.Answer("yes", 2)), + ), + game = + ChatGames.State( + id = "g-1", + type = io.flamenet.games.GameType.CHESS, + me = io.flamenet.games.Side.FIRST, + log = listOf("e2e4", "e7e5"), + startedByMe = true, + ending = ChatGames.RESIGN, + endingBy = io.flamenet.games.Side.SECOND, + offerBy = io.flamenet.games.Side.SECOND, + outOfStep = true, + ), + ) + s.applyReaction(thread, "m-1", 9L, "\uD83D\uDD25") + s.applyReaction(thread, "m-1", 9L, "\u2764\uFE0F", private = true) + s.applyEdit(thread, "m-1", 1L, "second words") + return s + } + + /** A value written out with sorted keys and plain numbers, for comparing content. */ + private fun canonical(value: Any?): String = + when (value) { + is JSONObject -> value.keys().asSequence().sorted().joinToString(",", "{", "}") { "$it:" + canonical(value.get(it)) } + is JSONArray -> (0 until value.length()).joinToString(",", "[", "]") { canonical(value.get(it)) } + is Number -> value.toDouble().toString() + else -> value.toString() + } + + private fun same( + a: Any?, + b: Any?, + ): Boolean = canonical(a) == canonical(b) + + @Test + fun `every key a row holds is carried by the backup or listed as not carried`() { + everything() + val row = vault.history(thread).getJSONObject(0) + val keys = row.keys().asSequence().toSet() + val back = + BackupPayload + .fromShared(BackupPayload.toShared(JSONObject().put("threads", JSONObject().put(thread, JSONArray().put(row))))) + .getJSONObject("threads") + .getJSONArray(thread) + .getJSONObject(0) + + val lost = keys.filter { it !in BackupPayload.NOT_CARRIED && !(back.has(it) && same(row.get(it), back.get(it))) } + assertEquals(emptyList(), lost, "a backup drops or changes these row keys without saying why") + // And the list of what is left out stays true. + for (key in BackupPayload.NOT_CARRIED.keys) { + assertTrue(key in keys, "NOT_CARRIED names $key, which no row holds any more") + } + } + + @Test + fun `every key a row holds is read back into the line`() { + val line = everything().history(thread).single() + val empty = Session.Line(text = "", mine = false, at = 0L) + val fields = Session.Line::class.java.declaredFields.filterNot { java.lang.reflect.Modifier.isStatic(it.modifiers) } + assertEquals(lineProperties, fields.map { it.name }.toSet(), "reflection sees a different line than the source") + for (field in fields) { + field.isAccessible = true + assertNotEquals(field.get(empty), field.get(line), "Line.${field.name} came back empty: history() does not read it") + } + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/HomeScanFillsInTheAddressTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/HomeScanFillsInTheAddressTest.kt new file mode 100644 index 0000000..88406f1 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/HomeScanFillsInTheAddressTest.kt @@ -0,0 +1,43 @@ +package io.flamenet.messenger.app + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Scanning a code fills the add screen with the address in it, on Home and on + * Messages, bound by the fnid (audit LOW, DESIGN 32): built from the fnid and + * relay the code holds, never from template text and never from a handle an + * older card names, which a card could claim for anybody. + */ +class HomeScanFillsInTheAddressTest { + private val fnid = "fnid:k68p33sqcbnf1mc2jgzkbfqk7xzx3ykbz0fftg49tb751nrnt290" + + @Test + fun `a scanned card fills in its address, bound by the fnid`() { + assertEquals("$fnid@relay.flamenet.io", prefillFor("flamenet:$fnid@relay.flamenet.io?u=pat")) + assertEquals("$fnid@relay.flamenet.io", prefillFor("$fnid@relay.flamenet.io")) + assertEquals("$fnid@relay.flamenet.io", prefillFor("https://flamenetmessenger.com/hi#$fnid@relay.flamenet.io")) + } + + @Test + fun `a code with no address in it fills in nothing`() { + assertNull(prefillFor("WIFI:S:home;T:WPA;P:secret;;")) + } + + @Test + fun `both scanners use it, and no template is left unevaluated`() { + val app = "src/main/kotlin/io/flamenet/messenger/app/" + val home = File(app + "MainActivity.kt").readText() + val messages = File(app + "MessagesScreen.kt").readText() + assertTrue(home.contains("prefillFor(raw)"), "Home's scanner no longer fills in through prefillFor") + assertTrue(messages.contains("prefillFor(raw)"), "the Messages scanner no longer fills in through prefillFor") + assertFalse(home.contains("\${'\$'}{card."), "a scanned address is written as template text again") + val scanner = messages.substringAfter("ScanAddressDialog(", "").substringBefore("onDismiss") + assertTrue(scanner.isNotEmpty(), "the Messages screen has no scanner") + assertFalse(scanner.contains("handle"), "the Messages scanner fills in a handle again") + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/InboundControlReachesTheThreadTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/InboundControlReachesTheThreadTest.kt index 4856573..e29c290 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/InboundControlReachesTheThreadTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/InboundControlReachesTheThreadTest.kt @@ -105,7 +105,11 @@ class InboundControlReachesTheThreadTest { ), ) - assertEquals(setOf(thread), touched, "the thread was not marked as changed") + // A reaction is redrawn and never marked unread: §6.9 says it must not + // notify, so the thread comes back from `takeQuietlyChanged` and not + // from `absorb`, which is the unread and alert set. + assertEquals(emptySet(), touched, "a reaction marked the thread unread") + assertEquals(setOf(thread), s.takeQuietlyChanged(), "the thread was not marked as changed") val line = s.history(thread).single { it.id == "m1" } assertEquals(setOf(them), line.reactions["❤️"], "the reaction never reached the message") } @@ -137,7 +141,9 @@ class InboundControlReachesTheThreadTest { listOf(arrived("""{"fnc":1,"kind":"pin","re":{"mid":"m1","author":0,"preview":""}}""")), ) - assertEquals(setOf(thread), touched, "the pin never reached the thread") + // §6.19, as for a reaction above: applied, redrawn, never unread. + assertEquals(emptySet(), touched, "a pin marked the thread unread") + assertEquals(setOf(thread), s.takeQuietlyChanged(), "the pin never reached the thread") assertEquals(1, s.history(thread).size, "the pin was filed as a message of its own") } } diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/InboundControlThroughAPollTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/InboundControlThroughAPollTest.kt new file mode 100644 index 0000000..75c0225 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/InboundControlThroughAPollTest.kt @@ -0,0 +1,185 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.Conversations +import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayClient +import io.flamenet.e2e.Content +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import javax.crypto.KeyGenerator +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Edits, pins and reactions as a poll delivers them (§6.15, §6.19, §6.9 and + * §6.22), through the same `absorb` a poll runs, not by calling the apply + * functions directly. None of them is ever a bubble or news: each lands on + * what it names, and the thread is redrawn without being marked unread. + * + * Every one of these was dropped on the floor: they were handled inside a + * loop over renderable messages only, and none of them is renderable. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class InboundControlThroughAPollTest { + private class MapStorage : Vault.Storage { + val records = LinkedHashMap<String, ByteArray>() + + override fun read(name: String) = records[name] + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + + override fun names() = records.keys.toList() + } + + private val me = 1L + private val sam = 2L + private val outsider = 9L + private val samThread = Vault.threadForUser(sam) + private val groupThread = Vault.threadForGroup("g-1") + private lateinit var server: MockWebServer + private lateinit var vault: Vault + private var nextId = 1L + + @BeforeTest fun start() { + server = + MockWebServer().also { + it.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val body = + if (request.path.orEmpty().contains("/e2e/groups")) { + """{"groups":[{"gid":"g-1","name":"Saturday","owner":1,"members":[{"user_id":1},{"user_id":2}]}]}""" + } else { + "{}" + } + return MockResponse().setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + } + } + it.start() + } + } + + @AfterTest fun stop() = server.shutdown() + + private fun session(): Session { + val identity = DeviceIdentity.create() + vault = Vault(MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + vault.saveIdentity(identity) + val base = server.url("/").toString() + return Session("me", me, identity, vault, RelayClient(base, "me", "pw"), MessengerApi(base, "me", "pw")) + } + + private fun from( + user: Long, + wire: String, + ) = Conversations.Received( + id = nextId++, + fromUser = user, + fromDevice = "device-$user", + content = Content.parse(wire), + senderProven = true, + at = System.currentTimeMillis(), + ) + + /** One poll's worth: what it touched, whether it had words for a notification, and what it redrew. */ + private fun Session.poll(vararg arriving: Conversations.Received): Set<String> { + val touched = absorbForTesting(arriving.toList()) + assertNull(takeNotice(), "a control envelope had words for the lock screen") + return touched + } + + @Test + fun `an edit in a poll rewrites the message it names, and only from its author`() { + val s = session() + s.record(samThread, "frist", mine = false, author = sam, messageId = "m-2") + assertTrue(s.poll(from(outsider, Content.edit("hijacked", "m-2"))).isEmpty()) + assertEquals("frist", s.history(samThread).single().text, "somebody else's edit was applied") + + val touched = s.poll(from(sam, Content.edit("first", "m-2"))) + val line = s.history(samThread).single() + assertEquals("first", line.text, "the edit never reached the message") + assertNotNull(line.editedAt, "an edit must show as one") + assertTrue(touched.isEmpty(), "an edit marked the chat unread, which is what notifies") + assertTrue(samThread in s.takeQuietlyChanged(), "the open chat is not redrawn") + } + + @Test + fun `a pin in a poll pins the message and draws nothing`() { + val s = session() + s.record(samThread, "the address", mine = false, author = sam, messageId = "m-1") + val touched = s.poll(from(sam, Content.pin("m-1"))) + assertEquals("m-1", s.pinnedMessage(samThread), "the pin never arrived") + assertEquals(1, s.history(samThread).size, "a pin was drawn as a message") + assertTrue(touched.isEmpty()) + assertTrue(samThread in s.takeQuietlyChanged()) + } + + @Test + fun `a reaction in a poll lands on the message it names`() { + val s = session() + s.record(samThread, "lunch?", mine = true, author = me, messageId = "m-1") + val touched = s.poll(from(sam, Content.reaction("+", Content.Reference("m-1", me, "")))) + val line = s.history(samThread).single() + assertEquals(setOf(sam), line.reactions["+"], "the reaction never arrived") + assertTrue(touched.isEmpty(), "a reaction marked the chat unread") + assertTrue(samThread in s.takeQuietlyChanged()) + } + + @Test + fun `a reaction to one of my stories in a poll is counted`() { + val s = session() + vault.saveStories( + listOf( + Vault.Story( + id = "s-1", + authorId = me, + mine = true, + ref = null, + caption = "back on Thursday", + postedAt = System.currentTimeMillis(), + expiresAt = System.currentTimeMillis() / 1000.0 + 3600, + ), + ), + nowSeconds = System.currentTimeMillis() / 1000.0, + ) + val touched = s.poll(from(sam, Content.storyReaction("+", "s-1", me))) + assertEquals(mapOf(sam to "+"), s.reactionsFor("s-1"), "the story reaction never arrived") + assertTrue(touched.isEmpty()) + assertTrue(s.history(samThread).isEmpty(), "a story reaction was drawn into the conversation") + } + + @Test + fun `in a group, only a member's edit, pin or reaction counts`() { + val s = session() + s.record(groupThread, "see you there", mine = false, author = sam, messageId = "m-g") + s.poll(from(outsider, Content.reaction("+", Content.Reference("m-g", sam, ""), groupId = "g-1"))) + s.poll(from(outsider, Content.pin("m-g", groupId = "g-1"))) + assertEquals(emptyMap(), s.history(groupThread).single().reactions, "an outsider's reaction landed in the group") + assertNull(s.pinnedMessage(groupThread), "an outsider pinned a message in the group") + + s.poll(from(sam, Content.reaction("+", Content.Reference("m-g", sam, ""), groupId = "g-1"))) + assertEquals(setOf(sam), s.history(groupThread).single().reactions["+"]) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/IncomingLinkPreviewTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/IncomingLinkPreviewTest.kt new file mode 100644 index 0000000..b9d55fd --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/IncomingLinkPreviewTest.kt @@ -0,0 +1,90 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.Conversations +import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayClient +import io.flamenet.e2e.Content +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import javax.crypto.KeyGenerator +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * A link preview that arrives inside a message is kept with it (§6.12): the + * sender fetched it, and the receiver draws it from those bytes. Arrivals never + * passed it on, so no incoming message ever showed one. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class IncomingLinkPreviewTest { + private class MapStorage : Vault.Storage { + val records = LinkedHashMap<String, ByteArray>() + + override fun read(name: String) = records[name] + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + + override fun names() = records.keys.toList() + } + + private val sam = 2L + private val samThread = Vault.threadForUser(sam) + private lateinit var server: MockWebServer + private lateinit var vault: Vault + private val identity = DeviceIdentity.create() + + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + repeat(20) { server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) } + vault = Vault(MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + vault.saveIdentity(identity) + } + + @AfterTest fun stop() = server.shutdown() + + private fun session(): Session { + val base = server.url("/").toString() + return Session("me", 1, identity, vault, RelayClient(base, "me", "pw"), MessengerApi(base, "me", "pw")) + } + + private fun from(wire: String) = + Conversations.Received(id = 1, fromUser = sam, fromDevice = "device-2", content = Content.parse(wire), senderProven = true, at = 1L) + + @Test + fun `a shared link arrives with its preview and keeps it`() { + // DESIGN item 18's shape: the URL alone as the text, `desc` present and empty. + val preview = Content.LinkPreview("https://flamenet.io/hi/sam", "Sam's page", "") + val s = session() + s.absorbForTesting(listOf(from(Content.text("https://flamenet.io/hi/sam", messageId = "m-1", link = preview)))) + + val line = s.history(samThread).single() + assertEquals("https://flamenet.io/hi/sam", line.text) + assertEquals(preview, line.link) + assertEquals(preview, session().history(samThread).single().link, "the preview did not reach the history store") + } + + @Test + fun `a message without a preview has none`() { + val s = session() + s.absorbForTesting(listOf(from(Content.text("no link here", messageId = "m-2")))) + assertNull(s.history(samThread).single().link) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/NavDestinationsTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/NavDestinationsTest.kt index d731c82..d69d815 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/NavDestinationsTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/NavDestinationsTest.kt @@ -12,14 +12,15 @@ import kotlin.test.assertTrue class NavDestinationsTest { /** * The order matches iOS, which puts Files and Wallet between Stories and - * Account. + * Account. Today sits beside Messages (SPEC §8.12). */ @Test - fun `the bar is the seven destinations iOS has, in the same order`() { + fun `the bar is the eight destinations, in the agreed order`() { assertEquals( listOf( Screen.CONTACTS, Screen.MESSAGES, + Screen.TODAY, Screen.CALLS, Screen.STORIES, Screen.FILES, @@ -44,6 +45,31 @@ class NavDestinationsTest { ) } + /** + * Supervision hides the Today tab, and with it the directory that is only + * reached from there (DESIGN 31). Nothing else goes. + */ + @Test + fun `supervision hides Today and nothing else`() { + val supervised = NavDestinations.tabs(storiesHidden = false, todayHidden = true).map { it.screen } + assertFalse(Screen.TODAY in supervised) + assertEquals(NavDestinations.tabs(storiesHidden = false).map { it.screen } - Screen.TODAY, supervised) + } + + /** + * The bar asks the parental controls, and a Today screen left open when + * supervision came on shows Home instead. + */ + @Test + fun `the bar and the screen both ask the parental controls`() { + val activity = java.io.File("src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt").readText() + assertTrue(activity.contains("todayHidden = ParentalControls.todayHidden(context)"), "the bar does not hide Today") + val screen = activity.substringAfter("MessengerViewModel.Phase.Screen.TODAY -> {").substringBefore("MessengerViewModel.Phase.Screen.CALLS") + assertTrue(screen.contains("ParentalControls.todayHidden("), "an open Today screen ignores supervision") + val controls = java.io.File("src/main/kotlin/io/flamenet/messenger/app/ParentalControls.kt").readText() + assertTrue(controls.contains("fun todayHidden(context: Context) = isEnabled(context)")) + } + /** * The tags are how the instrumented suite finds the bar. */ @@ -53,6 +79,7 @@ class NavDestinationsTest { listOf( "nav-contacts", "nav-messages", + "nav-today", "nav-calls", "nav-stories", "nav-files", diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt index 2dbcf3a..3e2a9f7 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt @@ -486,4 +486,162 @@ class OutboxTest { "two sends must be two distinguishable entries", ) } + + /** A directory answer for one freshly registered device, with a prekey. */ + private fun oneDevice(): String { + val device = DeviceIdentity.create() + val opk = device.generateOneTimePreKeys(1).first() + return org.json + .JSONObject() + .put( + "devices", + org.json.JSONArray().put( + device.registrationWire().put( + "opk", + org.json.JSONObject().put("id", opk.id).put( + "pub", + android.util.Base64.encodeToString(opk.publicKey, android.util.Base64.NO_WRAP), + ), + ), + ), + ).toString() + } + + /** + * A photo to a group follows the group rule (owner, 0.32): the members + * nothing reached are queued, and the bubble leaves pending once the last + * of them is. + */ + @Test + fun `a group photo that reached nobody waits in the outbox and leaves pending when it goes`() { + val session = session() + val group = + io.flamenet.client.RelayClient + .Group(id = "g1", name = "test", owner = 1, memberIds = listOf(1L, 101L, 102L)) + server.enqueue(json("""{"id":"photo-1"}""")) // the upload + server.enqueue(json("""{"devices":[]}""")) // 101 signed out + server.enqueue(json("""{"devices":[]}""")) // 102 signed out + val sent = session.sendGroupImage(group, ByteArray(64) { it.toByte() }, "image/jpeg", 8, 8) + + val thread = Vault.threadForGroup("g1") + val bubble = session.history(thread).single() + assertEquals(Session.Delivery.PENDING, bubble.delivery) + assertEquals("photo-1", bubble.attachment?.id) + assertEquals("photo-1", sent.attachment?.id) + val outbox = session.vault.outbox() + assertEquals(2, outbox.length(), "one entry per member nothing reached") + for (i in 0 until outbox.length()) { + val entry = outbox.getJSONObject(i) + assertEquals(thread, entry.optString("thread"), "a queued photo would be restated in the wrong chat") + assertEquals(bubble.at, entry.getLong("at"), "a queued photo would restate another bubble") + } + + // Both come back; the photo goes as the reference, not uploaded again. + val first = oneDevice() + server.enqueue(json(first)) // the directory + server.enqueue(json(first)) // the key route + server.enqueue(json("""{"ok":true}""")) + val second = oneDevice() + server.enqueue(json(second)) + server.enqueue(json(second)) + server.enqueue(json("""{"ok":true}""")) + val touched = session.flushOutbox() + + assertEquals(0, session.vault.outbox().length()) + assertTrue(thread in touched) + val after = session.history(thread).single() + assertEquals(Session.Delivery.DELIVERED, after.delivery, "the photo stayed pending") + assertEquals("photo-1", after.attachment?.id, "the bubble lost its photo") + val paths = (0 until server.requestCount).map { server.takeRequest().path.orEmpty() } + assertEquals(1, paths.count { "/e2e/attachments" in it }, "the photo was uploaded more than once: $paths") + } + + /** One registered device for each queued entry, in order: directory, key route, post. */ + private fun everyoneComesBack(entries: Int) { + repeat(entries) { + val wire = oneDevice() + server.enqueue(json(wire)) + server.enqueue(json(wire)) + server.enqueue(json("""{"ok":true}""")) + } + } + + /** + * A photo, a voice note or a file to one person follows the rule text + * and a group photo follow (owner, 0.32): what reached nobody waits in the + * outbox as the reference, uploaded once, and the bubble leaves pending + * once it goes. + */ + private fun waitsAndGoes( + id: String, + send: (Session) -> io.flamenet.client.Conversations.Sent, + ) { + val session = session() + server.enqueue(json("""{"id":"$id"}""")) // the upload + server.enqueue(json("""{"devices":[]}""")) // signed out + val sent = send(session) + + val thread = Vault.threadForUser(peer) + val bubble = session.history(thread).single() + assertEquals(Session.Delivery.PENDING, bubble.delivery) + assertEquals(id, bubble.attachment?.id) + assertEquals(id, sent.attachment?.id) + val outbox = session.vault.outbox() + assertEquals(1, outbox.length(), "what reached nobody was not queued") + assertEquals(bubble.at, outbox.getJSONObject(0).getLong("at"), "the queued copy would restate another bubble") + + everyoneComesBack(1) + session.flushOutbox() + assertEquals(0, session.vault.outbox().length()) + val after = session.history(thread).single() + assertEquals(Session.Delivery.DELIVERED, after.delivery, "the bubble stayed pending") + assertEquals(id, after.attachment?.id, "the bubble lost what it carried") + val paths = (0 until server.requestCount).map { server.takeRequest().path.orEmpty() } + assertEquals(1, paths.count { "/e2e/attachments" in it }, "uploaded more than once: $paths") + } + + @Test + fun `a photo to somebody with no device waits, and leaves pending when it goes`() = + waitsAndGoes("photo-1") { it.sendImage(peer, ByteArray(64) { b -> b.toByte() }, "image/jpeg", 8, 8) } + + @Test + fun `a voice note to somebody with no device waits, and leaves pending when it goes`() = + waitsAndGoes("voice-1") { it.sendVoice(peer, ByteArray(64) { b -> b.toByte() }, "audio/mp4", 3.5) } + + @Test + fun `a file to somebody with no device waits, and leaves pending when it goes`() = + waitsAndGoes("file-1") { it.sendFile(peer, "plain words".toByteArray(), "notes.txt", 0, emptyList()) } + + @Test + fun `a file to a group that reached nobody waits, and leaves pending when it goes`() { + val session = session() + val group = + io.flamenet.client.RelayClient + .Group(id = "g2", name = "test", owner = 1, memberIds = listOf(1L, 101L, 102L)) + server.enqueue(json("""{"id":"file-g"}""")) // the upload + server.enqueue(json("""{"devices":[]}""")) // 101 signed out + server.enqueue(json("""{"devices":[]}""")) // 102 signed out + session.sendGroupFile(group, "plain words".toByteArray(), "notes.txt", 0, emptyList()) + + val thread = Vault.threadForGroup("g2") + val bubble = session.history(thread).single() + assertEquals(Session.Delivery.PENDING, bubble.delivery) + assertEquals("file-g", bubble.attachment?.id) + assertEquals("notes.txt", bubble.attachment?.filename) + val outbox = session.vault.outbox() + assertEquals(2, outbox.length(), "one entry per member nothing reached") + for (i in 0 until outbox.length()) { + assertEquals(thread, outbox.getJSONObject(i).optString("thread")) + assertEquals(bubble.at, outbox.getJSONObject(i).getLong("at")) + } + + everyoneComesBack(2) + session.flushOutbox() + assertEquals(0, session.vault.outbox().length()) + val after = session.history(thread).single() + assertEquals(Session.Delivery.DELIVERED, after.delivery, "the file stayed pending") + assertEquals("notes.txt", after.attachment?.filename) + val paths = (0 until server.requestCount).map { server.takeRequest().path.orEmpty() } + assertEquals(1, paths.count { "/e2e/attachments" in it }, "uploaded more than once: $paths") + } } diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/SharedVectorsContentKindsTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/SharedVectorsContentKindsTest.kt new file mode 100644 index 0000000..190c550 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/SharedVectorsContentKindsTest.kt @@ -0,0 +1,221 @@ +package io.flamenet.messenger.app + +import io.flamenet.e2e.Content +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.fail + +/** + * The 0.32 content kinds against the shared vectors, `content-kinds-v1.json` + * from flamenet-e2e (§6.31 to §6.35, §6.12, §6.10). + * + * Two halves. Every `written` envelope is parsed and written back with this + * app's own encoders, and the bytes must be the file's. Every envelope, written + * or read, goes through `Content.parse` and the checks the session applies + * before anything is drawn or taken (control traffic, a game move's own rules + * through `ChatGames.receive`), and what comes out must be what the file says, + * for the fields the case lists. + * + * The file is found through `FLAMENET_CONTENT_KINDS_VECTORS`, or in a + * flamenet-e2e checkout beside this one. + */ +class SharedVectorsContentKindsTest { + private val file: JSONObject by lazy { + val candidates = + listOfNotNull( + System.getenv("FLAMENET_CONTENT_KINDS_VECTORS"), + "../../flamenet-e2e/test/vectors/content-kinds-v1.json", + "../../../flamenet-e2e/test/vectors/content-kinds-v1.json", + ).map(::File) + val found = + candidates.firstOrNull { it.exists() } + ?: throw AssertionError("content-kinds-v1.json not found. Looked in: ${candidates.joinToString { it.absolutePath }}") + JSONObject(found.readText()) + } + + private val dropped = mapOf("verdict" to "dropped") + + /** What this app makes of one plaintext, in the file's terms. */ + private fun read(plaintext: String): Map<String, Any?> { + val c = Content.parse(plaintext) + if (c.raw == null) return mapOf("verdict" to "plain") + val common = + mapOf( + "mid" to c.messageId, + "gid" to c.groupId, + "forwarded" to c.isForwarded, + "ownTimer" to c.lifetimeSeconds, + ) + when (c.kind) { + Content.Kind.RSVP -> { + val r = c.rsvp ?: return dropped + return common + mapOf("verdict" to "rsvp", "rsvp" to mapOf("eid" to r.eventId, "r" to r.answer, "n" to r.n)) + } + + Content.Kind.GAME_MOVE, Content.Kind.GAME_END -> { + // As the session takes them: one to one only, then the rules' + // own refusals before anything is built. + val g = c.game ?: return dropped + if (c.groupId != null) return dropped + if (ChatGames.receive(null, c.kind, g) == ChatGames.Received.Ignored) return dropped + return common + mapOf("verdict" to c.kind, "game" to game(g, c.kind)) + } + + Content.Kind.UNPOST -> { + val u = c.unpost ?: return dropped + return common + mapOf("verdict" to "unpost", "sid" to u.storyId, "set" to u.setId) + } + + Content.Kind.EDIT -> { + val target = c.editOf + if (target != null) { + return common + mapOf("verdict" to "edit", "story" to c.editsStory, "mid" to target, "caption" to c.text) + } + } + } + if (c.isControl || !c.isRenderable) return dropped + c.card?.let { card -> + val body = mutableMapOf<String, Any?>("d" to card.design, "msg" to card.msg) + card.to?.let { body["to"] = it } + card.from?.let { body["from"] = it } + card.open?.let { body["open"] = it } + return common + mapOf("verdict" to "card", "card" to body, "drawn" to card.shownDesign) + } + c.event?.let { e -> + val ev = mutableMapOf<String, Any?>("id" to e.id, "rev" to e.rev, "title" to e.title, "cancel" to e.cancelled) + if (e.start != null) { + ev["start"] = e.start + e.tz?.let { ev["tz"] = it } + } else { + ev["day"] = e.day + ev["dayCount"] = e.days ?: 1 + } + e.place?.let { ev["place"] = it } + e.note?.let { ev["note"] = it } + return common + + mapOf("verdict" to "event", "ev" to ev, "allDay" to e.allDay, "endShown" to if (e.start != null) e.end else null) + } + if (c.kind == Content.Kind.GAME) { + c.game?.let { return common + mapOf("verdict" to "game", "game" to game(it, c.kind)) } + } + if (c.kind == Content.Kind.TEXT) { + val link = c.link?.let { mapOf("url" to it.url, "title" to it.title, "desc" to it.desc) } + return common + mapOf("verdict" to "text", "caption" to c.text, "lnk" to link) + } + if (c.text == "[unsupported attachment]" && c.attachment == null) return mapOf("verdict" to "unsupported") + return mapOf("verdict" to "other: ${c.kind}") + } + + private fun game( + g: Content.Game, + kind: String, + ): Map<String, Any?> { + val out = mutableMapOf<String, Any?>("id" to g.id, "t" to g.type, "mine" to g.mine) + if (kind != Content.Kind.GAME) g.log?.let { out["log"] = it } + if (kind == Content.Kind.GAME_END) g.ending?.let { out["r"] = it } + return out + } + + /** The bytes this app writes for what it read from [plaintext]. */ + private fun write(plaintext: String): String? { + val c = Content.parse(plaintext) + val raw = c.raw ?: return null + val groupName = raw.optString("gname").takeIf { it.isNotEmpty() } + return when (c.kind) { + Content.Kind.CARD -> { + val card = c.card ?: return null + Content.card(card, c.messageId ?: return null, c.groupId, groupName, c.isForwarded) + } + + Content.Kind.EVENT -> { + val event = c.event ?: return null + Content.event(event, c.messageId ?: return null, c.groupId, groupName, c.isForwarded) + } + + Content.Kind.RSVP -> { + c.rsvp?.let { Content.rsvp(it, c.groupId) } + } + + Content.Kind.GAME -> { + c.game?.let { Content.game(it, c.messageId ?: return null) } + } + + Content.Kind.GAME_MOVE -> { + c.game?.let { Content.gameMove(it) } + } + + Content.Kind.GAME_END -> { + c.game?.let { Content.gameEnd(it) } + } + + Content.Kind.UNPOST -> { + val u = c.unpost ?: return null + u.storyId?.let { Content.unpostStory(it) } ?: u.setId?.let { Content.unpostStorySet(it) } + } + + Content.Kind.EDIT -> { + if (c.editsStory) c.editOf?.let { Content.storyEdit(it, c.text) } else null + } + + else -> { + null + } + } + } + + /** JSON values and this app's values in one shape: whole numbers as Long, objects as maps. */ + private fun plain(value: Any?): Any? = + when (value) { + null, JSONObject.NULL -> null + is JSONObject -> value.keys().asSequence().associateWith { plain(value.get(it)) } + is JSONArray -> (0 until value.length()).map { plain(value.get(it)) } + is Map<*, *> -> value.entries.associate { (k, v) -> k.toString() to plain(v) } + is List<*> -> value.map(::plain) + // org.json reads a fraction as a BigDecimal: kept as a fraction, never cut to a whole number. + is java.math.BigDecimal -> if (value.stripTrailingZeros().scale() <= 0) value.toLong() else value.toDouble() + is Double -> if (value == Math.floor(value) && !value.isInfinite()) value.toLong() else value + is Float -> plain(value.toDouble()) + is Number -> value.toLong() + else -> value + } + + @Test + fun `every written envelope is what this app writes, byte for byte`() { + val written = file.getJSONArray("written") + assertEquals(24, written.length()) + val wrong = mutableListOf<String>() + for (i in 0 until written.length()) { + val one = written.getJSONObject(i) + val envelope = one.getString("envelope") + val mine = runCatching { write(envelope) }.getOrElse { "threw: $it" } + if (mine != envelope) wrong += "written '${one.getString("name")}': the file has $envelope, this app writes $mine" + } + println("content-kinds written: ${written.length()} cases, ${wrong.size} differ") + if (wrong.isNotEmpty()) fail(wrong.joinToString("\n")) + } + + @Test + fun `every envelope reads the way the file says`() { + val cases = (0 until file.getJSONArray("written").length()).map { file.getJSONArray("written").getJSONObject(it) } + + (0 until file.getJSONArray("read").length()).map { file.getJSONArray("read").getJSONObject(it) } + assertEquals(132, cases.size) + val wrong = mutableListOf<String>() + for (one in cases) { + val envelope = one.getString("envelope") + val want = one.getJSONObject("reads") + val got = read(envelope) + for (key in want.keys().asSequence().sorted()) { + val expected = plain(want.get(key)) + val actual = plain(got[key]) + if (expected != actual) { + wrong += "read '${one.getString("name")}': $key is $expected in the file, $actual here. Envelope: $envelope" + } + } + } + println("content-kinds read: ${cases.size} cases, ${wrong.size} fields differ") + if (wrong.isNotEmpty()) fail(wrong.joinToString("\n")) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/SharedVectorsHelloLinkTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/SharedVectorsHelloLinkTest.kt new file mode 100644 index 0000000..861a013 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/SharedVectorsHelloLinkTest.kt @@ -0,0 +1,55 @@ +package io.flamenet.messenger.app + +import org.json.JSONObject +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.fail + +/** + * Hello links against the shared vectors, `hello-link-v1.json` from + * flamenet-e2e (§6.34). The `link` cases go through what the app does with a + * link it is opened with ([HelloLink.fromLink], as `openHelloLink` calls it), + * and the `scan` cases through what a scanned code fills the add screen with + * ([prefillFor]). The `fragment` cases are the relay's /hi page, not this app. + * + * The file is found through `FLAMENET_HELLO_LINK_VECTORS`, or in a + * flamenet-e2e checkout beside this one. + */ +class SharedVectorsHelloLinkTest { + private val file: JSONObject by lazy { + val candidates = + listOfNotNull( + System.getenv("FLAMENET_HELLO_LINK_VECTORS"), + "../../flamenet-e2e/test/vectors/hello-link-v1.json", + "../../../flamenet-e2e/test/vectors/hello-link-v1.json", + ).map(::File) + val found = + candidates.firstOrNull { it.exists() } + ?: throw AssertionError("hello-link-v1.json not found. Looked in: ${candidates.joinToString { it.absolutePath }}") + JSONObject(found.readText()) + } + + @Test + fun `every link and scan case gives the address or refusal the file says`() { + val cases = file.getJSONArray("cases") + val checked = mutableMapOf("link" to 0, "scan" to 0) + val wrong = mutableListOf<String>() + for (i in 0 until cases.length()) { + val one = cases.getJSONObject(i) + val context = one.getString("context") + if (context != "link" && context != "scan") continue + val input = one.getString("input") + val want = if (one.isNull("expect")) null else one.getString("expect") + val got = if (context == "link") HelloLink.fromLink(input) else prefillFor(input) + if (got != want) { + wrong += "$context '${one.getString("name")}': the file says ${want ?: "refused"}, " + + "this app gives ${got ?: "refused"}. Input: ${input.take(120)}" + } + checked[context] = checked.getValue(context) + 1 + } + println("hello links: $checked checked, ${wrong.size} differ") + assertEquals(mapOf("link" to 37, "scan" to 18), checked) + if (wrong.isNotEmpty()) fail(wrong.joinToString("\n")) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/StickerPacksTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/StickerPacksTest.kt new file mode 100644 index 0000000..b3633ac --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/StickerPacksTest.kt @@ -0,0 +1,94 @@ +package io.flamenet.messenger.app + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The shipped sticker packs (docs/STICKERS.md), read from the manifests in the + * app's own assets, the same files the picker loads. + */ +class StickerPacksTest { + private val folder = File("src/main/assets/stickers") + + private fun shipped(): List<StickerPack.Pack> { + assertTrue(folder.isDirectory, "this test reads app/src/main/assets/stickers") + return StickerPack.ordered( + folder.listFiles { f -> f.name.endsWith(".json") }!!.mapNotNull { StickerPack.parse(it.readText()) }, + ) + } + + @Test + fun `the glyphs come first, then the FlameNet art`() { + val packs = shipped() + assertEquals(listOf(StickerPack.CORE, StickerPack.RETRO), packs.map { it.id }) + assertEquals(listOf("Flamenet", "FlameNet"), packs.map { it.name }) + } + + @Test + fun `every picture a manifest names is shipped beside it`() { + val retro = shipped().single { it.id == StickerPack.RETRO } + assertEquals(24, retro.stickers.size) + for (sticker in retro.stickers) { + val path = assertNotNullPath(sticker.image, sticker.id) + assertTrue(File(folder.parentFile, path).isFile, "${sticker.id} names $path, which is not shipped") + assertTrue(sticker.glyph.isNotBlank() && sticker.name.isNotBlank()) + } + // The glyph pack ships no pictures at all. + assertTrue(shipped().single { it.id == StickerPack.CORE }.stickers.all { it.image == null }) + } + + private fun assertNotNullPath( + image: String?, + id: String, + ): String { + assertTrue(image != null, "$id has no picture") + return image + } + + @Test + fun `a picture first, then the glyph, then the pack's fallback, then the core's`() { + val packs = shipped() + val flame = StickerPack.resolve(packs, StickerPack.RETRO, "flame") + assertEquals("stickers/flamenet.retro/flame.png", flame.image) + assertEquals("\uD83D\uDD25", flame.glyph, "the glyph is kept for when the picture will not load") + + val wave = StickerPack.resolve(packs, StickerPack.CORE, "wave") + assertNull(wave.image) + assertEquals("\uD83D\uDC4B", wave.glyph) + + val unknownId = StickerPack.resolve(packs, StickerPack.RETRO, "drawn-next-year") + assertNull(unknownId.image) + assertEquals("\uD83D\uDE42", unknownId.glyph) + assertEquals("Sticker", unknownId.name) + + // A pack this build does not have draws as the core fallback. + val unknownPack = StickerPack.resolve(packs, "somebody.else", "x") + assertEquals("\uD83D\uDE42", unknownPack.glyph) + assertNull(unknownPack.image) + } + + @Test + fun `a manifest cannot point outside its own folder`() { + val pack = + StickerPack.parse( + """{"pack":"p","name":"P","fallback":"x","stickers":[ + {"id":"a","glyph":"a","name":"A","image":"../../secret.png"}, + {"id":"b","glyph":"b","name":"B","image":"b.png"}, + {"id":"","glyph":"c","name":"C"}]}""", + )!! + assertEquals(listOf("a", "b"), pack.stickers.map { it.id }, "a sticker with no id was kept") + assertNull(pack.stickers[0].image) + assertEquals("stickers/p/b.png", pack.stickers[1].image) + } + + @Test + fun `a broken manifest is no pack, and other packs sort after the two shipped`() { + assertNull(StickerPack.parse("not json")) + assertNull(StickerPack.parse("""{"name":"no id"}""")) + val extra = StickerPack.Pack("aaa.extra", "Extra", "x", emptyList()) + assertEquals(listOf(StickerPack.CORE, StickerPack.RETRO, "aaa.extra"), StickerPack.ordered(listOf(extra) + shipped()).map { it.id }) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/StoredStartStateTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/StoredStartStateTest.kt new file mode 100644 index 0000000..c541cb7 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/StoredStartStateTest.kt @@ -0,0 +1,98 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayClient +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.json.JSONArray +import org.json.JSONObject +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import javax.crypto.KeyGenerator +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * What a start reads from the phone alone (DESIGN 47): the contact list and + * the groups as they were last listed, with nothing asked of the relay. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class StoredStartStateTest { + private lateinit var server: MockWebServer + + private class MapStorage : Vault.Storage { + private val records = mutableMapOf<String, ByteArray>() + + override fun read(name: String) = records[name] + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + + override fun names() = records.keys.toList() + } + + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + } + + @AfterTest fun stop() { + server.shutdown() + } + + private fun session(vault: Vault): Session { + val identity = DeviceIdentity.create() + vault.saveIdentity(identity) + val base = server.url("/").toString() + return Session("alice", 1, identity, vault, RelayClient(base, "alice", "pw"), MessengerApi(base, "alice", "pw")) + } + + private fun vault() = Vault(MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + + @Test + fun `a phone that never listed anything says so, and asks nobody`() { + val s = session(vault()) + assertNull(s.storedRoster()) + assertNull(s.storedGroups()) + assertEquals(0, server.requestCount) + } + + @Test + fun `the stored contacts and groups come back with the relay out of reach`() { + val v = vault() + v.saveRoster(JSONArray().put(JSONObject().put("id", 9).put("handle", "ada").put("name", "Ada")), offServer = false) + val s = session(v) + // Once, while the relay answered. + server.enqueue( + MockResponse() + .setHeader("Content-Type", "application/json") + .setBody("""{"groups":[{"gid":"g-1","name":"Saturday","owner":9,"members":[{"user_id":1},{"user_id":9}]}]}"""), + ) + assertEquals(listOf("g-1"), s.namedGroups().map { it.id }) + + // Then out of reach: nothing is asked. + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE)) + val asked = server.requestCount + assertEquals(listOf("ada"), s.storedRoster()?.map { it.handle }) + val groups = s.storedGroups()!! + assertEquals(listOf("g-1"), groups.map { it.id }) + assertEquals("Saturday", groups.single().name) + assertEquals(listOf(1L, 9L), groups.single().memberIds) + assertEquals(asked, server.requestCount, "a start asked the relay") + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/StoriesBetweenTwoPhonesTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/StoriesBetweenTwoPhonesTest.kt new file mode 100644 index 0000000..e0ce077 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/StoriesBetweenTwoPhonesTest.kt @@ -0,0 +1,238 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayClient +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okio.Buffer +import org.json.JSONArray +import org.json.JSONObject +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import java.nio.file.Files +import java.time.Instant +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong +import javax.crypto.KeyGenerator +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A story posted, edited and taken down between two real Sessions (§6.6, + * §6.15 for stories, §6.35): every envelope goes through the real ratchet, the + * picture through a sealed blob, and the friend's phone collects them with an + * ordinary poll. It ends with nothing of the story left on either phone. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class StoriesBetweenTwoPhonesTest { + private class MapStorage : Vault.Storage { + val records = LinkedHashMap<String, ByteArray>() + + override fun read(name: String) = records[name] + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + + override fun names() = records.keys.toList() + } + + private lateinit var server: MockWebServer + private val held = Collections.synchronizedList(mutableListOf<JSONObject>()) + private val blobs = ConcurrentHashMap<String, ByteArray>() + private val nextEnvelope = AtomicLong(1) + private val phones = ConcurrentHashMap<Long, Phone>() + + @BeforeTest fun start() { + server = MockWebServer().also { it.dispatcher = relay() }.also { it.start() } + } + + @AfterTest fun stop() { + server.shutdown() + phones.values.forEach { it.cache.deleteRecursively() } + } + + private inner class Phone( + val user: Long, + val name: String, + ) { + val identity: DeviceIdentity = DeviceIdentity.create() + val vault = + Vault(MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + .also { it.saveIdentity(identity) } + val cache: File = Files.createTempDirectory("cache-$name").toFile() + val session: Session + + init { + val base = server.url("/").toString() + session = + Session(name, user, identity, vault, RelayClient(base, name, "pw"), MessengerApi(base, name, "pw")) + .also { it.attachmentCacheDir = cache } + phones[user] = this + } + + /** A poll that must be neither news nor drawn into any conversation. */ + fun quietPoll() { + assertTrue(session.receive().isEmpty(), "a story change marked a chat unread") + assertNull(session.takeNotice(), "a story change had words for the lock screen") + } + } + + private fun json(body: String) = MockResponse().setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + + private fun relay() = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val url = request.requestUrl ?: return MockResponse().setResponseCode(400) + val path = url.encodedPath.substringAfter("/flamenet/v1") + return when { + path.startsWith("/e2e/log") -> { + MockResponse().setResponseCode(404) + } + + path == "/e2e/attachments" && request.method == "POST" -> { + val bytes = request.body.readByteArray() + val id = (1..64).map { "0123456789abcdef"[(Math.random() * 16).toInt()] }.joinToString("") + blobs[id] = bytes + json(JSONObject().put("ok", true).put("id", id).put("bytes", bytes.size).toString()) + } + + path.startsWith("/e2e/attachments/") -> { + val bytes = blobs[path.substringAfterLast('/')] ?: return MockResponse().setResponseCode(404) + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/octet-stream") + .setBody(Buffer().write(bytes)) + } + + path == "/e2e/messages" && request.method == "POST" -> { + val body = JSONObject(request.body.readUtf8()) + val fromDevice = body.optString("from_device") + val fromUser = phones.values.firstOrNull { it.identity.deviceId == fromDevice }?.user ?: 0L + val messages = body.getJSONArray("messages") + for (i in 0 until messages.length()) { + val m = messages.getJSONObject(i) + held += + JSONObject() + .put("id", nextEnvelope.getAndIncrement()) + .put("to_device", m.getString("to_device")) + .put("from_user", fromUser) + .put("from_device", fromDevice) + .put("payload", m.getString("payload")) + .put("created_at", Instant.now().toString()) + .put("envelope_id", m.optString("envelope_id")) + } + json("""{"ok":true}""") + } + + path == "/e2e/messages" -> { + val device = url.queryParameter("device_id").orEmpty() + val after = url.queryParameter("after")?.toLongOrNull() ?: 0L + val out = JSONArray() + synchronized(held) { + held.filter { it.getString("to_device") == device && it.getLong("id") > after }.forEach { out.put(it) } + } + json(JSONObject().put("messages", out).toString()) + } + + path.startsWith("/e2e/devices/") || path.startsWith("/e2e/keys/") -> { + val phone = path.substringAfterLast('/').toLongOrNull()?.let { phones[it] } + val devices = JSONArray().also { a -> phone?.let { a.put(it.identity.registrationWire()) } } + json(JSONObject().put("devices", devices).toString()) + } + + path.startsWith("/e2e/groups") -> { + json("""{"groups":[]}""") + } + + else -> { + json("{}") + } + } + } + } + + private val picture = ByteArray(4096) { (it % 251).toByte() } + + @Test + fun `a photo story posted, edited and taken down leaves the friend's phone with nothing`() { + val alice = Phone(1, "alice") + val bob = Phone(2, "bob") + + val posted = + alice.session.postStory(picture, "image/jpeg", 1080, 1920, "the top was cut", listOf(bob.user), lifetimeSeconds = 86_400.0) + assertEquals(1, posted.reached) + val mine = alice.session.stories().single() + assertEquals(listOf(bob.user), mine.sentTo, "who it went to was not kept") + + bob.session.receive() + val theirs = bob.session.stories().single() + assertEquals(alice.user, theirs.authorId) + assertEquals(mine.id, theirs.id, "the two phones name the story differently") + assertEquals("the top was cut", theirs.caption) + assertContentEquals(picture, bob.session.attachmentBytes(theirs.ref!!, bob.cache)) + assertTrue(File(bob.cache, "att-${theirs.id}").exists()) + + alice.session.editStoryCaption(mine, "the whole picture", contacts = listOf(bob.user)) + bob.quietPoll() + val edited = bob.session.stories().single() + assertEquals("the whole picture", edited.caption, "the edit never arrived") + assertNotNull(edited.editedAt, "an edited story must say so") + + val change = alice.session.takeDownStory(alice.session.stories().single(), contacts = listOf(bob.user)) + assertEquals(Session.StoryChange(1, 0, asPosted = true), change) + assertTrue(alice.session.stories().isEmpty(), "the poster still has it") + bob.quietPoll() + assertTrue(bob.session.stories().isEmpty(), "the friend still has it") + assertFalse(File(bob.cache, "att-${theirs.id}").exists(), "the friend's phone kept the decrypted picture") + assertTrue(bob.session.history(Vault.threadForUser(alice.user)).isEmpty(), "something was drawn into the conversation") + } + + @Test + fun `a set and a text story come down, the set whole`() { + val alice = Phone(1, "alice") + val bob = Phone(2, "bob") + val items = (0..2).map { io.flamenet.client.Conversations.StoryItem(picture.copyOf(100 + it), "image/jpeg", 1200, 800) } + alice.session.postStorySet(items, "three of them", listOf(bob.user), lifetimeSeconds = 86_400.0) + alice.session.postTextStory("back on Thursday", listOf(bob.user), lifetimeSeconds = 86_400.0) + bob.session.receive() + assertEquals(4, bob.session.stories().size) + + val set = alice.session.stories().first { it.set != null } + alice.session.takeDownStory(set, contacts = listOf(bob.user)) + bob.quietPoll() + assertEquals(listOf("back on Thursday"), bob.session.stories().map { it.caption }, "the set did not come down whole") + + val words = alice.session.stories().single() + alice.session.editStoryCaption(words, "back on Friday", contacts = listOf(bob.user)) + bob.quietPoll() + assertEquals("back on Friday", bob.session.stories().single().caption) + + alice.session.takeDownStory(alice.session.stories().single(), contacts = listOf(bob.user)) + bob.quietPoll() + assertTrue(bob.session.stories().isEmpty()) + assertTrue(alice.session.stories().isEmpty()) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/StoryFrameTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/StoryFrameTest.kt new file mode 100644 index 0000000..e279601 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/StoryFrameTest.kt @@ -0,0 +1,118 @@ +package io.flamenet.messenger.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The feed card's shape for a story's photo (DESIGN item 27): the whole photo, + * never cut, in a card that takes the photo's own shape between two wide by + * one high and four wide by five high, at most 560dp tall, with bars past + * either. The same numbers as the iPhone. + */ +class StoryFrameTest { + private val phone = 351f + + private fun close( + expected: Float, + actual: Float, + what: String, + ) = assertEquals(expected, actual, 0.01f, what) + + @Test + fun `a tall photo is shown whole, at four by five, with bars beside it`() { + val f = StoryFrame.frame(phone, photoWidth = 1080, photoHeight = 1920) + close(phone * 1.25f, f.height, "the card") + close(f.height, f.photoHeight, "the photo is cut at the top or bottom") + close(f.height * 1080f / 1920f, f.photoWidth, "the photo's shape changed") + assertTrue(f.barsBeside) + assertFalse(f.barsAbove) + } + + @Test + fun `a wide photo is shown whole, at two by one, with bars above and below`() { + val f = StoryFrame.frame(phone, photoWidth = 3000, photoHeight = 1000) + close(phone * 0.5f, f.height, "the card") + close(phone, f.photoWidth, "the photo is cut at the sides") + close(phone / 3f, f.photoHeight, "the photo's shape changed") + assertTrue(f.barsAbove) + assertFalse(f.barsBeside) + } + + @Test + fun `a photo whose shape fits takes the card exactly, with no bars`() { + for ((w, h) in listOf(1000 to 1000, 1600 to 1200, 1200 to 1500, 2000 to 1000)) { + val f = StoryFrame.frame(phone, w, h) + close(phone * h / w, f.height, "${w}x$h") + close(phone, f.photoWidth, "${w}x$h") + close(f.height, f.photoHeight, "${w}x$h") + assertFalse(f.barsBeside || f.barsAbove, "${w}x$h has bars") + } + } + + @Test + fun `only the shape counts, never the pixel count`() { + val tiny = StoryFrame.frame(phone, photoWidth = 3, photoHeight = 4) + val huge = StoryFrame.frame(phone, photoWidth = 12_000, photoHeight = 16_000) + assertEquals(tiny, huge, "a tiny photo and a huge one of the same shape are laid out alike") + close(phone * 1.25f, tiny.height, "a 3x4 photo is taller than four by five") + } + + @Test + fun `a sliver is still all there, however thin`() { + val f = StoryFrame.frame(phone, photoWidth = 1, photoHeight = 1000) + close(phone * 1.25f, f.height, "the card") + close(f.height / 1000f, f.photoWidth, "the photo") + val strip = StoryFrame.frame(phone, photoWidth = 1000, photoHeight = 1) + close(phone * 0.5f, strip.height, "the card") + close(phone, strip.photoWidth, "the photo") + } + + @Test + fun `a photo of unknown size is drawn as a square`() { + val f = StoryFrame.frame(phone, photoWidth = 0, photoHeight = 0) + close(phone, f.height, "the card") + close(phone, f.photoWidth, "the photo") + } + + @Test + fun `no card is taller than 560dp, on a tablet or sideways`() { + val wide = 900f + val f = StoryFrame.frame(wide, photoWidth = 1000, photoHeight = 1000) + close(560f, f.height, "the card") + close(560f, f.photoWidth, "a square photo stays square") + assertTrue(f.barsBeside) + val tall = StoryFrame.frame(wide, photoWidth = 1080, photoHeight = 1920) + close(560f, tall.height, "the card") + close(560f * 1080f / 1920f, tall.photoWidth, "the photo") + } + + @Test + fun `the words after a take-down say what was reached and which audience it went to`() { + assertEquals("Taken down.", StoryWords.takenDown(Session.StoryChange(reached = 3, missed = 0, asPosted = true))) + assertEquals( + "Taken down here. 1 of 4 could not be reached right now, so their copy stays until it expires.", + StoryWords.takenDown(Session.StoryChange(reached = 3, missed = 1, asPosted = true)), + ) + assertTrue( + StoryWords + .takenDown(Session.StoryChange(reached = 2, missed = 0, asPosted = false)) + .endsWith("It went to your story audience as it is now, since this story was posted before the app kept who it went to."), + ) + assertEquals(null, StoryWords.edited(Session.StoryChange(reached = 2, missed = 0, asPosted = true))) + assertEquals( + "Changed here. 2 of 2 could not be reached right now, so they still see the old words.", + StoryWords.edited(Session.StoryChange(reached = 0, missed = 2, asPosted = true)), + ) + } + + @Test + fun `the take-down warning is DESIGN item 25's, word for word`() { + assertEquals( + "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.", + StoryWords.TAKE_DOWN_WARNING, + ) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/StoryScreensTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/StoryScreensTest.kt new file mode 100644 index 0000000..9ddc6bc --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/StoryScreensTest.kt @@ -0,0 +1,296 @@ +package io.flamenet.messenger.app + +import android.app.Application +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.unit.dp +import androidx.test.core.app.ApplicationProvider +import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayClient +import io.flamenet.e2e.Attachment +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Vault +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.shadows.ShadowDialog +import java.io.ByteArrayOutputStream +import java.io.File +import javax.crypto.KeyGenerator +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Stories on the screen (DESIGN items 25 to 27): a tall photo whole in its + * card, the composer showing it exactly as the feed will, the take-down + * asking first in DESIGN's words, an edited caption saying so, and the editor + * refusing to empty a story. Pictures go to build/screenshots/stories. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w375dp-h812dp-normal-long-notround-any-440dpi-keyshidden-nonav") +class StoryScreensTest { + @get:Rule val compose = createAndroidComposeRule<ComponentActivity>() + + private val out = File("build/screenshots/stories").also { it.mkdirs() } + private val app: Application get() = ApplicationProvider.getApplicationContext() + + private class MapStorage : Vault.Storage { + val records = LinkedHashMap<String, ByteArray>() + + override fun read(name: String) = records[name] + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + + override fun names() = records.keys.toList() + } + + /** A picture whose top and bottom are marked, so a crop would show. */ + private fun picture( + width: Int, + height: Int, + ): ByteArray { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + canvas.drawColor(Color.rgb(120, 170, 220)) + val band = Paint().apply { color = Color.rgb(200, 40, 60) } + canvas.drawRect(0f, 0f, width.toFloat(), height * 0.12f, band) + canvas.drawRect(0f, height * 0.88f, width.toFloat(), height.toFloat(), Paint().apply { color = Color.rgb(30, 120, 70) }) + val words = Paint().apply { color = Color.WHITE; textSize = height * 0.06f; isAntiAlias = true } + canvas.drawText("TOP", width * 0.05f, height * 0.09f, words) + canvas.drawText("BOTTOM", width * 0.05f, height * 0.97f, words) + return ByteArrayOutputStream().also { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }.toByteArray() + } + + private fun storyOf( + id: String, + author: Long, + width: Int, + height: Int, + caption: String, + editedAt: Long? = null, + ): Vault.Story { + // What `attachmentBytes` finds already decrypted on this phone. + File(app.cacheDir, "att-$id").writeBytes(picture(width, height)) + return Vault.Story( + id = id, + authorId = author, + mine = author == 1L, + ref = + Attachment.Ref( + kind = Attachment.STORY, + id = id, + key = ByteArray(32), + digest = ByteArray(32), + bytes = 1, + mime = "image/jpeg", + width = width, + height = height, + expires = System.currentTimeMillis() / 1000.0 + 20 * 3600, + ), + caption = caption, + postedAt = System.currentTimeMillis(), + seen = true, + editedAt = editedAt, + sentTo = if (author == 1L) listOf(2L) else null, + ) + } + + private fun model(): MessengerViewModel { + val identity = DeviceIdentity.create() + val vault = Vault(MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + vault.saveIdentity(identity) + val base = "http://127.0.0.1:9/" + val session = Session("me", 1, identity, vault, RelayClient(base, "me", "pw"), MessengerApi(base, "me", "pw")) + return MessengerViewModel(app).also { it.useSessionForTesting(session) } + } + + private fun phase( + stories: List<Vault.Story> = emptyList(), + draft: MessengerViewModel.StoryDraft? = null, + ) = MessengerViewModel.Phase.SignedIn( + handle = "me", + userId = 1, + deviceId = "d-1", + relay = "relay.example", + status = "", + buddies = listOf(MessengerApi.Buddy(userId = 2, handle = "sam", displayName = "Sam", presence = "online")), + stories = stories, + storyDraft = draft, + ) + + private var screen by mutableStateOf<(@Composable ColumnScope.() -> Unit)?>(null) + private var shown = false + + private fun show( + dark: Boolean = false, + content: @Composable ColumnScope.() -> Unit, + ) { + DisplaySettings(compose.activity).appearance = if (dark) DisplaySettings.Appearance.DARK else DisplaySettings.Appearance.LIGHT + screen = content + if (!shown) { + shown = true + compose.setContent { + key(screen) { + MessengerTheme { Surface(modifier = Modifier.fillMaxSize()) { Column { screen?.invoke(this) } } } + } + } + } + compose.waitForIdle() + } + + private fun waitForPicture() = + compose.waitUntil(5_000) { + compose.onAllNodes(hasTestTag("story-photo"), useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty() + } + + private fun draw( + view: View, + name: String, + ) { + if (view.width == 0 || view.height == 0) { + val spec = View.MeasureSpec.makeMeasureSpec(1031, View.MeasureSpec.AT_MOST) + view.measure(spec, View.MeasureSpec.makeMeasureSpec(2233, View.MeasureSpec.AT_MOST)) + view.layout(0, 0, view.measuredWidth, view.measuredHeight) + } + val bitmap = Bitmap.createBitmap(view.width.coerceAtLeast(1), view.height.coerceAtLeast(1), Bitmap.Config.ARGB_8888) + view.draw(Canvas(bitmap)) + val file = File(out, "$name.png") + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("wrote ${file.absolutePath}") + } + + private fun shoot(name: String) { + compose.waitForIdle() + compose.activityRule.scenario.onActivity { draw(it.window.decorView, name) } + } + + private fun shootDialog(name: String) { + compose.waitForIdle() + val dialog = ShadowDialog.getLatestDialog() + draw(dialog.window!!.decorView, name) + } + + private fun aspect(node: SemanticsNodeInteraction): Float { + val bounds = node.getUnclippedBoundsInRoot() + return (bounds.right - bounds.left).value / (bounds.bottom - bounds.top).value + } + + private val tallId = "a".repeat(64) + + @Test + fun `a tall photo is shown whole in the feed, light and dark`() { + val model = model() + val story = storyOf(tallId, author = 2, width = 540, height = 960, caption = "Sunset from the ridge, all of it") + for (dark in listOf(false, true)) { + show(dark) { StoriesCards(model, phase(listOf(story))) } + waitForPicture() + assertEquals(1 / 1.25f, aspect(compose.onNodeWithTag("story-photo-frame", useUnmergedTree = true)), 0.01f) + shoot(if (dark) "stories-tall-dark" else "stories-tall-light") + } + } + + @Test + fun `the composer shows the photo exactly as the feed will`() { + val model = model() + for ((w, h) in listOf(540 to 960, 1600 to 600, 1000 to 1000)) { + val id = "b".repeat(60) + "%04d".format(w) + val story = storyOf(id, author = 2, width = w, height = h, caption = "") + show { StoriesCards(model, phase(listOf(story))) } + waitForPicture() + val feed = aspect(compose.onNodeWithTag("story-photo-frame", useUnmergedTree = true)) + + // What the composer draws above its caption field, at a dialog's width. + val picked = ImagePrep.decodeBounded(File(app.cacheDir, "att-$id").readBytes())!!.asImageBitmap() + show { Box(Modifier.width(280.dp)) { StoryComposerPreview(picked, w, h, durationSeconds = 0.0) } } + compose.onNodeWithTag("story-preview", useUnmergedTree = true).assertExists() + val preview = aspect(compose.onNodeWithTag("story-photo-frame", useUnmergedTree = true)) + assertEquals(feed, preview, 0.01f, "the composer showed ${w}x$h in another shape than the feed") + } + } + + @Test + fun `taking my story down asks first, in DESIGN's words`() { + val model = model() + val mine = storyOf("c".repeat(64), author = 1, width = 540, height = 960, caption = "My story") + show { StoriesCards(model, phase(listOf(mine))) } + compose.onNodeWithTag("story-takedown").performClick() + val warning = compose.onNodeWithTag("story-takedown-warning", useUnmergedTree = true) + val said = warning.fetchSemanticsNode().config[SemanticsProperties.Text].joinToString { it.text } + assertEquals(StoryWords.TAKE_DOWN_WARNING, said) + compose.onNodeWithText("Take down this story?").assertExists() + shootDialog("stories-takedown-confirm") + compose.onNodeWithText("Cancel").performClick() + compose.onNodeWithTag("story-takedown-warning", useUnmergedTree = true).assertDoesNotExist() + } + + @Test + fun `an edited caption says so`() { + val model = model() + val story = + storyOf(tallId, author = 2, width = 540, height = 960, caption = "Sunset from the ridge, the whole picture", editedAt = 1L) + show { StoriesCards(model, phase(listOf(story))) } + waitForPicture() + val line = compose.onNodeWithTag("story-set-count-$tallId", useUnmergedTree = true) + val said = line.fetchSemanticsNode().config[SemanticsProperties.Text].joinToString { it.text } + assertTrue(said.endsWith(" · Edited"), "an edited story does not say so: $said") + shoot("stories-edited-caption") + } + + @Test + fun `the editor never empties a story, and offers taking it down instead`() { + // The editor itself, outside its dialog: a text field inside a dialog + // window never lets Robolectric settle, with or without these changes. + var draft by mutableStateOf("Words") + var offered = false + show { StoryCaptionEditor(draft = draft, onDraft = { draft = it }, onTakeDownInstead = { offered = true }) } + compose.onNodeWithTag("story-edit-empty", useUnmergedTree = true).assertDoesNotExist() + compose.onNodeWithTag("story-edit-field").performTextClearance() + assertEquals("", draft) + compose.onNodeWithTag("story-edit-empty", useUnmergedTree = true).assertExists() + compose.onNodeWithTag("story-edit-takedown").performClick() + assertTrue(offered, "Take down was not offered for an emptied story") + assertFalse(StoryWords.canSave(" ", "Words"), "an empty caption could be saved") + assertFalse(StoryWords.canSave("Words ", "Words"), "an unchanged caption could be sent as an edit") + assertTrue(StoryWords.canSave("New words", "Words")) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/StoryTakeDownTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/StoryTakeDownTest.kt new file mode 100644 index 0000000..98db775 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/StoryTakeDownTest.kt @@ -0,0 +1,439 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.Conversations +import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayClient +import io.flamenet.e2e.Attachment +import io.flamenet.e2e.Content +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.E2ESession +import io.flamenet.e2e.Envelope +import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.json.JSONArray +import org.json.JSONObject +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import java.nio.file.Files +import java.util.Base64 +import javax.crypto.KeyGenerator +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Taking a story down and changing its words, through a real Session + * (§6.35 and §6.15 for stories, DESIGN items 25, 26, 33 and 44). + * + * What goes out is opened again with the recipient's own identity, so the + * checks are on what a friend's phone reads; what comes in goes through the + * same `absorb` a poll runs. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class StoryTakeDownTest { + private class MapStorage : Vault.Storage { + val records = LinkedHashMap<String, ByteArray>() + + override fun read(name: String) = records[name] + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + + override fun names() = records.keys.toList() + } + + private val me = 1L + private val sam = 2L + private val pat = 3L + + /** Sam's and Pat's phones are one device for the relay double; the sends are opened there. */ + private val friends = DeviceIdentity.create() + + /** This account's other phone, when a test gives it one. */ + private val myTablet = DeviceIdentity.create() + + @Volatile private var iHaveATablet = false + + private lateinit var server: MockWebServer + private lateinit var vault: Vault + private lateinit var cacheDir: File + private val posts = java.util.Collections.synchronizedList(mutableListOf<JSONObject>()) + private var nextId = 1L + + @BeforeTest fun start() { + server = MockWebServer().also { it.dispatcher = relay() }.also { it.start() } + cacheDir = Files.createTempDirectory("story-cache").toFile() + } + + @AfterTest fun stop() { + server.shutdown() + cacheDir.deleteRecursively() + } + + private fun json(body: String) = MockResponse().setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + + private fun relay() = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.requestUrl?.encodedPath.orEmpty().substringAfter("/flamenet/v1") + return when { + path.startsWith("/e2e/log") -> { + MockResponse().setResponseCode(404) + } + + path == "/e2e/messages" && request.method == "POST" -> { + posts += JSONObject(request.body.readUtf8()) + json("""{"ok":true}""") + } + + path.startsWith("/e2e/devices/") || path.startsWith("/e2e/keys/") -> { + val devices = JSONArray() + when (path.substringAfterLast('/').toLongOrNull()) { + sam, pat -> devices.put(friends.registrationWire()) + me -> if (iHaveATablet) devices.put(myTablet.registrationWire()) + } + json(JSONObject().put("devices", devices).toString()) + } + + path.startsWith("/e2e/groups") -> { + val members = """[{"user_id":1},{"user_id":2},{"user_id":3}]""" + json("""{"groups":[{"gid":"g-1","name":"Saturday","owner":1,"members":$members}]}""") + } + + else -> { + json("{}") + } + } + } + } + + private fun session(): Session { + val identity = DeviceIdentity.create() + vault = Vault(MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + vault.saveIdentity(identity) + val base = server.url("/").toString() + return Session("me", me, identity, vault, RelayClient(base, "me", "pw"), MessengerApi(base, "me", "pw")) + .also { it.attachmentCacheDir = cacheDir } + } + + // -- What a friend's phone reads -----------------------------------------------. + + private class Posted( + val to: Long, + val silent: Boolean, + val content: Content, + ) + + private val opened = mutableMapOf<String, E2ESession>() + private var read = 0 + + /** Every send since the last call, opened by the device it went to. */ + private fun drain(): List<Posted> { + val out = mutableListOf<Posted>() + while (read < posts.size) { + val body = posts[read++] + val messages = body.getJSONArray("messages") + for (i in 0 until messages.length()) { + val m = messages.getJSONObject(i) + val device = if (m.getString("to_device") == myTablet.deviceId) myTablet else friends + val envelope = Envelope.fromPayload(m.getString("payload")) + val session = opened[device.deviceId] ?: E2ESession.respond(device, envelope).also { opened[device.deviceId] = it } + val content = Content.parse(session.decrypt(envelope)) + // The delivery key and key-log preludes ride along on a first send. + if (content.kind == Content.Kind.UNPOST || content.kind == Content.Kind.EDIT) { + out += Posted(body.getLong("to"), body.optBoolean("silent", false), content) + } + } + } + return out + } + + // -- Stories -----------------------------------------------------------------. + + private val hex = "0123456789abcdef" + + private fun blobId(seed: Int) = (0 until 64).map { hex[(it * 7 + seed) % 16] }.joinToString("") + + private fun photo( + id: String, + author: Long, + caption: String = "", + set: Content.StorySet? = null, + gid: String? = null, + sentTo: List<Long>? = null, + ) = Vault.Story( + id = id, + authorId = author, + mine = author == me, + ref = + Attachment.Ref( + kind = Attachment.STORY, + id = id, + key = ByteArray(32), + digest = ByteArray(32), + bytes = 100, + mime = "image/jpeg", + width = 1080, + height = 1920, + expires = System.currentTimeMillis() / 1000.0 + 3600, + ), + caption = caption, + postedAt = System.currentTimeMillis(), + set = set, + gid = gid, + sentTo = sentTo, + ) + + private fun hold(vararg stories: Vault.Story) = vault.saveStories(vault.loadStories() + stories, System.currentTimeMillis() / 1000.0) + + private fun Session.held(): List<Vault.Story> = vault.loadStories() + + private fun from( + user: Long, + wire: String, + proven: Boolean = true, + at: Long = System.currentTimeMillis(), + ) = Conversations.Received( + id = nextId++, + fromUser = user, + fromDevice = "device-$user", + content = Content.parse(wire), + senderProven = proven, + at = at, + ) + + /** A poll's worth, which must be neither news nor drawn anywhere. */ + private fun Session.poll(vararg arriving: Conversations.Received) { + assertTrue(absorbForTesting(arriving.toList()).isEmpty(), "a story change marked a chat unread") + assertNull(takeNotice(), "a story change had words for the lock screen") + assertTrue(history(Vault.threadForUser(sam)).isEmpty() && history(Vault.threadForUser(pat)).isEmpty(), "it was drawn") + } + + // -- Taking my own story down ---------------------------------------------------. + + @Test + fun `my take-down goes to exactly who the story went to, and my copy goes at once`() { + val s = session() + val id = blobId(1) + val story = photo(id, me, "the top is cut off", sentTo = listOf(sam)) + hold(story) + vault.saveStoryArchive(listOf(story)) + s.applyStoryReaction(id, sam, "+") + File(cacheDir, "att-$id").writeBytes(byteArrayOf(1, 2, 3)) + File(cacheDir, "story-${id}123.mp4").writeBytes(byteArrayOf(4)) + + val change = s.takeDownStory(story, contacts = listOf(sam, pat)) + assertEquals(Session.StoryChange(reached = 1, missed = 0, asPosted = true), change) + assertTrue(s.held().isEmpty(), "my copy is still on the strip") + assertTrue(vault.storyArchive().isEmpty(), "my copy is still in the archive") + assertTrue(s.reactionsFor(id).isEmpty()) + assertFalse(File(cacheDir, "att-$id").exists(), "the decrypted bytes stayed behind") + assertFalse(File(cacheDir, "story-${id}123.mp4").exists(), "the video copy stayed behind") + + val sent = drain().single() + assertEquals(sam, sent.to, "it went to somebody the story never went to") + assertEquals(Content.Unpost(storyId = id), sent.content.unpost) + assertTrue(sent.silent, "a take-down must never ring a phone") + } + + @Test + fun `a story posted before recipients were kept comes down to today's audience, and says so`() { + val s = session() + val story = photo(blobId(2), me) + hold(story) + s.saveStoryAudience(Session.StoryAudience(Session.AudienceMode.EXCEPT, setOf(pat))) + val change = s.takeDownStory(story, contacts = listOf(sam, pat)) + assertFalse(change.asPosted) + assertEquals(listOf(sam), drain().map { it.to }) + } + + @Test + fun `a group story comes down to the group's roster`() { + val s = session() + val story = photo(blobId(3), me, gid = "g-1") + hold(story) + s.takeDownStory(story, contacts = emptyList()) + assertEquals(listOf(sam, pat), drain().map { it.to }.sorted()) + } + + @Test + fun `a set comes down whole, by the set's id, and nothing else of mine goes`() { + val s = session() + val items = + (0..2).map { + photo(blobId(10 + it), me, if (it == 0) "three pictures" else "", Content.StorySet("set-1", it, 3), sentTo = listOf(sam)) + } + val other = photo(blobId(20), me, "another day", sentTo = listOf(sam)) + hold(*(items + other).toTypedArray()) + s.takeDownStory(items[1], contacts = listOf(sam)) + assertEquals(listOf(other.id), s.held().map { it.id }) + assertEquals(Content.Unpost(setId = "set-1"), drain().single().content.unpost) + } + + @Test + fun `this account's other phone is told too`() { + val s = session() + iHaveATablet = true + // Spec 17.3: a copy goes to the devices a person LINKED, under the key + // they were linked with, and to no other. A device merely signed in + // with the password is a device of the account and nothing more, so + // without this the tablet is registered and still gets nothing. + vault.saveLinkedDevices( + mapOf( + myTablet.deviceId to + Base64.getEncoder().encodeToString(myTablet.identitySigningPublic), + ), + ) + val story = photo(blobId(4), me, sentTo = listOf(sam)) + hold(story) + s.takeDownStory(story, contacts = listOf(sam)) + val sent = drain() + assertEquals(listOf(me, sam), sent.map { it.to }.sorted(), "my tablet keeps a story I took down") + } + + // -- Changing my own story's words ----------------------------------------------. + + @Test + fun `my edit changes my copy at once and goes out as the story edit`() { + val s = session() + val id = blobId(5) + hold(photo(id, me, "frist words", sentTo = listOf(sam))) + val change = s.editStoryCaption(s.held().single(), " first words ", contacts = listOf(sam)) + assertEquals(Session.StoryChange(1, 0, asPosted = true), change) + val mine = s.held().single() + assertEquals("first words", mine.caption) + assertNotNull(mine.editedAt) + val sent = drain().single() + assertTrue(sent.content.editsStory) + assertEquals(id, sent.content.editOf) + assertEquals("first words", sent.content.text) + assertTrue(sent.silent) + assertFailsWith<IllegalArgumentException> { s.editStoryCaption(mine, " ", contacts = listOf(sam)) } + } + + @Test + fun `a set's words are on its first picture, whichever one is edited`() { + val s = session() + val items = + (0..1).map { + photo(blobId(30 + it), me, if (it == 0) "two" else "", Content.StorySet("set-2", it, 2), sentTo = listOf(sam)) + } + hold(*items.toTypedArray()) + s.editStoryCaption(items[1], "two pictures", contacts = listOf(sam)) + assertEquals(items[0].id, drain().single().content.editOf) + assertEquals("two pictures", s.held().first { it.set?.i == 0 }.caption) + assertEquals("", s.held().first { it.set?.i == 1 }.caption) + } + + // -- What arrives ------------------------------------------------------------------. + + @Test + fun `a take-down counts only from the story's author, for a story held`() { + val s = session() + val sams = photo(blobId(40), sam, "sam's") + val pats = photo(blobId(41), pat, "pat's") + hold(sams, pats) + File(cacheDir, "att-${sams.id}").writeBytes(byteArrayOf(1)) + + s.poll(from(pat, Content.unpostStory(sams.id))) + s.poll(from(sam, Content.unpostStory("not-a-story-here"))) + s.poll(from(sam, Content.unpostStory(sams.id), proven = false)) + assertEquals(2, s.held().size, "a take-down from the wrong person, or of nothing, removed something") + + s.poll(from(sam, Content.unpostStory(sams.id))) + assertEquals(listOf(pats.id), s.held().map { it.id }) + assertFalse(File(cacheDir, "att-${sams.id}").exists(), "their bytes stayed on this phone") + } + + @Test + fun `a set comes down whole from its author, and only theirs`() { + val s = session() + val sams = (0..2).map { photo(blobId(50 + it), sam, set = Content.StorySet("shared-set", it, 3)) } + val pats = photo(blobId(60), pat, set = Content.StorySet("shared-set", 0, 1)) + hold(*(sams + pats).toTypedArray()) + s.poll(from(sam, Content.unpostStorySet("shared-set"))) + assertEquals(listOf(pats.id), s.held().map { it.id }, "the whole set should go, and nobody else's") + } + + @Test + fun `a take-down naming both a story and a set is dropped`() { + val s = session() + val sams = photo(blobId(70), sam, set = Content.StorySet("set-3", 0, 1)) + hold(sams) + s.poll(from(sam, """{"fnc":1,"kind":"unpost","ctl":1,"st":1,"sid":"${sams.id}","set":"set-3"}""")) + assertEquals(1, s.held().size) + } + + @Test + fun `my own other phone's take-down removes my copy here too`() { + val s = session() + val mine = photo(blobId(80), me) + hold(mine) + vault.saveStoryArchive(listOf(mine)) + s.poll(from(me, Content.unpostStory(mine.id))) + assertTrue(s.held().isEmpty()) + assertTrue(vault.storyArchive().isEmpty(), "the archive kept it") + } + + @Test + fun `an edit shows as one, counts only from the author, and the latest wins`() { + val s = session() + val sams = photo(blobId(90), sam, "frist") + hold(sams) + val t = 1_800_000_000_000L + s.poll(from(pat, Content.storyEdit(sams.id, "hijacked"), at = t)) + assertEquals("frist", s.held().single().caption) + assertNull(s.held().single().editedAt) + + s.poll(from(sam, Content.storyEdit(sams.id, "first, again"), at = t + 2)) + s.poll(from(sam, Content.storyEdit(sams.id, "first"), at = t + 1)) + val held = s.held().single() + assertEquals("first, again", held.caption, "an older edit replaced a newer one") + assertEquals(t + 2, held.editedAt) + + s.poll(from(sam, """{"fnc":1,"kind":"edit","st":1,"caption":" ","re":{"mid":"${sams.id}","author":0,"preview":""}}""", at = t + 3)) + assertEquals("first, again", s.held().single().caption, "an edit emptied a story") + } + + @Test + fun `a story edit never touches a message with the same id`() { + val s = session() + val id = "same-id" + s.record(Vault.threadForUser(sam), "a message", mine = false, author = sam, messageId = id) + s.absorbForTesting(listOf(from(sam, Content.storyEdit(id, "story words")))) + assertEquals("a message", s.history(Vault.threadForUser(sam)).single().text) + } + + // -- Backups ----------------------------------------------------------------------. + + @Test + fun `a story taken down leaves the backup`() { + val s = session() + val story = photo(blobId(100), me, sentTo = listOf(sam)) + hold(story) + vault.saveStoryArchive(listOf(story)) + s.takeDownStory(story, contacts = listOf(sam)) + val file = io.flamenet.e2e.BackupPayload.toShared(vault.exportPayload()).toString() + assertFalse(file.contains(story.id), "the backup still holds the story") + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/TheSavedTodayPageSaysSoTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/TheSavedTodayPageSaysSoTest.kt new file mode 100644 index 0000000..b32fa65 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/TheSavedTodayPageSaysSoTest.kt @@ -0,0 +1,74 @@ +package io.flamenet.messenger.app + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import io.flamenet.client.TodayFeed +import io.flamenet.client.TodayFile +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test + +/** + * The saved copy of the Today page, shown because the relay could not be + * reached or did not send a newer one, says "From <date>" at the top and + * one plain line under it, as on the iPhone (DESIGN 4). A page the relay + * is serving now keeps the plain date. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w375dp-h812dp-normal-long-notround-any-440dpi-keyshidden-nonav") +class TheSavedTodayPageSaysSoTest { + @get:Rule val compose = createAndroidComposeRule<ComponentActivity>() + + private val bundle = + TodayFile.Bundle( + date = "2026-10-04", + made = 1_791_080_513, + headlines = emptyList(), + markets = emptyList(), + weather = emptyList(), + site = null, + siteNote = null, + post = null, + notes = emptyList(), + ) + + private fun show(status: TodayFeed.Status) = + compose.setContent { + MessengerTheme { + Column { + TodayPage( + ui = TodayModel.Ui(snapshot = TodayFeed.Snapshot(bundle, status, 0L)), + relay = "relay.example", + fahrenheit = false, + onOpen = {}, + onChooseCities = {}, + onBrowse = {}, + ) + } + } + } + + @Test + fun `a saved copy with the relay out of reach says which day it is from, once`() { + show(TodayFeed.Status.UNREACHABLE) + compose.onNodeWithText(TodayText.from(bundle.date), useUnmergedTree = true).assertExists() + compose.onNodeWithText(TodayText.day(bundle.date), useUnmergedTree = true).assertDoesNotExist() + compose + .onNodeWithTag("today-status", useUnmergedTree = true) + .assertTextEquals("Couldn't reach your server just now.") + } + + @Test + fun `a page the relay serves now keeps the plain date and no line`() { + show(TodayFeed.Status.CURRENT) + compose.onNodeWithText(TodayText.day(bundle.date), useUnmergedTree = true).assertExists() + compose.onNodeWithText(TodayText.from(bundle.date), useUnmergedTree = true).assertDoesNotExist() + compose.onNodeWithTag("today-status", useUnmergedTree = true).assertDoesNotExist() + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/TodayChoicesTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/TodayChoicesTest.kt new file mode 100644 index 0000000..f9cb90a --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/TodayChoicesTest.kt @@ -0,0 +1,118 @@ +package io.flamenet.messenger.app + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsNotFocused +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import io.flamenet.client.MessengerApi +import io.flamenet.client.TodayFile +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The two choices the Today tab asks for: which cities to show, and who to + * send a site to. Both are made on the phone, and nothing goes anywhere until + * Send. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w375dp-h812dp-normal-long-notround-any-440dpi-keyshidden-nonav") +class TodayChoicesTest { + @get:Rule val compose = createAndroidComposeRule<ComponentActivity>() + + private fun city( + name: String, + cc: String, + ) = TodayFile.City(name, cc, "UTC", null, emptyList()) + + @Test + fun `cities are picked from the file's own list`() { + var chosen: Set<String>? = null + compose.setContent { + MessengerTheme { + Column { + CityPickerPage( + cities = listOf(city("Lagos", "NG"), city("Oslo", "NO"), city("Lima", "PE")), + chosen = setOf("Lima|PE"), + onDone = { chosen = it }, + onCancel = {}, + ) + } + } + } + // Nothing asks for the keyboard until the field is tapped. + compose.onNodeWithTag("city-filter").assertIsNotFocused() + compose.onNodeWithTag("city-Oslo|NO").performClick() + compose.onNodeWithTag("city-Lima|PE").performClick() + compose.onNodeWithTag("cities-done").performClick() + assertEquals(setOf("Oslo|NO"), chosen) + } + + @Test + fun `a site goes to the one person picked, and only on Send`() { + var sentTo: MessengerApi.Buddy? = null + val people = + listOf( + MessengerApi.Buddy(userId = 32, handle = "pat", displayName = "Pat", presence = "online"), + MessengerApi.Buddy(userId = 33, handle = "hoang", displayName = "Hoang Nguyen", presence = "away"), + ) + val site = Directory.Site("ArchDaily", "https://www.archdaily.com/", "Architecture news.", listOf("architecture")) + compose.setContent { + MessengerTheme { + SendSiteDialog(site = site, people = people, outcome = null, onSend = { sentTo = it }, onDismiss = {}) + } + } + compose.onNodeWithTag("send-site").assertIsNotEnabled() + compose.onNodeWithTag("send-to-33").performClick() + assertNull(sentTo, "choosing a person sent the site") + compose.onNodeWithTag("send-site").assertIsEnabled().performClick() + assertEquals(33L, sentTo?.userId) + } + + /** DESIGN 31: with supervision on, the bar has no Today tab to open. */ + @Test + fun `supervision takes the Today tab out of the bar`() { + val app = androidx.test.core.app.ApplicationProvider.getApplicationContext<android.app.Application>() + val model = MessengerViewModel(app) + val phase = + MessengerViewModel.Phase.SignedIn( + handle = "kid", + userId = 1, + deviceId = "74d869d7-4667-4d97-854b-80ee684aaacc", + relay = "relay.flamenet.io", + status = "", + ) + ParentalControls.enable(app, "2468") + try { + compose.setContent { MessengerTheme { BottomNav(model, phase) } } + compose.onNodeWithTag("nav-today").assertDoesNotExist() + compose.onNodeWithTag("nav-messages").assertExists() + } finally { + ParentalControls.disable(app) + } + } + + @Test + fun `with nobody to send to, the dialog says so`() { + compose.setContent { + MessengerTheme { + SendSiteDialog( + site = Directory.Site("ArchDaily", "https://www.archdaily.com/", null, emptyList()), + people = emptyList(), + outcome = null, + onSend = {}, + onDismiss = {}, + ) + } + } + compose.onNodeWithTag("send-nobody").assertExists() + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/TodayDataIsClearedTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/TodayDataIsClearedTest.kt new file mode 100644 index 0000000..b184098 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/TodayDataIsClearedTest.kt @@ -0,0 +1,121 @@ +package io.flamenet.messenger.app + +import android.app.Application +import android.content.Context +import android.os.Looper +import androidx.test.core.app.ApplicationProvider +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okio.Buffer +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Sign-out and a lost-device wipe take the Today tab's file and the weather + * picks with them (DESIGN 32), from the disk and from the screen's memory. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class TodayDataIsClearedTest { + private val app = ApplicationProvider.getApplicationContext<Application>() + + private fun prefs() = app.getSharedPreferences(TodayData.PREFS, Context.MODE_PRIVATE) + + @Test + fun `forgetting deletes the cached file and the city picks`() { + val dir = File(app.filesDir, TodayData.CACHE_DIR).also { it.mkdirs() } + File(dir, "envelope.json").writeText("{}") + File(dir, "meta.json").writeText("{}") + prefs().edit().putStringSet(TodayData.KEY_CITIES, setOf("Oslo|NO")).commit() + val before = TodayData.cleared.value + + TodayData.forget(app) + + assertFalse(dir.exists(), "the cached Today file is still on disk") + assertTrue(prefs().all.isEmpty(), "the weather picks are still saved") + assertEquals(before + 1, TodayData.cleared.value) + } + + @Test + fun `an open Today tab lets go of what it held`() { + prefs().edit().putStringSet(TodayData.KEY_CITIES, setOf("Oslo|NO", "Lima|PE")).commit() + val model = TodayModel(app) + assertEquals(setOf("Oslo|NO", "Lima|PE"), model.ui.value.cities) + + TodayData.forget(app) + shadowOf(android.os.Looper.getMainLooper()).idle() + + assertEquals(emptySet(), model.ui.value.cities, "the screen still shows the last person's cities") + } + + /** + * A fetch still on its way at sign-out or a wipe is dropped when it comes + * back: nothing of it is written or shown, as on the iPhone. + */ + @Test + fun `a fetch still on its way when the data is cleared is dropped when it returns`() { + val sample = File("../client/src/test/resources/today/today-sample.json").readBytes() + val server = MockWebServer() + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + // Signed out while the request was out. + TodayData.forget(app) + return MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(Buffer().write(sample)) + } + } + server.start() + try { + val model = TodayModel(app) + model.opened(server.url("/").toString()) + val deadline = System.currentTimeMillis() + 10_000 + while (server.requestCount == 0 && System.currentTimeMillis() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + Thread.sleep(10) + } + assertEquals(1, server.requestCount, "the tab never asked") + // Time for whatever the answer would do. + repeat(100) { + shadowOf(Looper.getMainLooper()).idle() + Thread.sleep(10) + } + assertFalse(File(app.filesDir, TodayData.CACHE_DIR).exists(), "the answer was written back after the clear") + assertNull(model.ui.value.snapshot, "the answer was shown after the clear") + } finally { + server.shutdown() + } + } + + /** Every way out of an account, and the wipe, reach it. Read off the sources. */ + @Test + fun `sign-out, the other ways out and a wipe all clear it`() { + val src = "src/main/kotlin/io/flamenet/messenger/app/" + val vm = File(src + "MessengerViewModel.kt").readText() + for (fn in listOf("fun signOut(", "private fun signOutRevoked(", "fun discardUnreadableVault(")) { + val body = vm.substringAfter(fn, "").substringBefore("\n }\n") + assertTrue(body.isNotEmpty(), "$fn is gone") + assertTrue(body.contains("TodayData.forget("), "$fn leaves the Today data behind") + } + val wirings = Regex("""session\?\.let \{[^}]*wireSealingPolicy\(it\)[^}]*\}""").findAll(vm).map { it.value }.toList() + assertEquals(2, wirings.size, "a session is wired somewhere new") + assertTrue(wirings.all { "forgetTodayOnWipe(it)" in it }, "a session built one way does not clear the Today data on a wipe") + val session = File(src + "Session.kt").readText() + val wipe = session.substringAfter("fun honourPendingWipe(): Boolean {").substringBefore("\n }\n") + assertTrue(wipe.indexOf("onWiped?.invoke()") > wipe.indexOf("vault.forgetEverything()"), "a wipe does not reach the Today data") + val service = File(src + "MessengerService.kt").readText() + assertTrue(service.contains("s.onWiped = { TodayData.forget(applicationContext) }"), "the background session ignores a wipe") + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/TodayIsSection812Test.kt b/app/src/test/kotlin/io/flamenet/messenger/app/TodayIsSection812Test.kt new file mode 100644 index 0000000..e33d802 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/TodayIsSection812Test.kt @@ -0,0 +1,30 @@ +package io.flamenet.messenger.app + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The Today file is SPEC section 8.12 (DESIGN 34); the section before it is + * push-token registration. + */ +class TodayIsSection812Test { + /** Built, so this file does not name the section it looks for. */ + private val wrong = "§8." + "11" + + @Test + fun `nothing about the Today file cites section 8_11`() { + val roots = listOf("src/main", "src/test", "../client/src/main", "../client/src/test").map(::File) + assertTrue(roots.all { it.isDirectory }, "a source root moved: $roots") + val citing = + roots + .flatMap { root -> root.walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() } + .filter { file -> file.readText().let { "Today" in it && wrong in it } } + .map { it.name } + .sorted() + assertEquals(emptyList(), citing) + val file = File("../client/src/main/kotlin/io/flamenet/client/TodayFile.kt").readText() + assertTrue("SPEC §8.12" in file, "the Today file no longer says which section it is") + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/TodayKeysTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/TodayKeysTest.kt new file mode 100644 index 0000000..f91afc0 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/TodayKeysTest.kt @@ -0,0 +1,54 @@ +package io.flamenet.messenger.app + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Which keys a Today file (SPEC §8.12) may be signed with, per build. + * + * The dev key signs every test file. A release build that trusted it would + * show anything signed with a key that sits in a scratch directory, so it is + * compiled into debug builds and no others, which is checked here from the + * sources because a unit test runs as one variant at a time. + */ +class TodayKeysTest { + private val dev = "o6fwSoRjw0zldgUOpSClF9/Z2ySQcf0bTDtmtoDgFoA=" + + @Test + fun `the production list is empty until the release step fills it`() { + assertTrue(TodayKeys.PRODUCTION.isEmpty()) + } + + @Test + fun `the dev key is in the debug source set and nowhere a release build reads`() { + val debug = File("src/debug/kotlin/io/flamenet/messenger/app/TodayDevKey.kt") + val release = File("src/release/kotlin/io/flamenet/messenger/app/TodayDevKey.kt") + assertTrue(debug.readText().contains(dev), "the debug build lost the dev key") + assertFalse(release.readText().contains(dev), "the release build carries the dev key") + assertTrue(release.readText().contains("emptyList()")) + val leaks = + File("src/main") + .walk() + .filter { it.isFile && it.extension in setOf("kt", "xml", "json") } + .filter { it.readText().contains(dev) } + .toList() + assertEquals(emptyList(), leaks, "the dev key is in code every build compiles") + } + + @Test + fun `each build type compiles its own copy`() { + val gradle = File("build.gradle.kts").readText() + assertTrue(gradle.contains("getByName(\"debug\") { java.srcDir(\"src/debug/kotlin\") }")) + assertTrue(gradle.contains("getByName(\"release\") { java.srcDir(\"src/release/kotlin\") }")) + } + + @Test + fun `a build trusts production plus its own dev keys and nothing else`() { + val expected = (TodayKeys.PRODUCTION + TodayDevKey.KEYS).size + assertEquals(expected, TodayKeys.trusted.byKid.size) + if (TodayDevKey.KEYS.isNotEmpty()) assertEquals(setOf("a9a94e74"), TodayKeys.trusted.byKid.keys) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/TodayScreenshotTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/TodayScreenshotTest.kt new file mode 100644 index 0000000..b1fea86 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/TodayScreenshotTest.kt @@ -0,0 +1,240 @@ +package io.flamenet.messenger.app + +import android.app.Application +import android.graphics.Bitmap +import android.graphics.Canvas +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import androidx.test.core.app.ApplicationProvider +import io.flamenet.client.TodayFeed +import io.flamenet.client.TodayFile +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File +import java.time.Instant +import kotlin.test.assertIs + +/** + * The Today tab, the directory and the hello link, drawn on the JVM from the + * real signed sample and the shipped directory, and written out as PNGs. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w375dp-h812dp-normal-long-notround-any-440dpi-keyshidden-nonav") +class TodayScreenshotTest { + @get:Rule val compose = createAndroidComposeRule<ComponentActivity>() + + private val out = File("build/screenshots").also { it.mkdirs() } + + /** The file flamenet.io's builder made, verified with the dev key exactly as the app would. */ + private val bundle: TodayFile.Bundle by lazy { + val sample = File("../client/src/test/resources/today/today-sample.json").readBytes() + val verdict = TodayFile.verify(sample, TodayFile.Keys(listOf("o6fwSoRjw0zldgUOpSClF9/Z2ySQcf0bTDtmtoDgFoA="))) + assertIs<TodayFile.Verdict.Good>(verdict, "the sample did not verify: $verdict").bundle + } + + private val directory: Directory by lazy { + Directory.parse(File("src/main/assets/${Directory.ASSET}").readText()) + } + + /** When the sample was made, so each city's forecast starts on its own today. */ + private val made get() = Instant.ofEpochSecond(bundle.made) + + /** + * Every section, with the first four headlines so the page fits one + * picture. [todayHeadlines] shows all of them. + */ + @Test + @Config(qualifiers = "w375dp-h2600dp-normal-long-notround-any-xhdpi-keyshidden-nonav") + fun todayWithData() = + shoot("today-with-data") { + TodayPage( + ui = + TodayModel.Ui( + snapshot = TodayFeed.Snapshot(bundle.copy(headlines = bundle.headlines.take(4)), TodayFeed.Status.CURRENT, bundle.made * 1000), + cities = setOf("New York|US", "London|GB", "Tokyo|JP"), + directory = directory, + ), + relay = "relay.flamenet.io", + fahrenheit = false, + now = made, + onOpen = {}, + onChooseCities = {}, + onBrowse = {}, + ) + } + + @Test + @Config(qualifiers = "w375dp-h2600dp-normal-long-notround-any-xhdpi-keyshidden-nonav") + fun todayWithDataDark() { + val settings = DisplaySettings(ApplicationProvider.getApplicationContext()) + settings.appearance = DisplaySettings.Appearance.DARK + try { + todayWithDataNamed("today-with-data-dark") + } finally { + settings.appearance = DisplaySettings.Appearance.SYSTEM + } + } + + private fun todayWithDataNamed(name: String) = + shoot(name) { + TodayPage( + ui = + TodayModel.Ui( + snapshot = TodayFeed.Snapshot(bundle.copy(headlines = bundle.headlines.take(4)), TodayFeed.Status.CURRENT, bundle.made * 1000), + cities = setOf("New York|US", "London|GB", "Tokyo|JP"), + directory = directory, + ), + relay = "relay.flamenet.io", + fahrenheit = true, + now = made, + onOpen = {}, + onChooseCities = {}, + onBrowse = {}, + ) + } + + @Test + @Config(qualifiers = "w375dp-h3000dp-normal-long-notround-any-xhdpi-keyshidden-nonav") + fun todayHeadlines() = + shoot("today-headlines") { + TodayPage( + ui = TodayModel.Ui(snapshot = TodayFeed.Snapshot(bundle, TodayFeed.Status.CURRENT, bundle.made * 1000), directory = directory), + relay = "relay.flamenet.io", + fahrenheit = false, + now = made, + onOpen = {}, + onChooseCities = {}, + onBrowse = {}, + ) + } + + /** No network, so the copy on disk with the day it is from, and no cities picked yet. */ + @Test + @Config(qualifiers = "w375dp-h2000dp-normal-long-notround-any-xhdpi-keyshidden-nonav") + fun todayOffline() = + shoot("today-offline") { + TodayPage( + ui = + TodayModel.Ui( + snapshot = TodayFeed.Snapshot(bundle.copy(headlines = bundle.headlines.take(3)), TodayFeed.Status.UNREACHABLE, bundle.made * 1000), + directory = directory, + ), + relay = "relay.flamenet.io", + fahrenheit = true, + now = made, + onOpen = {}, + onChooseCities = {}, + onBrowse = {}, + ) + }.also { compose.onNodeWithTag("today-status", useUnmergedTree = true).assertExists() } + + @Test + fun todayNotOffered() = + shoot("today-not-offered") { + TodayPage( + ui = + TodayModel.Ui( + snapshot = TodayFeed.Snapshot(null, TodayFeed.Status.NOT_OFFERED, 1L), + directory = directory, + ), + relay = "relay.example.org", + fahrenheit = false, + onOpen = {}, + onChooseCities = {}, + onBrowse = {}, + ) + }.also { compose.onNodeWithTag("today-directory", useUnmergedTree = true).assertExists() } + + @Test + @Config(qualifiers = "w375dp-h1400dp-normal-long-notround-any-xhdpi-keyshidden-nonav") + fun cityPicker() = + shoot("today-cities") { + CityPickerPage(bundle.weather, setOf("New York|US", "London|GB", "Tokyo|JP"), onDone = {}, onCancel = {}) + } + + @Test + fun directoryTop() = + shoot("directory-top") { + DirectoryPage(directory, category = null, query = "", onQuery = {}, onBrowse = {}, onOpen = {}, onSend = {}, onClose = {}) + } + + @Test + @Config(qualifiers = "w375dp-h1600dp-normal-long-notround-any-xhdpi-keyshidden-nonav") + fun directoryCategory() = + shoot("directory-category") { + DirectoryPage(directory, category = "architecture", query = "", onQuery = {}, onBrowse = {}, onOpen = {}, onSend = {}, onClose = {}) + } + + @Test + fun directorySearch() = + shoot("directory-search") { + DirectoryPage(directory, category = null, query = "bookshop", onQuery = {}, onBrowse = {}, onOpen = {}, onSend = {}, onClose = {}) + } + + @Test + @Config(qualifiers = "w375dp-h1000dp-normal-long-notround-any-440dpi-keyshidden-nonav") + fun helloLinkCard() = + shoot("hello-link-card", pad = true) { + AddressCard( + fnid = "fnid:k68p33sqcbnf1mc2jgzkbfqk7xzx3ykbz0fftg49tb751nrnt290", + relay = "relay.flamenet.io", + ) + }.also { compose.onNodeWithTag("hello-link", useUnmergedTree = true).assertExists() } + + /** Eight tabs on a 360dp phone, the narrowest this app is built for. */ + @Test + @Config(qualifiers = "w360dp-h200dp-normal-notlong-notround-any-xhdpi-keyshidden-nonav") + fun navBarNarrow() { + val model = MessengerViewModel(ApplicationProvider.getApplicationContext<Application>()) + val phase = + MessengerViewModel.Phase.SignedIn( + handle = "alice", + userId = 1, + deviceId = "74d869d7-4667-4d97-854b-80ee684aaacc", + relay = "relay.flamenet.io", + status = "", + screen = MessengerViewModel.Phase.Screen.TODAY, + unread = setOf("u:2"), + ) + shoot("nav-bar-360dp") { BottomNav(model, phase) } + compose.onNodeWithTag("nav-today", useUnmergedTree = true).assertExists() + } + + private fun shoot( + name: String, + pad: Boolean = false, + content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit, + ) { + compose.setContent { + MessengerTheme { + Surface(modifier = Modifier.fillMaxSize()) { + Column(if (pad) Modifier.padding(20.dp) else Modifier) { content() } + } + } + } + compose.waitForIdle() + var shot: Bitmap? = null + compose.activityRule.scenario.onActivity { activity -> + val view = activity.window.decorView + val bitmap = Bitmap.createBitmap(view.width.coerceAtLeast(1), view.height.coerceAtLeast(1), Bitmap.Config.ARGB_8888) + view.draw(Canvas(bitmap)) + shot = bitmap + } + val bitmap = shot ?: throw AssertionError("nothing was drawn for $name") + File(out, "$name.png").outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("wrote ${File(out, "$name.png").absolutePath} (${bitmap.width}x${bitmap.height})") + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/TodayTextTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/TodayTextTest.kt new file mode 100644 index 0000000..dac43ba --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/TodayTextTest.kt @@ -0,0 +1,114 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.TodayFeed +import io.flamenet.client.TodayFeed.Status +import io.flamenet.client.TodayFile +import java.time.Instant +import java.util.Locale +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The words on the Today tab (SPEC §8.12), from the state the feed is in. + */ +class TodayTextTest { + private val bundle = + TodayFile.Bundle( + date = "2026-10-04", + made = 1_791_080_513, + headlines = emptyList(), + markets = emptyList(), + weather = emptyList(), + site = null, + siteNote = null, + post = null, + notes = emptyList(), + ) + + private fun status( + s: Status, + held: Boolean, + ) = TodayText.status(TodayFeed.Snapshot(if (held) bundle else null, s, 0L), Locale.US) + + @Test + fun `a copy the relay is serving now needs no line`() { + assertNull(status(Status.CURRENT, held = true)) + assertNull(status(Status.NEVER, held = false)) + } + + @Test + fun `a relay that does not offer it says so in the agreed words`() { + assertEquals("Today's page is not offered by this relay.", status(Status.NOT_OFFERED, held = false)) + } + + @Test + fun `offline, the held copy says which day it is from`() { + // The day is in the header; the line under it says only why. + assertEquals("Couldn't reach your server just now.", status(Status.UNREACHABLE, held = true)) + assertEquals( + "Couldn't reach your server. Today's page will be here once it can be fetched.", + status(Status.UNREACHABLE, held = false), + ) + } + + @Test + fun `every other state is said plainly, with or without a copy`() { + for (s in listOf(Status.FAILED, Status.NOT_READY, Status.REFUSED)) { + assertFalse(status(s, held = true)!!.contains("From"), s.name) + assertFalse(status(s, held = false)!!.contains("From"), s.name) + } + assertTrue(status(Status.REFUSED, held = false)!!.contains("could not be checked")) + } + + @Test + fun `temperatures follow the reader's own setting`() { + assertTrue(TodayText.fahrenheit("fahrenhe")) + assertFalse(TodayText.fahrenheit("celsius")) + assertFalse(TodayText.fahrenheit("kelvin")) + assertFalse(TodayText.fahrenheit("")) + assertEquals("18°C", TodayText.degrees(18, fahrenheit = false)) + assertEquals("64°F", TodayText.degrees(18, fahrenheit = true)) + assertEquals("-40°F", TodayText.degrees(-40, fahrenheit = true)) + assertEquals("21°", TodayText.bare(21, fahrenheit = false)) + } + + @Test + fun `money is shown as written, with only a percent sign added`() { + assertEquals("+0.18%", TodayText.percent("+0.18")) + assertEquals("-1.2%", TodayText.percent("-1.2%")) + assertEquals("", TodayText.percent("")) + } + + @Test + fun `a city's days start at its own today`() { + val days = (3..7).map { TodayFile.Day("2026-10-0$it", 20, 10, TodayFile.Sky.CLEAR) } + val noonUtc = Instant.parse("2026-10-04T12:00:00Z") + // Already the 5th in Auckland, still the 4th in Los Angeles. + val auckland = TodayFile.City("Auckland", "NZ", "Pacific/Auckland", null, days) + val la = TodayFile.City("Los Angeles", "US", "America/Los_Angeles", null, days) + assertEquals("2026-10-05", TodayText.ahead(auckland, noonUtc).first().date) + assertEquals("2026-10-04", TodayText.ahead(la, noonUtc).first().date) + assertTrue(TodayText.ahead(la, Instant.parse("2026-11-01T00:00:00Z")).isEmpty(), "a stale copy shows no days at all") + } + + @Test + fun `a zone this phone does not know shows the days as the file gives them (DESIGN 42)`() { + val days = listOf(TodayFile.Day("2026-10-01", 1, 0, null), TodayFile.Day("2026-10-02", 2, 1, null)) + for (zone in listOf("Mars/Olympus_Mons", "", "not a zone")) { + val city = TodayFile.City("Somewhere", "", zone, null, days) + assertEquals(days, TodayText.ahead(city, Instant.parse("2026-11-01T00:00:00Z")), "zone \"$zone\"") + } + } + + @Test + fun `small words`() { + assertEquals("Sun", TodayText.weekday("2026-10-04", Locale.US)) + assertEquals("www.archdaily.com", TodayText.host("https://www.archdaily.com/x")) + assertEquals("Partly cloudy", TodayText.sky(TodayFile.Sky.PARTLY)) + assertNull(TodayText.sky(null)) + assertTrue(TodayText.sameForEveryone("relay.flamenet.io").startsWith("Everyone on relay.flamenet.io gets this same page.")) + } +} diff --git a/ci/android-ci.Dockerfile b/ci/android-ci.Dockerfile new file mode 100644 index 0000000..ff6dac8 --- /dev/null +++ b/ci/android-ci.Dockerfile @@ -0,0 +1,37 @@ +# Toolchain image for the Android unit suites on Gitea Actions (mac1/mac2, +# `ubuntu-latest`, Intel). Every version is pinned. When anything here +# changes, bump IMAGE_TAG in .gitea/workflows/ci.yml; the `image` job builds +# and pushes a tag that does not exist yet. +# +# JDK 21, as the GitLab runner used (openjdk@21). Node is here because +# actions/checkout is a JavaScript action and runs inside this container. +FROM eclipse-temurin:21.0.5_11-jdk-jammy + +ARG NODE_VERSION=22.11.0 +ARG CMDLINE_TOOLS=11076708 + +ENV ANDROID_HOME=/opt/android-sdk \ + ANDROID_SDK_ROOT=/opt/android-sdk + +# python3 for the result gate; fontconfig for Robolectric's NATIVE graphics. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates curl git unzip xz-utils python3 fontconfig \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \ + | tar -xJ -C /usr/local --strip-components=1 \ + && node --version + +RUN mkdir -p "$ANDROID_HOME/cmdline-tools" \ + && curl -fsSL -o /tmp/ct.zip \ + "https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip" \ + && unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools" \ + && mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" \ + && rm /tmp/ct.zip + +# compileSdk 35; AGP 8.7.3 defaults to build-tools 34.0.0. 35.0.0 matches the +# developer machines. +RUN yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null \ + && "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \ + "platform-tools" "platforms;android-35" "build-tools;34.0.0" "build-tools;35.0.0" diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 9cab6d7..0fbebd8 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -326,6 +326,39 @@ class Conversations( ).copy(attachment = ref) } + /** + * Seal and upload an attachment once, for a send the caller makes itself, + * so what reaches nobody can wait in the outbox as the reference. + */ + fun uploadAttachment( + data: ByteArray, + mime: String, + width: Int, + height: Int, + kind: String = Attachment.IMAGE, + filename: String = "", + classification: Int = 0, + compartments: List<Int> = emptyList(), + duration: Double = 0.0, + ): Attachment.Ref { + val sealed = Attachment.seal(data) + val id = relay.uploadAttachment(sealed.blob) + return Attachment.Ref( + kind = kind, + id = id, + key = sealed.key, + digest = sealed.digest, + bytes = sealed.blob.size, + mime = mime, + width = width, + height = height, + duration = duration, + filename = filename, + classification = classification, + compartments = compartments.sorted(), + ) + } + /** * Send an image to a group. */ @@ -1254,17 +1287,6 @@ class Conversations( text: String, ): Sent = sendPlaintext(toUserId, text) - /** - * Whether an envelope is worth interrupting somebody for. - */ - private fun warrantsAlert(body: String): Boolean { - if (!body.startsWith("""{"fnc":""")) return true - val content = Content.parse(body) - // Only the OFFER announces itself. - content.call?.let { return it.action == Content.CallSignal.OFFER } - return content.isRenderable - } - /** * §6.14, must ring a locked phone. A call offer, and nothing else. * @@ -3423,5 +3445,22 @@ class Conversations( val action = Content.parse(body).call?.action return action == Content.CallSignal.OFFER } + + /** + * Whether an envelope is worth interrupting somebody for; everything + * else goes with the relay's silent flag. Edits, reactions, pins and + * story reactions change what is already there and are never news, + * so they go silently, as all control traffic does (owner, 0.32). + */ + fun warrantsAlert(body: String): Boolean { + if (!body.startsWith("""{"fnc":""")) return true + val content = Content.parse(body) + // Only the OFFER announces itself. + content.call?.let { return it.action == Content.CallSignal.OFFER } + // §6.33. A game's moves and endings wake the other phone, whose app + // decides what to show; they are control traffic all the same. + if (content.kind == Content.Kind.GAME_MOVE || content.kind == Content.Kind.GAME_END) return true + return content.isRenderable + } } } diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index e3efaab..6f98d84 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -62,6 +62,9 @@ class RelayClient( */ val host: String get() = base.host + /** The relay's address with its scheme and port, for a public file read from the same place. */ + val baseUrl: String get() = base.toString().trimEnd('/') + /** * Spaces are stripped because the account service prints application * passwords in readable groups, and users paste what they were shown. diff --git a/client/src/main/kotlin/io/flamenet/client/TodayFeed.kt b/client/src/main/kotlin/io/flamenet/client/TodayFeed.kt new file mode 100644 index 0000000..abd02bb --- /dev/null +++ b/client/src/main/kotlin/io/flamenet/client/TodayFeed.kt @@ -0,0 +1,363 @@ +package io.flamenet.client + +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.json.JSONObject +import java.io.File +import java.io.IOException +import java.security.MessageDigest + +/** + * Getting the Today file from the home relay (SPEC §8.12), and keeping the + * last good copy on disk so the tab still reads offline. + * + * The request is the same for every person on the relay: no Authorization, no + * cookie, no device or account id, no query string. The one header that can + * vary, `If-None-Match`, is sent only when it is the hash of the copy held + * (DESIGN 19), which every reader of that file computes alike, so a relay + * cannot hand a phone a tag of its own and recognise it later. It goes through + * the app's ordinary client, so a bridge in use carries it like everything else. + * + * The copy on disk is public data and is re-verified every time it is read, + * so a file changed on disk, or a key this build no longer trusts, is never + * shown. + */ +class TodayFeed( + baseUrl: String, + private val dir: File, + private val keys: TodayFile.Keys, + http: OkHttpClient = RelayClient.defaultHttpClient(), + private val now: () -> Long = System::currentTimeMillis, + private val userAgent: String = "FlamenetMessenger/${RelayClient.CLIENT_VERSION} (Android)", + /** + * Moves on when this phone's copy is cleared, by sign-out or a wipe + * (DESIGN 32). A feed made before that writes nothing again, so an answer + * still on its way is dropped, never written back. + */ + private val epoch: () -> Int = { 0 }, +) { + /** What the last answer left behind. */ + enum class Status { + /** Nothing asked yet, and nothing held. */ + NEVER, + + /** The relay served this file, or said it had not changed. */ + CURRENT, + + /** The relay could not be reached. */ + UNREACHABLE, + + /** The relay answered with an error, or with a redirect, which is not followed. */ + FAILED, + + /** 503: the relay offers a Today file but has no good one yet. */ + NOT_READY, + + /** 404: this relay does not offer one. */ + NOT_OFFERED, + + /** The relay sent a file this build could not verify. */ + REFUSED, + } + + data class Snapshot( + /** The last good bundle, or null when there is none to show. */ + val bundle: TodayFile.Bundle?, + val status: Status, + /** When the relay last answered at all, epoch millis, 0 for never. */ + val answeredAt: Long, + ) + + private val base = baseUrl.trimEnd('/') + + /** The [epoch] this feed was made in. */ + private val born = epoch() + + /** Where the file lives on this relay. Nothing is ever added to it. */ + val url: String = urlFor(base) + + /** + * Redirects are not followed. A relay that answers with one would send the + * phone to some other host, and the only host this fetch may reach is the + * home relay. + */ + private val http: OkHttpClient = + http + .newBuilder() + .followRedirects(false) + .followSslRedirects(false) + .build() + + private val envelopeFile = File(dir, "envelope.json") + private val metaFile = File(dir, "meta.json") + + private var loaded = false + private var bundle: TodayFile.Bundle? = null + + /** The verified envelope behind [bundle], byte for byte. */ + private var envelope: ByteArray? = null + private var etag: String? = null + private var status = Status.NEVER + private var answeredAt = 0L + + /** When a connection last failed to reach the relay, 0 once it answers. */ + private var failedAt = 0L + + /** What is held, from disk on first use. Does no networking. */ + @Synchronized + fun current(): Snapshot { + load() + return snapshot() + } + + /** + * Whether [refresh] would ask the relay right now: an hour after its last + * answer of any kind, and five minutes after a connection that failed. + */ + @Synchronized + fun isDue(): Boolean { + load() + val t = now() + // A clock that moved backwards is a reason to ask, not to wait for it. + if (failedAt != 0L && t >= failedAt && t - failedAt < RETRY_MS) return false + return answeredAt == 0L || t < answeredAt || t - answeredAt >= INTERVAL_MS + } + + /** + * Ask the relay if [isDue]. A relay that is failing is not asked again + * every time the tab opens, and a connection that never reached it does + * not use up the hour. + * + * Blocking. Call it off the main thread. + */ + @Synchronized + fun refresh(): Snapshot { + if (cleared()) return forgotten() + if (!isDue()) return snapshot() + val response = + try { + http.newCall(request()).execute() + } catch (_: IOException) { + if (cleared()) return forgotten() + failedAt = now() + status = Status.UNREACHABLE + saveMeta() + return snapshot() + } + response.use { + // Cleared while the request was out: what it brought back + // belongs to nobody now. + if (cleared()) return forgotten() + answer(it) + } + return snapshot() + } + + /** + * The request, exactly as it is sent. Nothing about the person or the + * device is in it. + */ + @Synchronized + fun request(): Request { + load() + val builder = + Request + .Builder() + .url(url) + .get() + .header("Accept", "application/json") + .header("User-Agent", userAgent) + // Only the hash of the copy held, so the relay sees the same tag from + // every phone holding that file, and only with a copy to fall back on. + val held = envelope + val tag = etag + if (held != null && tag != null && tag == etagOf(held)) builder.header("If-None-Match", tag) + return builder.build() + } + + private fun answer(response: Response) { + answeredAt = now() + failedAt = 0L + when (response.code) { + 200 -> { + val bytes = readCapped(response) + if (bytes == null) { + status = Status.REFUSED + } else { + take(bytes, response.header("ETag")) + } + } + + 304 -> { + if (bundle != null) { + status = Status.CURRENT + } else { + // Nothing to keep showing. Ask plainly next time, and soon. + etag = null + answeredAt = 0L + status = Status.FAILED + } + } + + 404 -> { + // The relay says it does not offer one, so nothing is shown. + bundle = null + envelope = null + etag = null + envelopeFile.delete() + status = Status.NOT_OFFERED + } + + 503 -> { + status = Status.NOT_READY + } + + else -> { + status = Status.FAILED + } + } + saveMeta() + } + + private fun take( + bytes: ByteArray, + tag: String?, + ) { + when (val verdict = TodayFile.verify(bytes, keys)) { + is TodayFile.Verdict.Refused -> { + status = Status.REFUSED + } + + is TodayFile.Verdict.Good -> { + val held = bundle + // An older signed file is still a signed file, and a relay + // replaying one would put yesterday back on the screen. The + // newer copy stays. + if (held == null || verdict.bundle.made >= held.made) { + bundle = verdict.bundle + envelope = verdict.envelope + // A tag that is not the file's hash is never kept. + etag = tag?.takeIf { it == etagOf(verdict.envelope) } + // Not kept on disk, it is shown this once and fetched again. + if (!writeAtomically(envelopeFile, verdict.envelope)) etag = null + } + status = Status.CURRENT + } + } + } + + /** The body, or null when it is larger than any Today file may be. */ + private fun readCapped(response: Response): ByteArray? { + val body = response.body ?: return ByteArray(0) + val declared = body.contentLength() + if (declared > TodayFile.MAX_ENVELOPE_BYTES) return null + val source = body.source() + val buffer = okio.Buffer() + while (true) { + val read = source.read(buffer, 8192) + if (read == -1L) break + if (buffer.size > TodayFile.MAX_ENVELOPE_BYTES) return null + } + return buffer.readByteArray() + } + + private fun snapshot() = Snapshot(bundle, status, answeredAt) + + /** Whether this phone's copy was cleared since this feed was made. */ + private fun cleared() = epoch() != born + + /** What a feed holds once the phone was cleared under it: nothing. */ + private fun forgotten(): Snapshot { + bundle = null + envelope = null + etag = null + status = Status.NEVER + answeredAt = 0L + failedAt = 0L + return snapshot() + } + + private fun load() { + if (loaded) return + loaded = true + val meta = + runCatching { JSONObject(metaFile.readText()) }.getOrNull() + // A copy from another relay is that relay's, not this one's. + ?.takeIf { it.optString("base") == base } + ?: return + answeredAt = meta.optLong("answered", 0L) + failedAt = meta.optLong("failed", 0L) + status = runCatching { Status.valueOf(meta.optString("status")) }.getOrDefault(Status.NEVER) + val bytes = runCatching { envelopeFile.readBytes() }.getOrNull() + val verdict = bytes?.let { TodayFile.verify(it, keys) } + if (verdict is TodayFile.Verdict.Good) { + bundle = verdict.bundle + envelope = verdict.envelope + etag = meta.optString("etag").takeIf { it.isNotEmpty() } + } else if (status == Status.CURRENT) { + // What was on disk no longer checks out. Ask again now; waiting + // out the hour would show nothing. + status = Status.NEVER + answeredAt = 0L + } + } + + private fun saveMeta() { + val meta = + JSONObject() + .put("base", base) + .put("answered", answeredAt) + .put("failed", failedAt) + .put("status", status.name) + etag?.let { meta.put("etag", it) } + writeAtomically(metaFile, meta.toString().toByteArray(Charsets.UTF_8)) + } + + private fun writeAtomically( + file: File, + bytes: ByteArray, + ): Boolean = + runCatching { + check(!cleared()) { "cleared" } + dir.mkdirs() + val temp = File(dir, file.name + ".tmp") + temp.writeBytes(bytes) + if (!temp.renameTo(file)) { + temp.delete() + error("could not replace ${file.name}") + } + // Cleared while this was writing: what it wrote goes too. + if (cleared()) { + file.delete() + dir.delete() + error("cleared") + } + }.isSuccess + + companion object { + /** At most one request an hour while the app is in use. */ + const val INTERVAL_MS = 60 * 60 * 1000L + + /** And after a connection that failed, five minutes before the next try. */ + const val RETRY_MS = 5 * 60 * 1000L + + /** `/today.json` on the relay at [baseUrl]. */ + fun urlFor(baseUrl: String): String = + baseUrl + .trimEnd('/') + .toHttpUrl() + .newBuilder() + .addPathSegment("today.json") + .build() + .toString() + + /** The ETag a relay gives these bytes (DESIGN 19): their SHA-256 in lowercase hex, quoted. */ + fun etagOf(bytes: ByteArray): String = + MessageDigest + .getInstance("SHA-256") + .digest(bytes) + .joinToString("", prefix = "\"", postfix = "\"") { "%02x".format(it) } + } +} diff --git a/client/src/main/kotlin/io/flamenet/client/TodayFile.kt b/client/src/main/kotlin/io/flamenet/client/TodayFile.kt new file mode 100644 index 0000000..cb01a5a --- /dev/null +++ b/client/src/main/kotlin/io/flamenet/client/TodayFile.kt @@ -0,0 +1,514 @@ +package io.flamenet.client + +import io.flamenet.e2e.Ed25519 +import org.json.JSONArray +import org.json.JSONObject +import java.security.MessageDigest +import java.util.Base64 + +/** + * The Today file (SPEC §8.12): one public bundle, signed by FlameNet, that the + * home relay serves byte for byte to everyone who asks. + * + * Nothing in it is about the reader, and nothing is sent to get it, so the + * signature is the whole of the trust. A file this build cannot verify is + * refused, never shown, whatever the relay says about it. + */ +object TodayFile { + /** The whole envelope, as the relay serves it. */ + const val MAX_ENVELOPE_BYTES = 512 * 1024 + + const val MAX_HEADLINES = 20 + const val MAX_MARKETS = 12 + const val MAX_CITIES = 150 + const val MAX_DAYS = 5 + const val MAX_NOTES = 5 + + /** `t` */ + const val MAX_TITLE = 200 + + /** `d`, and the note that labels the site's line */ + const val MAX_DESC = 300 + + /** `src`, `by`, a market's `name`, a `city` */ + const val MAX_NAME = 80 + + /** A market's `sym`, `last`, `chg` and `pct` (DESIGN 42). */ + private const val MAX_FIGURE = 24 + + /** A city's `tz`, an IANA name; a longer one is ignored (DESIGN 42). */ + private const val MAX_ZONE = 64 + + /** A city's `cc`: two ASCII letters, or ignored (DESIGN 42). */ + private val COUNTRY = Regex("[A-Za-z]{2}") + + private const val MAX_NOTE = 200 + + /** What the sky was doing. Anything else in the file draws no sky at all. */ + enum class Sky( + val wire: String, + ) { + CLEAR("clear"), + PARTLY("partly"), + CLOUDY("cloudy"), + FOG("fog"), + DRIZZLE("drizzle"), + RAIN("rain"), + SNOW("snow"), + STORM("storm"), + ; + + companion object { + fun of(wire: String?): Sky? = entries.firstOrNull { it.wire == wire } + } + } + + data class Headline( + val title: String, + val url: String, + val source: String, + ) + + /** Money figures exactly as the file writes them. Nothing here does arithmetic. */ + data class Market( + /** Null when the file gives none; a market needs only a name and a price (DESIGN 38). */ + val symbol: String?, + val name: String, + val last: String, + val change: String, + val percent: String, + ) + + data class Now( + val celsius: Int, + val sky: Sky?, + ) + + data class Day( + /** `yyyy-MM-dd`, the city's own date. */ + val date: String, + val high: Int, + val low: Int, + val sky: Sky?, + ) + + data class City( + val name: String, + val country: String, + /** The city's IANA zone, so "today" can be the city's today. */ + val zone: String, + val now: Now?, + val days: List<Day>, + ) { + /** How a choice of this city is remembered on the phone. */ + val key: String get() = "$name|$country" + } + + /** The site of the day, or the latest FlameNet blog post. */ + data class Link( + val title: String, + val url: String, + val description: String?, + /** The author's public display name, blog posts only. */ + val by: String? = null, + ) + + data class Bundle( + /** `yyyy-MM-dd`, the day the file was made for. */ + val date: String, + /** Unix seconds. A file older than one already held is not taken. */ + val made: Long, + val headlines: List<Headline>, + val markets: List<Market>, + val weather: List<City>, + val site: Link?, + /** The file's own label for the site's line: who wrote it, and how. */ + val siteNote: String?, + val post: Link?, + /** Where the figures come from, shown at the foot of the page. */ + val notes: List<String>, + ) + + /** + * Why an envelope was refused, as both apps and the vectors name them + * (DESIGN 48). Every one of them leaves the last good bundle in place. + */ + enum class Refusal { + TOO_LARGE, + + /** Not JSON, or an envelope without `v`, `kid`, `body` or `sig`. */ + MALFORMED, + + /** A `v` there but not exactly the integer 1, envelope or body. */ + UNKNOWN_VERSION, + UNKNOWN_KEY, + BAD_SIGNATURE, + BAD_BODY, + } + + sealed interface Verdict { + data class Good( + val bundle: Bundle, + /** The envelope exactly as it arrived, which is what gets cached. */ + val envelope: ByteArray, + ) : Verdict { + override fun equals(other: Any?): Boolean = other is Good && bundle == other.bundle && envelope.contentEquals(other.envelope) + + override fun hashCode(): Int = bundle.hashCode() + } + + data class Refused( + val why: Refusal, + ) : Verdict + } + + /** + * The keys a build accepts, by kid. + * + * Built from base64 public keys, which is how the apps pin them. A string + * that is not a 32-byte key is left out, not trusted in some other shape. + */ + class Keys( + base64: Collection<String>, + ) { + val byKid: Map<String, ByteArray> = + base64 + .mapNotNull { runCatching { Base64.getDecoder().decode(it.trim()) }.getOrNull() } + .filter { it.size == Ed25519.KEY_LEN } + .associateBy { kid(it) } + + val isEmpty: Boolean get() = byKid.isEmpty() + } + + /** The first 8 hex characters of SHA-256 over the 32-byte public key. */ + fun kid(publicKey: ByteArray): String = + MessageDigest + .getInstance("SHA-256") + .digest(publicKey) + .take(4) + .joinToString("") { "%02x".format(it) } + + /** + * Check an envelope and read the bundle inside it. + * + * The signature is checked over the body bytes before a single field of + * the body is read, so nothing an unknown signer wrote ever reaches the + * parser. + */ + fun verify( + envelope: ByteArray, + keys: Keys, + ): Verdict { + if (envelope.size > MAX_ENVELOPE_BYTES) return Verdict.Refused(Refusal.TOO_LARGE) + val outerText = String(envelope, Charsets.UTF_8) + val outer = + runCatching { JSONObject(outerText) }.getOrNull() + ?: return Verdict.Refused(Refusal.MALFORMED) + val kid = (outer.opt("kid") as? String)?.takeIf { it.isNotEmpty() } + val sigText = (outer.opt("sig") as? String)?.takeIf { it.isNotEmpty() } + val bodyText = outer.opt("body") as? String + if (!outer.has("v") || kid == null || sigText == null || bodyText == null) return Verdict.Refused(Refusal.MALFORMED) + if (!isVersion1(outerText)) return Verdict.Refused(Refusal.UNKNOWN_VERSION) + val key = keys.byKid[kid] ?: return Verdict.Refused(Refusal.UNKNOWN_KEY) + val body = + runCatching { Base64.getDecoder().decode(bodyText) }.getOrNull() + ?: return Verdict.Refused(Refusal.MALFORMED) + val sig = + runCatching { Base64.getDecoder().decode(sigText) }.getOrNull() + ?: return Verdict.Refused(Refusal.BAD_SIGNATURE) + if (!Ed25519.verify(key, sig, body)) return Verdict.Refused(Refusal.BAD_SIGNATURE) + return readBody(body, envelope) + } + + /** + * The body of an envelope whose signature already checked out. Only the + * version, the day and the time it was made are required; a list or a + * section that is missing or unreadable is simply not shown, and a list + * longer than its bound is cut to it. + */ + private fun readBody( + bytes: ByteArray, + envelope: ByteArray, + ): Verdict { + val text = String(bytes, Charsets.UTF_8) + val o = + runCatching { JSONObject(text) }.getOrNull() + ?: return Verdict.Refused(Refusal.BAD_BODY) + if (!o.has("v")) return Verdict.Refused(Refusal.BAD_BODY) + if (!isVersion1(text)) return Verdict.Refused(Refusal.UNKNOWN_VERSION) + val date = (o.opt("date") as? String)?.takeIf(::isDay) ?: return Verdict.Refused(Refusal.BAD_BODY) + val made = wholeNumber(o.opt("made"))?.takeIf { it >= 0 } ?: return Verdict.Refused(Refusal.BAD_BODY) + return Verdict.Good( + Bundle( + date = date, + made = made, + headlines = objects(o, "headlines").mapNotNull(::headline).take(MAX_HEADLINES), + markets = objects(o, "markets").mapNotNull(::market).take(MAX_MARKETS), + weather = objects(o, "weather").mapNotNull(::city).take(MAX_CITIES), + site = o.optJSONObject("site")?.let { link(it, withAuthor = false) }, + siteNote = text(o, "note", MAX_DESC), + post = o.optJSONObject("post")?.let { link(it, withAuthor = true) }, + notes = + (o.optJSONArray("notes") ?: JSONArray()) + .let { a -> (0 until a.length()).mapNotNull { a.opt(it) as? String } } + .map { bounded(it.trim(), MAX_NOTE) } + .filter { it.isNotEmpty() } + .take(MAX_NOTES), + ), + envelope.copyOf(), + ) + } + + private fun headline(o: JSONObject): Headline? { + val title = text(o, "t", MAX_TITLE) ?: return null + val url = link(o) ?: return null + return Headline(title, url, text(o, "src", MAX_NAME).orEmpty()) + } + + private fun market(o: JSONObject): Market? { + val name = text(o, "name", MAX_NAME) ?: return null + val last = text(o, "last", MAX_FIGURE) ?: return null + return Market( + symbol = text(o, "sym", MAX_FIGURE), + name = name, + last = last, + change = text(o, "chg", MAX_FIGURE).orEmpty(), + percent = text(o, "pct", MAX_FIGURE).orEmpty(), + ) + } + + private fun city(o: JSONObject): City? { + val name = text(o, "city", MAX_NAME) ?: return null + val now = + o.optJSONObject("now")?.let { n -> + degrees(n.opt("c"))?.let { Now(it, Sky.of(n.opt("sky") as? String)) } + } + val days = + objects(o, "days") + .mapNotNull { d -> + val date = (d.opt("d") as? String)?.takeIf(::isDay) ?: return@mapNotNull null + val high = degrees(d.opt("hi")) ?: return@mapNotNull null + val low = degrees(d.opt("lo")) ?: return@mapNotNull null + Day(date, high, low, Sky.of(d.opt("sky") as? String)) + }.take(MAX_DAYS) + return City( + name = name, + country = (o.opt("cc") as? String)?.trim()?.takeIf(COUNTRY::matches).orEmpty(), + zone = (o.opt("tz") as? String)?.trim()?.takeIf { it.isNotEmpty() && it.length <= MAX_ZONE }.orEmpty(), + now = now, + days = days, + ) + } + + private fun link( + o: JSONObject, + withAuthor: Boolean, + ): Link? { + val title = text(o, "t", MAX_TITLE) ?: return null + val url = link(o) ?: return null + return Link( + title = title, + url = url, + description = text(o, "d", MAX_DESC), + by = if (withAuthor) text(o, "by", MAX_NAME) else null, + ) + } + + /** + * `u`, when it is an http or https address with a host. Anything else, a + * `javascript:` or an `intent:` link included, drops the entry it is on: + * a signature says who wrote a link, not that it is safe to open. + */ + private fun link(o: JSONObject): String? { + val u = (o.opt("u") as? String)?.trim() ?: return null + if (u.length > 2048) return null + val uri = runCatching { java.net.URI(u) }.getOrNull() ?: return null + val scheme = uri.scheme?.lowercase() ?: return null + if (scheme != "https" && scheme != "http") return null + if (uri.host.isNullOrBlank()) return null + return u + } + + /** A present, non-blank string, cut to [max] characters as a reader counts them. */ + private fun text( + o: JSONObject, + key: String, + max: Int, + ): String? = (o.opt(key) as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let { bounded(it, max) } + + private fun objects( + o: JSONObject, + key: String, + ): List<JSONObject> { + val a = o.optJSONArray(key) ?: return emptyList() + return (0 until a.length()).mapNotNull { a.opt(it) as? JSONObject } + } + + /** Whole degrees. A fraction rounds; a string or a boolean is not a temperature. */ + private fun degrees(v: Any?): Int? = + when (v) { + is Int -> v.takeIf { it in -200..200 } + is Long -> v.takeIf { it in -200..200 }?.toInt() + is Number -> v.toDouble().takeIf { it.isFinite() && it in -200.0..200.0 }?.let { Math.round(it).toInt() } + else -> null + } + + /** + * Whether the object [json] has a `v` written as exactly `1` (DESIGN 38). + * Judged on the token as written, not the parsed number, which is the + * same for `1.0`, `1e0` and, on Android's own parser, `01`. + */ + private fun isVersion1(json: String): Boolean = rawMember(json, "v") == "1" + + /** + * The text of the top-level member [key] of the JSON object [json], + * exactly as written and without the space around it. Null when the + * object has no such member, has it twice, or cannot be read. + */ + internal fun rawMember( + json: String, + key: String, + ): String? { + var i = 0 + val n = json.length + + fun space() { + while (i < n && json[i] in " \t\r\n") i++ + } + + // A string from its opening quote, decoded; null when it is broken. + fun string(): String? { + if (i >= n || json[i] != '"') return null + i++ + val out = StringBuilder() + while (i < n) { + val c = json[i++] + if (c == '"') return out.toString() + if (c != '\\') { + out.append(c) + continue + } + if (i >= n) return null + when (val e = json[i++]) { + '"', '\\', '/' -> out.append(e) + 'b' -> out.append('\b') + 'f' -> out.append('\u000c') + 'n' -> out.append('\n') + 'r' -> out.append('\r') + 't' -> out.append('\t') + 'u' -> { + if (i + 4 > n) return null + out.append(json.substring(i, i + 4).toIntOrNull(16)?.toChar() ?: return null) + i += 4 + } + else -> return null + } + } + return null + } + + // Past one value of any kind; false when there is none to pass. + fun value(): Boolean { + if (i >= n) return false + when (json[i]) { + '"' -> { + return string() != null + } + + '{', '[' -> { + var depth = 0 + while (i < n) { + when (json[i]) { + '"' -> { + if (string() == null) return false + continue + } + + '{', '[' -> { + depth++ + } + + '}', ']' -> { + depth-- + if (depth == 0) { + i++ + return true + } + } + } + i++ + } + return false + } + + else -> { + val start = i + while (i < n && json[i] !in ",}] \t\r\n") i++ + return i > start + } + } + } + + space() + if (i >= n || json[i] != '{') return null + i++ + space() + if (i < n && json[i] == '}') return null + var found: String? = null + while (true) { + space() + val name = string() ?: return null + space() + if (i >= n || json[i] != ':') return null + i++ + space() + val start = i + if (!value()) return null + if (name == key) { + if (found != null) return null + found = json.substring(start, i) + } + space() + if (i >= n) return null + when (json[i++]) { + ',' -> continue + '}' -> break + else -> return null + } + } + space() + return found.takeIf { i == n } + } + + private fun wholeNumber(v: Any?): Long? = + when (v) { + is Int -> v.toLong() + is Long -> v + is Number -> v.toDouble().takeIf { it.isFinite() && it == Math.floor(it) && kotlin.math.abs(it) < 9e15 }?.toLong() + else -> null + } + + private val DAY = Regex("""\d{4}-\d{2}-\d{2}""") + + /** A real calendar day written `yyyy-MM-dd`. */ + fun isDay(s: String): Boolean = DAY.matches(s) && runCatching { java.time.LocalDate.parse(s) }.isSuccess + + /** [s] cut to at most [max] characters as a reader counts them, never inside one. */ + fun bounded( + s: String, + max: Int, + ): String { + if (s.length <= max) return s + val breaks = java.text.BreakIterator.getCharacterInstance() + breaks.setText(s) + var end = 0 + var count = 0 + while (count < max) { + val next = breaks.next() + if (next == java.text.BreakIterator.DONE) return s + end = next + count++ + } + return s.substring(0, end) + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/LiveTodayTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveTodayTest.kt new file mode 100644 index 0000000..02d82d4 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/LiveTodayTest.kt @@ -0,0 +1,67 @@ +package io.flamenet.client + +import okhttp3.OkHttpClient +import okhttp3.Request +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.net.URI +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * The Today file (SPEC §8.12) from a real relay, not a MockWebServer: the + * bytes it serves verify, its ETag is the hash both sides compute (DESIGN 19), + * and a conditional request comes back 304. + * + * Only against a relay on this machine, started with `FN_TODAY_FILE` and + * `FN_TODAY_KEYS` naming the dev key. Set `FN_LIVE_TODAY_RELAY` to its base + * address, for example `http://127.0.0.1:18931`. + */ +class LiveTodayTest { + private val relay: String? = System.getenv("FN_LIVE_TODAY_RELAY") + + private fun needLocalRelay(): String { + LiveTarget.refuseProduction(relay) + assumeTrue(relay != null, "set FN_LIVE_TODAY_RELAY to a relay running on this machine") + val host = URI(relay!!).host + check(host == "127.0.0.1" || host == "localhost") { "refusing $host: this suite runs against a local relay only" } + return relay + } + + @Test + fun `a local relay's Today file verifies, and its tag is the file's hash`() { + val base = needLocalRelay() + var clock = System.currentTimeMillis() + val dir = Files.createTempDirectory("today-live").toFile() + try { + val keys = TodayFile.Keys(listOf(TodayFileTest.DEV_PUBLIC)) + val feed = TodayFeed(base, dir, keys, now = { clock }) + val first = feed.refresh() + assertEquals(TodayFeed.Status.CURRENT, first.status) + assertNotNull(first.bundle) + + // Kept only because it is the hash of the bytes held. + val tag = feed.request().header("If-None-Match") + assertNotNull(tag, "the relay's ETag is not the SHA-256 of the file it served") + + val http = OkHttpClient() + val again = http.newCall(Request.Builder().url(feed.url).header("If-None-Match", tag).build()).execute() + again.use { assertEquals(304, it.code) } + val plain = http.newCall(Request.Builder().url(feed.url).build()).execute() + plain.use { + assertEquals(200, it.code) + assertNull(it.header("Set-Cookie")) + assertEquals(tag, it.header("ETag")) + } + + clock += TodayFeed.INTERVAL_MS + val later = feed.refresh() + assertEquals(TodayFeed.Status.CURRENT, later.status) + assertEquals(first.bundle, later.bundle) + } finally { + dir.deleteRecursively() + } + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/SharedVectorsTodayTest.kt b/client/src/test/kotlin/io/flamenet/client/SharedVectorsTodayTest.kt new file mode 100644 index 0000000..276767c --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/SharedVectorsTodayTest.kt @@ -0,0 +1,264 @@ +package io.flamenet.client + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.fail + +/** + * The Today file against the shared vectors, `today-v1.json` from + * flamenet-e2e (§8.12): which envelopes this app takes and why it refuses the + * rest, what a taken one holds, the 512 KB edge, which of two files the feed + * shows, and when it sends the stored ETag back. + * + * The file is found through `FLAMENET_TODAY_VECTORS`, or in a flamenet-e2e + * checkout beside this one. + */ +class SharedVectorsTodayTest { + private val file: JSONObject by lazy { + val candidates = + listOfNotNull( + System.getenv("FLAMENET_TODAY_VECTORS"), + "../../flamenet-e2e/test/vectors/today-v1.json", + "../../../flamenet-e2e/test/vectors/today-v1.json", + ).map(::File) + val found = + candidates.firstOrNull { it.exists() } + ?: throw AssertionError("today-v1.json not found. Looked in: ${candidates.joinToString { it.absolutePath }}") + JSONObject(found.readText()) + } + + private val keys: TodayFile.Keys by lazy { + val key = file.getJSONObject("key") + TodayFile.Keys(listOf(key.getString("public"))).also { + assertEquals(setOf(key.getString("kid")), it.byKid.keys) + } + } + + private fun why(refusal: TodayFile.Refusal): String = + when (refusal) { + TodayFile.Refusal.TOO_LARGE -> "too-large" + TodayFile.Refusal.MALFORMED -> "malformed" + TodayFile.Refusal.UNKNOWN_VERSION -> "unknown-version" + TodayFile.Refusal.UNKNOWN_KEY -> "unknown-key" + TodayFile.Refusal.BAD_SIGNATURE -> "bad-signature" + TodayFile.Refusal.BAD_BODY -> "bad-body" + } + + /** What this app keeps at one of the file's paths. It has no `lat` or `lon` to keep. */ + private fun valueAt( + path: String, + b: TodayFile.Bundle, + ): Any? { + val p = path.split(".") + fun blank(s: String?) = s?.takeIf { it.isNotEmpty() } + val fields: Map<String, Any?> = + when (p.first()) { + "headlines" -> b.headlines.getOrNull(p[1].toInt())?.let { mapOf("t" to it.title, "u" to it.url, "src" to blank(it.source)) } + "markets" -> + b.markets.getOrNull(p[1].toInt())?.let { + mapOf("sym" to it.symbol, "name" to it.name, "last" to it.last, "chg" to blank(it.change), "pct" to blank(it.percent)) + } + "weather" -> b.weather.getOrNull(p[1].toInt())?.let { mapOf("city" to it.name, "cc" to blank(it.country), "tz" to blank(it.zone)) } + "site" -> b.site?.let { mapOf("t" to it.title, "u" to it.url, "d" to it.description) } + "post" -> b.post?.let { mapOf("t" to it.title, "u" to it.url, "d" to it.description, "by" to it.by) } + "notes" -> return b.notes.getOrNull(p[1].toInt()) + else -> null + } ?: return null + return fields[p.last()] + } + + /** Characters as a reader counts them, which is how the file states lengths. */ + private fun drawnLength(s: String): Int { + val breaks = java.text.BreakIterator.getCharacterInstance() + breaks.setText(s) + var n = 0 + while (breaks.next() != java.text.BreakIterator.DONE) n++ + return n + } + + private fun plain(value: Any?): Any? = + when (value) { + null, JSONObject.NULL -> null + // org.json reads a fraction as a BigDecimal: kept as a fraction, never cut to a whole number. + is java.math.BigDecimal -> if (value.stripTrailingZeros().scale() <= 0) value.toLong() else value.toDouble() + is Double -> if (value == Math.floor(value) && !value.isInfinite()) value.toLong() else value + is Number -> value.toLong() + else -> value + } + + /** Where this app's answer for one envelope differs from [want], in words. */ + private fun unlike( + envelope: ByteArray, + want: JSONObject, + ): List<String> { + val verdict = want.getString("verdict") + return when (val got = TodayFile.verify(envelope, keys)) { + is TodayFile.Verdict.Refused -> { + if (verdict != "refuse") { + listOf("refused (${why(got.why)}), the file takes it") + } else if (why(got.why) != want.optString("why")) { + listOf("refused as ${why(got.why)}, the file says ${want.optString("why")}") + } else { + emptyList() + } + } + + is TodayFile.Verdict.Good -> { + if (verdict != "accept") return listOf("taken, the file refuses it (${want.optString("why")})") + val b = got.bundle + val e = want.optJSONObject("expect") ?: JSONObject() + val wrong = mutableListOf<String>() + if (e.has("date") && e.getString("date") != b.date) wrong += "date ${b.date}" + if (e.has("made") && e.getLong("made") != b.made) wrong += "made ${b.made}" + val counts = mapOf("headlines" to b.headlines.size, "markets" to b.markets.size, "cities" to b.weather.size, "notes" to b.notes.size) + for ((key, n) in counts) if (e.has(key) && e.getInt(key) != n) wrong += "$key $n, not ${e.getInt(key)}" + e.optJSONArray("days")?.let { days -> + val expected = (0 until days.length()).map { days.getInt(it) } + val mine = expected.indices.map { b.weather.getOrNull(it)?.days?.size ?: -1 } + if (mine != expected) wrong += "days $mine, not $expected" + } + val present = mapOf("site" to (b.site != null), "post" to (b.post != null), "siteNote" to (b.siteNote != null)) + for ((key, has) in present) if (e.has(key) && e.getBoolean(key) != has) wrong += "$key $has" + e.optJSONObject("lengths")?.let { lengths -> + for (path in lengths.keys().asSequence().sorted()) { + val n = drawnLength(valueAt(path, b) as? String ?: "") + if (n != lengths.getInt(path)) wrong += "$path drawn $n long, not ${lengths.getInt(path)}" + } + } + e.optJSONObject("fields")?.let { fields -> + for (path in fields.keys().asSequence().sorted()) { + val mine = plain(valueAt(path, b)) + val theirs = plain(fields.get(path)) + if (mine != theirs) wrong += "$path is $mine, not $theirs" + } + } + wrong + } + } + } + + @Test + fun `the sample, every case, and the 512 KB edge, as the file says`() { + val wrong = mutableListOf<String>() + val sample = file.getJSONObject("sample") + val sampleBytes = sample.getString("envelope").toByteArray(Charsets.UTF_8) + unlike(sampleBytes, sample).forEach { wrong += "the sample: $it" } + assertEquals(sample.getString("etag"), TodayFeed.etagOf(sampleBytes)) + + val cases = file.getJSONArray("cases") + assertEquals(34, cases.length()) + for (i in 0 until cases.length()) { + val one = cases.getJSONObject(i) + unlike(one.getString("envelope").toByteArray(Charsets.UTF_8), one).forEach { wrong += "case '${one.getString("name")}': $it" } + } + + val built = file.getJSONArray("built") + val text = sample.getString("envelope") + for (i in 0 until built.length()) { + val one = built.getJSONObject(i) + val pad = one.getInt("padTo") - sampleBytes.size - ",\"pad\":\"\"".length + val bytes = (text.dropLast(1) + ",\"pad\":\"" + "x".repeat(pad) + "\"}").toByteArray(Charsets.UTF_8) + assertEquals(one.getInt("padTo"), bytes.size) + unlike(bytes, one).forEach { wrong += "built '${one.getString("name")}': $it" } + } + println("today: the sample, ${cases.length()} cases and ${built.length()} built files, ${wrong.size} differences") + if (wrong.isNotEmpty()) fail(wrong.joinToString("\n")) + } + + /** Every named file in the vectors, by name. */ + private fun byName(): Map<String, ByteArray> { + val out = mutableMapOf<String, ByteArray>() + val sample = file.getJSONObject("sample") + out[sample.getString("name")] = sample.getString("envelope").toByteArray(Charsets.UTF_8) + val cases = file.getJSONArray("cases") + for (i in 0 until cases.length()) { + val one = cases.getJSONObject(i) + out[one.getString("name")] = one.getString("envelope").toByteArray(Charsets.UTF_8) + } + return out + } + + /** A feed over a relay in a box on this machine, with a clock the test moves. */ + private fun <T> withFeed(block: (MockWebServer, TodayFeed, () -> Unit) -> T): T { + val server = MockWebServer().also { it.start() } + val dir = Files.createTempDirectory("today-vectors").toFile() + var clock = 1_800_000_000_000L + try { + val feed = TodayFeed(server.url("/").toString().trimEnd('/'), dir, keys, now = { clock }) + return block(server, feed) { clock += 2 * TodayFeed.INTERVAL_MS } + } finally { + server.shutdown() + dir.deleteRecursively() + } + } + + private fun MockWebServer.serve( + bytes: ByteArray, + etag: String, + ) = enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setHeader("ETag", etag) + .setBody(okio.Buffer().write(bytes)), + ) + + @Test + fun `an older file never replaces a newer one, through the feed`() { + val named = byName() + val made = { name: String -> (TodayFile.verify(named.getValue(name), keys) as TodayFile.Verdict.Good).bundle.made } + val steps = file.getJSONArray("sequence") + assertEquals(2, steps.length()) + val wrong = mutableListOf<String>() + for (i in 0 until steps.length()) { + val step = steps.getJSONObject(i) + val held = named.getValue(step.getString("held")) + val then = named.getValue(step.getString("then")) + val shown = step.getString("shown") + val got = + withFeed { server, feed, later -> + server.serve(held, TodayFeed.etagOf(held)) + feed.refresh() + later() + server.serve(then, TodayFeed.etagOf(then)) + feed.refresh() + assertEquals(2, server.requestCount) + feed.current().bundle?.made + } + if (got != made(shown)) wrong += "sequence '${step.getString("name")}': shows made $got, the file says '$shown' (made ${made(shown)})" + } + if (wrong.isNotEmpty()) fail(wrong.joinToString("\n")) + } + + @Test + fun `the stored tag goes back only when it is the hash of the copy held`() { + val named = byName() + val rules = file.getJSONArray("ifNoneMatch") + assertEquals(4, rules.length()) + val wrong = mutableListOf<String>() + for (i in 0 until rules.length()) { + val rule = rules.getJSONObject(i) + val stored = rule.getString("stored") + val send = rule.getBoolean("send") + val sent = + withFeed { server, feed, _ -> + // The relay hands this tag over with a file the phone keeps, + // or, with no copy to hold, with one it refuses. + val held = if (rule.isNull("held")) null else named.getValue(rule.getString("held")) + server.serve(held ?: "{}".toByteArray(), stored) + feed.refresh() + feed.request().header("If-None-Match") + } + if ((sent == stored) != send || (sent != null && sent != stored)) { + wrong += "ifNoneMatch '${rule.getString("name")}': sends ${sent ?: "nothing"}, the file says send=$send" + } + } + if (wrong.isNotEmpty()) fail(wrong.joinToString("\n")) + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/SilentKindsTest.kt b/client/src/test/kotlin/io/flamenet/client/SilentKindsTest.kt new file mode 100644 index 0000000..d5d05b5 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/SilentKindsTest.kt @@ -0,0 +1,97 @@ +package io.flamenet.client + +import io.flamenet.e2e.Attachment +import io.flamenet.e2e.Content +import io.flamenet.e2e.SealedSender +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Which envelopes wake the other phone, and which go with the relay's silent + * flag (§6.10). Edits, reactions, pins and story reactions change what is + * already there and are never news, so they go silently and never notify, on + * both apps (owner, 0.32). Every kind this app sends is named here, so a new + * one is decided, not inherited. + */ +class SilentKindsTest { + private val ref = + Attachment.Ref(kind = Attachment.IMAGE, id = "a", key = ByteArray(32), digest = ByteArray(32), bytes = 1, mime = "image/jpeg") + private val about = Content.Reference("m-1", 2, "") + private val game = Content.Game("g", "chess", "first", listOf("e2e4")) + private val thumbsUp = "👍" + + private fun call(action: String) = Content.callSignal(Content.CallSignal(action, "c-1")) + + /** What a person sees, and what has to wake a phone to be seen. */ + private val wakes = + linkedMapOf( + "text, no envelope" to "hello", + "text" to Content.text("hello", messageId = "m"), + "group text" to Content.groupText("hello", "g-1", messageId = "m"), + "photo" to Content.attachment(ref, messageId = "m"), + "voice note" to Content.attachment(ref.copy(kind = Attachment.AUDIO, duration = 2.0), messageId = "m"), + "file" to Content.attachment(ref.copy(kind = Attachment.FILE, filename = "notes.txt"), messageId = "m"), + "story" to Content.attachment(ref.copy(kind = Attachment.STORY, expires = 1.0)), + "text story" to Content.textStory("hello", 1.0, "m"), + "story reply" to Content.storyReply("nice", "s-1", 2, "", "m"), + "sticker" to Content.sticker("flamenet.retro", "cake"), + "card" to Content.card(Content.Card("birthday", "Hi"), "m"), + "event" to Content.event(Content.Event(id = "e", rev = 1, title = "Picnic", start = 1), "m"), + "game" to Content.game(game, "m"), + "game move" to Content.gameMove(game), + "game end" to Content.gameEnd(game.copy(ending = "resign")), + "call offer" to call(Content.CallSignal.OFFER), + ) + + /** Changes to what is there, and control traffic: never news. */ + private val silent = + linkedMapOf( + "edit" to Content.edit("new words", "m-1"), + "reaction" to Content.reaction(thumbsUp, about), + "reaction taken back" to Content.reaction("", about), + "story reaction" to Content.storyReaction(thumbsUp, "s-1", 2), + "pin" to Content.pin("m-1"), + "unpin" to Content.pin(""), + "story edit" to Content.storyEdit("s-1", "new words"), + "story take-down" to Content.unpostStory("s-1"), + "set take-down" to Content.unpostStorySet("set-1"), + "answer to an event" to Content.rsvp(Content.Rsvp("e", "yes", 1)), + "poll" to Content.poll("p", "Lunch?", listOf("Yes", "No"), "g-1", "1,2,3"), + "poll share" to Content.pollShare("p", intArrayOf(1, 2), "g-1"), + "poll sum" to Content.pollSum("p", intArrayOf(1, 2), "g-1"), + "call answer" to call(Content.CallSignal.ANSWER), + "call end" to call(Content.CallSignal.END), + "call reaction" to call(Content.CallSignal.REACTION), + "call handoff" to call(Content.CallSignal.HANDOFF), + "call handoff taken" to call(Content.CallSignal.HANDOFF_TAKEN), + "avatar" to Content.avatar(ref), + "disappearing timer" to Content.disappearing(3600), + "off the record" to Content.offTheRecord(true), + "off the record, echoed" to Content.offTheRecord(true, echo = true), + "screenshot notice" to Content.screenshot(1), + // `bridgeOffer` is nullable since main refused a blank or + // over-long line (§24.4). This line is neither. + "bridge offer" to Content.bridgeOffer("bridge line")!!, + "delivery key" to SealedSender.deliveryKeyEnvelope(ByteArray(32)), + "group delivery key" to SealedSender.deliveryKeyEnvelope(ByteArray(32), groupId = "g-1", groupName = "Saturday"), + ) + + @Test + fun `what a person sees wakes their phone`() { + for ((name, body) in wakes) assertTrue(Conversations.warrantsAlert(body), "$name went silently") + } + + @Test + fun `edits, reactions, pins and story reactions go silently, as all control traffic does`() { + for ((name, body) in silent) assertFalse(Conversations.warrantsAlert(body), "$name woke the phone") + } + + @Test + fun `only a call offer rings`() { + for ((name, body) in wakes + silent) { + assertEquals(name == "call offer", Conversations.ringsAPhone(body), name) + } + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/TodayFeedTest.kt b/client/src/test/kotlin/io/flamenet/client/TodayFeedTest.kt new file mode 100644 index 0000000..e747e25 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/TodayFeedTest.kt @@ -0,0 +1,397 @@ +package io.flamenet.client + +import io.flamenet.client.TodayFeed.Status +import io.flamenet.client.TodayFileTest.Companion.body +import io.flamenet.client.TodayFileTest.Companion.envelope +import io.flamenet.client.TodayFileTest.Companion.keysFor +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import java.io.File +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * When the Today file is asked for, what the request carries, and what is + * kept when the relay cannot or will not give a good one (SPEC §8.12, + * DESIGN 19). + */ +class TodayFeedTest { + private lateinit var server: MockWebServer + private lateinit var dir: File + private val signer = TodayFileTest.key() + private val keys = keysFor(signer) + private var clock = 1_800_000_000_000L + + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + dir = Files.createTempDirectory("today").toFile() + } + + @AfterTest fun stop() { + server.shutdown() + dir.deleteRecursively() + Obfs4Routing.use(null) + } + + private fun base() = server.url("/").toString().trimEnd('/') + + private fun feed(base: String = base()) = TodayFeed(base, dir, keys, now = { clock }) + + private fun file(made: Long = 1_791_080_513) = envelope(body(made = made), signer) + + /** A 200 with the file and, unless told otherwise, the ETag a relay gives it. */ + private fun serve( + bytes: ByteArray, + etag: String = TodayFeed.etagOf(bytes), + ) { + server.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setHeader("ETag", etag) + .setBody(okio.Buffer().write(bytes)), + ) + } + + private fun next(): RecordedRequest = assertNotNull(server.takeRequest(2, TimeUnit.SECONDS), "no request reached the relay") + + // -- What the request carries ---------------------------------------------- + + @Test + fun `the request names nobody`() { + val request = feed().request() + assertEquals(base() + "/today.json", request.url.toString()) + assertEquals("GET", request.method) + assertNull(request.url.query, "a query string on a public file") + assertNull(request.header("Authorization")) + assertNull(request.header("Cookie")) + assertNull(request.header("If-None-Match"), "nothing is held yet, so nothing to compare") + // Every header that is sent, named, so adding one means changing this test. + assertEquals(setOf("Accept", "User-Agent"), request.headers.names()) + } + + @Test + fun `what reaches the relay carries no credentials, cookies or ids`() { + serve(file()) + feed().refresh() + val got = next() + assertEquals("/today.json", got.path) + assertNull(got.requestUrl?.query) + assertNull(got.getHeader("Authorization")) + assertNull(got.getHeader("Cookie")) + for (name in got.headers.names()) { + assertTrue( + name in setOf("Accept", "User-Agent", "Host", "Connection", "Accept-Encoding"), + "an unexpected header went out: $name", + ) + } + } + + /** + * The fetch goes through the app's ordinary client, so a bridge in use + * carries it. A bridge that refuses the connection leaves the relay + * unasked; a request that went around the bridge would reach it. + */ + @Test + fun `a bridge in use carries the request like every other`() { + val closed = java.net.ServerSocket(0).use { it.localPort } + Obfs4Routing.use(io.flamenet.obfs4.Obfs4Bridge("127.0.0.1", closed, ByteArray(20) { 1 }, ByteArray(32) { 2 })) + serve(file()) + assertEquals(Status.UNREACHABLE, feed().refresh().status) + assertEquals(0, server.requestCount, "the Today request went around the bridge, straight to the relay") + // And with no bridge it goes straight there, so the count above means something. + Obfs4Routing.use(null) + clock += TodayFeed.RETRY_MS + assertEquals(Status.CURRENT, feed().refresh().status) + assertEquals(1, server.requestCount) + } + + @Test + fun `a cookie the relay sets is never sent back`() { + server.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Set-Cookie", "who=you; Path=/") + .setBody(okio.Buffer().write(file())), + ) + server.enqueue(MockResponse().setResponseCode(304)) + val f = feed() + f.refresh() + clock += TodayFeed.INTERVAL_MS + f.refresh() + next() + assertNull(next().getHeader("Cookie")) + } + + // -- How often -------------------------------------------------------------- + + @Test + fun `at most one request an hour, then a conditional one`() { + val first = file() + serve(first) + val f = feed() + assertEquals(Status.CURRENT, f.refresh().status) + f.refresh() + clock += TodayFeed.INTERVAL_MS - 1 + f.refresh() + assertEquals(1, server.requestCount, "asked again inside the hour") + + server.enqueue(MockResponse().setResponseCode(304)) + clock += 1 + val after = f.refresh() + assertEquals(2, server.requestCount) + next() + assertEquals(TodayFeed.etagOf(first), next().getHeader("If-None-Match")) + assertEquals(Status.CURRENT, after.status) + assertNotNull(after.bundle, "a 304 keeps what is held") + } + + /** + * A tag that is not the file's hash could be one the relay gave this + * phone alone, to recognise it next time. It is never sent back. + */ + @Test + fun `a tag that is not the hash of the file is never sent back`() { + serve(file(), etag = "\"device-7f3a\"") + val f = feed() + f.refresh() + server.enqueue(MockResponse().setResponseCode(304)) + clock += TodayFeed.INTERVAL_MS + f.refresh() + next() + assertNull(next().getHeader("If-None-Match")) + // Nor after a restart, from whatever is on disk. + assertNull(feed().request().header("If-None-Match")) + } + + /** Each half of that on its own: a foreign tag is not kept... */ + @Test + fun `a tag that is not the hash of the file is not kept`() { + serve(file(), etag = "\"device-7f3a\"") + feed().refresh() + assertFalse(File(dir, "meta.json").readText().contains("device-7f3a"), "the relay's own tag was stored") + } + + /** ...and one that reached the disk some other way is still not sent. */ + @Test + fun `a stored tag that is not the hash of the copy held is not sent`() { + serve(file()) + feed().refresh() + val meta = File(dir, "meta.json") + meta.writeText(org.json.JSONObject(meta.readText()).put("etag", "\"device-7f3a\"").toString()) + assertNull(feed().request().header("If-None-Match")) + } + + @Test + fun `the tag sent is the hash of the copy held, and nothing else`() { + val bytes = file() + assertEquals( + "\"" + java.security.MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + "\"", + TodayFeed.etagOf(bytes), + ) + serve(bytes) + feed().refresh() + assertEquals(TodayFeed.etagOf(bytes), feed().request().header("If-None-Match")) + } + + @Test + fun `the hour survives a restart`() { + serve(file()) + feed().refresh() + clock += 60_000 + val restarted = feed() + assertFalse(restarted.isDue()) + restarted.refresh() + assertEquals(1, server.requestCount) + assertNotNull(restarted.current().bundle) + } + + @Test + fun `after a failed connection the next try waits five minutes, not an hour`() { + val f = feed() + server.shutdown() + assertEquals(Status.UNREACHABLE, f.refresh().status) + assertFalse(f.isDue(), "tried again at once") + clock += TodayFeed.RETRY_MS - 1 + assertFalse(f.isDue()) + assertFalse(feed().isDue(), "the wait did not survive a restart") + clock += 1 + assertTrue(f.isDue(), "a failed connection is not an answer, so the hour does not apply") + } + + // -- What is kept ----------------------------------------------------------- + + @Test + fun `offline, the last good copy is still there after a restart`() { + serve(file()) + feed().refresh() + server.shutdown() + clock += TodayFeed.INTERVAL_MS + val restarted = feed() + assertNotNull(restarted.current().bundle, "nothing read back from disk") + val offline = restarted.refresh() + assertEquals(Status.UNREACHABLE, offline.status) + assertEquals("2026-10-04", offline.bundle?.date) + } + + @Test + fun `a 404 means the relay does not offer it, and nothing is shown`() { + serve(file()) + val f = feed() + f.refresh() + server.enqueue(MockResponse().setResponseCode(404).setBody("""{"error":"not_offered"}""")) + clock += TodayFeed.INTERVAL_MS + val gone = f.refresh() + assertEquals(Status.NOT_OFFERED, gone.status) + assertNull(gone.bundle) + val restarted = feed().current() + assertEquals(Status.NOT_OFFERED, restarted.status) + assertNull(restarted.bundle, "the copy from before the 404 came back from disk") + } + + @Test + fun `a 503 keeps the last good copy`() { + serve(file()) + val f = feed() + f.refresh() + server.enqueue(MockResponse().setResponseCode(503)) + clock += TodayFeed.INTERVAL_MS + val later = f.refresh() + assertEquals(Status.NOT_READY, later.status) + assertNotNull(later.bundle) + } + + @Test + fun `a file that does not verify is dropped and the last good one kept`() { + val good = file(made = 100) + serve(good) + val f = feed() + f.refresh() + serve(envelope(body(made = 200), signer = TodayFileTest.key(), kidOf = signer)) + clock += TodayFeed.INTERVAL_MS + val later = f.refresh() + assertEquals(Status.REFUSED, later.status) + assertEquals(100L, later.bundle?.made) + // And the tag of the refused file is not adopted. + server.enqueue(MockResponse().setResponseCode(304)) + clock += TodayFeed.INTERVAL_MS + f.refresh() + next() + next() + assertEquals(TodayFeed.etagOf(good), next().getHeader("If-None-Match")) + } + + @Test + fun `nothing unverified is ever shown, even with nothing to fall back on`() { + serve(envelope(body(), signer = TodayFileTest.key())) + val first = feed().refresh() + assertEquals(Status.REFUSED, first.status) + assertNull(first.bundle) + } + + @Test + fun `an older signed file does not replace a newer one`() { + serve(file(made = 2_000)) + val f = feed() + f.refresh() + serve(file(made = 1_000)) + clock += TodayFeed.INTERVAL_MS + assertEquals(2_000L, f.refresh().bundle?.made) + assertEquals(2_000L, feed().current().bundle?.made) + } + + @Test + fun `a body larger than any Today file is refused without being kept`() { + serve(file()) + val f = feed() + f.refresh() + serve(ByteArray(TodayFile.MAX_ENVELOPE_BYTES + 10) { 'x'.code.toByte() }) + clock += TodayFeed.INTERVAL_MS + val later = f.refresh() + assertEquals(Status.REFUSED, later.status) + assertNotNull(later.bundle) + } + + @Test + fun `a redirect is not followed to another host`() { + val elsewhere = MockWebServer().also { it.start() } + try { + server.enqueue( + MockResponse() + .setResponseCode(302) + .setHeader("Location", elsewhere.url("/today.json").toString()), + ) + assertEquals(Status.FAILED, feed().refresh().status) + assertEquals(0, elsewhere.requestCount, "the phone followed the relay somewhere else") + } finally { + elsewhere.shutdown() + } + } + + @Test + fun `a copy changed on disk is not shown`() { + serve(file()) + feed().refresh() + val stored = File(dir, "envelope.json") + val o = org.json.JSONObject(stored.readText()) + val sig = java.util.Base64.getDecoder().decode(o.getString("sig")) + sig[0] = (sig[0].toInt() xor 0x40).toByte() + stored.writeText(o.put("sig", java.util.Base64.getEncoder().encodeToString(sig)).toString()) + val restarted = feed().current() + assertNull(restarted.bundle) + assertTrue(feed().isDue(), "a copy that no longer checks out should be fetched again, not waited out") + } + + @Test + fun `another relay's copy is not this relay's`() { + serve(file()) + feed().refresh() + val other = feed(base = "https://relay.example.org").current() + assertNull(other.bundle) + assertEquals(Status.NEVER, other.status) + } + + // -- Cleared under it (DESIGN 32) ---------------------------------------------- + + @Test + fun `a feed made before the phone was cleared asks nothing and writes nothing`() { + val epoch = java.util.concurrent.atomic.AtomicInteger(0) + val f = TodayFeed(base(), dir, keys, now = { clock }, epoch = { epoch.get() }) + epoch.incrementAndGet() + serve(file()) + assertNull(f.refresh().bundle) + assertEquals(0, server.requestCount, "a feed from before the clear asked the relay") + assertTrue(dir.listFiles().isNullOrEmpty(), "a feed from before the clear wrote: ${dir.list()?.toList()}") + } + + @Test + fun `an answer that comes back after the clear is dropped, never written`() { + val epoch = java.util.concurrent.atomic.AtomicInteger(0) + val bytes = file() + server.dispatcher = + object : okhttp3.mockwebserver.Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + // Signed out while the request was out. + epoch.incrementAndGet() + return MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setHeader("ETag", TodayFeed.etagOf(bytes)) + .setBody(okio.Buffer().write(bytes)) + } + } + val got = TodayFeed(base(), dir, keys, now = { clock }, epoch = { epoch.get() }).refresh() + assertEquals(1, server.requestCount) + assertNull(got.bundle, "the answer was taken after the clear") + assertTrue(dir.listFiles().isNullOrEmpty(), "the answer was written after the clear: ${dir.list()?.toList()}") + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/TodayFileTest.kt b/client/src/test/kotlin/io/flamenet/client/TodayFileTest.kt new file mode 100644 index 0000000..0169753 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/TodayFileTest.kt @@ -0,0 +1,431 @@ +package io.flamenet.client + +import io.flamenet.e2e.Ed25519 +import org.json.JSONArray +import org.json.JSONObject +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Checking a Today envelope (SPEC §8.12) before a word of it is shown. + */ +class TodayFileTest { + companion object { + /** The dev key every 0.32 test signs with. Public, and dev only. */ + const val DEV_PUBLIC = "o6fwSoRjw0zldgUOpSClF9/Z2ySQcf0bTDtmtoDgFoA=" + + private val b64 = Base64.getEncoder() + + /** A fresh key, so a test can sign whatever it needs to. */ + fun key(): Ed25519.KeyPair = Ed25519.generate() + + fun keysFor(vararg pairs: Ed25519.KeyPair) = TodayFile.Keys(pairs.map { b64.encodeToString(it.publicKey) }) + + /** An envelope over [body], signed by [signer] and naming [kidOf]'s key. */ + fun envelope( + body: String, + signer: Ed25519.KeyPair, + kidOf: Ed25519.KeyPair = signer, + v: Int = 1, + ): ByteArray { + val bytes = body.toByteArray(Charsets.UTF_8) + return JSONObject() + .put("v", v) + .put("kid", TodayFile.kid(kidOf.publicKey)) + .put("body", b64.encodeToString(bytes)) + .put("sig", b64.encodeToString(Ed25519.sign(signer, bytes))) + .toString() + .toByteArray(Charsets.UTF_8) + } + + fun body( + made: Long = 1_791_080_513, + date: String = "2026-10-04", + extra: JSONObject.() -> Unit = {}, + ): String = + JSONObject() + .put("v", 1) + .put("date", date) + .put("made", made) + .put( + "headlines", + JSONArray().put( + JSONObject().put("t", "Title").put("u", "https://example.com/a").put("src", "Source name"), + ), + ).put( + "markets", + JSONArray().put( + JSONObject() + .put("sym", "^GSPC") + .put("name", "S&P 500") + .put("last", "6712.40") + .put("chg", "+12.10") + .put("pct", "+0.18"), + ), + ).put( + "weather", + JSONArray().put( + JSONObject() + .put("city", "New York") + .put("cc", "US") + .put("tz", "America/New_York") + .put("lat", 40.71) + .put("lon", -74.01) + .put("now", JSONObject().put("c", 18).put("sky", "rain")) + .put( + "days", + JSONArray().put(JSONObject().put("d", "2026-10-04").put("hi", 21).put("lo", 14).put("sky", "rain")), + ), + ), + ).put("site", JSONObject().put("t", "Name").put("u", "https://example.org/").put("d", "One line")) + .put( + "post", + JSONObject() + .put("t", "Title") + .put("u", "https://flamenet.io/blog/x") + .put("d", "First line") + .put("by", "Name"), + ).put("notes", JSONArray().put("Weather: Open-Meteo").put("Market prices are delayed")) + .apply(extra) + .toString() + + fun sample(): ByteArray = + requireNotNull(TodayFileTest::class.java.getResourceAsStream("/today/today-sample.json")) { + "the signed sample is not on the test classpath" + }.readBytes() + } + + private fun good(verdict: TodayFile.Verdict): TodayFile.Bundle = assertIs<TodayFile.Verdict.Good>(verdict, "refused: $verdict").bundle + + private fun refused(verdict: TodayFile.Verdict): TodayFile.Refusal = assertIs<TodayFile.Verdict.Refused>(verdict, "accepted: $verdict").why + + @Test + fun `the kid is the first eight hex of the key's SHA-256`() { + assertEquals("a9a94e74", TodayFile.kid(Base64.getDecoder().decode(DEV_PUBLIC))) + } + + /** + * The file flamenet.io's builder made, checked by this verifier + * and compared with a plain reading of the same body. + */ + @Test + fun `the real signed sample verifies with the dev key`() { + val bundle = good(TodayFile.verify(sample(), TodayFile.Keys(listOf(DEV_PUBLIC)))) + val raw = JSONObject(String(Base64.getDecoder().decode(JSONObject(String(sample())).getString("body")))) + assertEquals(raw.getString("date"), bundle.date) + assertEquals(raw.getLong("made"), bundle.made) + assertEquals(raw.getJSONArray("headlines").length().coerceAtMost(TodayFile.MAX_HEADLINES), bundle.headlines.size) + assertEquals(raw.optJSONArray("markets")?.length() ?: 0, bundle.markets.size) + assertEquals(raw.getJSONArray("weather").length(), bundle.weather.size) + assertTrue(bundle.weather.all { it.days.size <= TodayFile.MAX_DAYS }) + assertEquals(raw.getJSONObject("site").getString("t"), bundle.site?.title) + assertEquals(raw.optString("note").ifEmpty { null }, bundle.siteNote) + // The blog section stays absent until FlameNet's blog has a post (DESIGN 20). + assertNull(bundle.post) + assertTrue(bundle.headlines.all { it.url.startsWith("https://") || it.url.startsWith("http://") }) + } + + @Test + fun `the real sample is refused by a build that does not pin the dev key`() { + assertEquals(TodayFile.Refusal.UNKNOWN_KEY, refused(TodayFile.verify(sample(), TodayFile.Keys(emptyList())))) + } + + @Test + fun `a good envelope is read`() { + val k = key() + val bundle = good(TodayFile.verify(envelope(body(), k), keysFor(k))) + assertEquals(1_791_080_513, bundle.made) + assertEquals(TodayFile.Headline("Title", "https://example.com/a", "Source name"), bundle.headlines.single()) + assertEquals(TodayFile.Market("^GSPC", "S&P 500", "6712.40", "+12.10", "+0.18"), bundle.markets.single()) + val city = bundle.weather.single() + assertEquals("New York|US", city.key) + assertEquals("America/New_York", city.zone) + assertEquals(TodayFile.Now(18, TodayFile.Sky.RAIN), city.now) + assertEquals(TodayFile.Day("2026-10-04", 21, 14, TodayFile.Sky.RAIN), city.days.single()) + assertEquals(TodayFile.Link("Name", "https://example.org/", "One line"), bundle.site) + assertEquals(TodayFile.Link("Title", "https://flamenet.io/blog/x", "First line", by = "Name"), bundle.post) + assertEquals(listOf("Weather: Open-Meteo", "Market prices are delayed"), bundle.notes) + } + + @Test + fun `a signature by another key under a trusted kid is refused`() { + val trusted = key() + val forger = key() + val forged = envelope(body(), signer = forger, kidOf = trusted) + assertEquals(TodayFile.Refusal.BAD_SIGNATURE, refused(TodayFile.verify(forged, keysFor(trusted)))) + } + + @Test + fun `an unknown kid is refused`() { + assertEquals(TodayFile.Refusal.UNKNOWN_KEY, refused(TodayFile.verify(envelope(body(), key()), keysFor(key())))) + } + + @Test + fun `a body changed after signing is refused`() { + val k = key() + val o = JSONObject(String(envelope(body(), k))) + val bytes = Base64.getDecoder().decode(o.getString("body")) + // One headline word changed, everything else kept. + val changed = String(bytes).replace("\"Title\"", "\"Tytle\"").toByteArray() + o.put("body", b64.encodeToString(changed)) + assertEquals(TodayFile.Refusal.BAD_SIGNATURE, refused(TodayFile.verify(o.toString().toByteArray(), keysFor(k)))) + } + + @Test + fun `a changed signature is refused`() { + val k = key() + val o = JSONObject(String(envelope(body(), k))) + val sig = Base64.getDecoder().decode(o.getString("sig")) + sig[10] = (sig[10].toInt() xor 1).toByte() + o.put("sig", b64.encodeToString(sig)) + assertEquals(TodayFile.Refusal.BAD_SIGNATURE, refused(TodayFile.verify(o.toString().toByteArray(), keysFor(k)))) + o.put("sig", b64.encodeToString(sig.copyOf(63))) + assertEquals(TodayFile.Refusal.BAD_SIGNATURE, refused(TodayFile.verify(o.toString().toByteArray(), keysFor(k)))) + } + + @Test + fun `an envelope over 512 KB is refused even when it is signed`() { + val k = key() + val padding = "x".repeat(TodayFile.MAX_ENVELOPE_BYTES) + val big = envelope(body { put("pad", padding) }, k) + assertTrue(big.size > TodayFile.MAX_ENVELOPE_BYTES) + assertEquals(TodayFile.Refusal.TOO_LARGE, refused(TodayFile.verify(big, keysFor(k)))) + } + + @Test + fun `malformed envelopes are refused`() { + val keys = keysFor(key()) + for (text in listOf("", "{", "[1,2]", "null", "not json", "{\"v\":1,\"kid\":\"a9a94e74\",\"sig\":\"AA==\"}")) { + val why = refused(TodayFile.verify(text.toByteArray(), keys)) + assertEquals(TodayFile.Refusal.MALFORMED, why, text) + } + val k = key() + val o = JSONObject(String(envelope(body(), k))).put("body", "@@not base64@@") + assertEquals(TodayFile.Refusal.MALFORMED, refused(TodayFile.verify(o.toString().toByteArray(), keysFor(k)))) + } + + /** DESIGN 48: an envelope without `v`, `kid`, `body` or `sig` is malformed, whatever else it holds. */ + @Test + fun `an envelope missing a part is malformed`() { + val k = key() + for (part in listOf("v", "kid", "body", "sig")) { + val o = JSONObject(String(envelope(body(), k))) + o.remove(part) + assertEquals(TodayFile.Refusal.MALFORMED, refused(TodayFile.verify(o.toString().toByteArray(), keysFor(k))), "without $part") + } + for (part in listOf("kid", "sig")) { + val o = JSONObject(String(envelope(body(), k))).put(part, "") + assertEquals(TodayFile.Refusal.MALFORMED, refused(TodayFile.verify(o.toString().toByteArray(), keysFor(k))), "an empty $part") + } + // A body without a version is a bad body; one with another version is still unknown. + val noV = body { remove("v") } + assertEquals(TodayFile.Refusal.BAD_BODY, refused(TodayFile.verify(envelope(noV, k), keysFor(k)))) + } + + @Test + fun `another version of the envelope or the body is refused`() { + val k = key() + assertEquals(TodayFile.Refusal.UNKNOWN_VERSION, refused(TodayFile.verify(envelope(body(), k, v = 2), keysFor(k)))) + val v2 = body { put("v", 2) } + assertEquals(TodayFile.Refusal.UNKNOWN_VERSION, refused(TodayFile.verify(envelope(v2, k), keysFor(k)))) + } + + @Test + fun `the version is the JSON integer 1 as written, not a number equal to it (DESIGN 38)`() { + val k = key() + val one = Regex(""""v":1(?=[,}])""") + for (v in listOf("1.0", "1.00", "1e0", "1E0", "10e-1", "01", "1.4")) { + val plain = String(envelope(body(), k)) + val outer = one.replaceFirst(plain, "\"v\":$v") + assertTrue(outer != plain, "the envelope's v was not rewritten") + assertEquals(TodayFile.Refusal.UNKNOWN_VERSION, refused(TodayFile.verify(outer.toByteArray(), keysFor(k))), "envelope v $v") + val inner = one.replaceFirst(body(), "\"v\":$v") + assertTrue(inner != body(), "the body's v was not rewritten") + assertEquals(TodayFile.Refusal.UNKNOWN_VERSION, refused(TodayFile.verify(envelope(inner, k), keysFor(k))), "body v $v") + } + // Space around it is not part of it. + val spaced = one.replaceFirst(String(envelope(body(), k)), "\"v\" : 1 ") + good(TodayFile.verify(spaced.toByteArray(), keysFor(k))) + } + + @Test + fun `a market needs a name and a last price, and its symbol is optional (DESIGN 38)`() { + val k = key() + val b = + body { + put( + "markets", + JSONArray() + .put(JSONObject().put("name", "Gold").put("last", "2401.10").put("chg", "-3.20")) + .put(JSONObject().put("sym", "X").put("last", "1")) + .put(JSONObject().put("sym", "Y").put("name", "No price")) + .put(JSONObject().put("sym", "Z").put("name", "Empty price").put("last", "")) + .put(JSONObject().put("name", "Blank price").put("last", " ")), + ) + } + val markets = good(TodayFile.verify(envelope(b, k), keysFor(k))).markets + assertEquals(listOf("Gold"), markets.map { it.name }, "a market without a symbol was dropped, or one without a name or price kept") + assertNull(markets.single().symbol, "a symbol was made up") + assertEquals("2401.10", markets.single().last) + assertEquals("-3.20", markets.single().change) + } + + @Test + fun `figures, notes, country codes and zones keep to their bounds (DESIGN 42)`() { + val k = key() + val long = "9".repeat(30) + val b = + body { + put( + "markets", + JSONArray().put( + JSONObject().put("sym", "S".repeat(30)).put("name", "Index").put("last", long).put("chg", "+$long").put("pct", "-$long"), + ), + ) + put("notes", JSONArray().put("x".repeat(250)).apply { repeat(6) { put("Note $it") } }) + put( + "weather", + JSONArray() + .put( + JSONObject() + .put("city", "Far") + .put("cc", "USA") + .put("tz", "Z".repeat(65)) + .put("lat", 200) + .put("lon", -400) + .put("days", JSONArray().put(JSONObject().put("d", "2026-10-04").put("hi", 1).put("lo", 0))), + ).put(JSONObject().put("city", "Near").put("cc", "u1").put("tz", "America/New_York")) + .put(JSONObject().put("city", "Kept").put("cc", "nz").put("tz", "Pacific/Auckland")), + ) + } + val bundle = good(TodayFile.verify(envelope(b, k), keysFor(k))) + val m = bundle.markets.single() + assertEquals(listOf(24, 24, 24, 24), listOf(m.symbol!!.length, m.last.length, m.change.length, m.percent.length)) + assertEquals(5, bundle.notes.size, "more than five notes") + assertEquals(200, bundle.notes.first().length) + val (far, near, kept) = bundle.weather + assertEquals("", far.country, "three letters were taken as a country") + assertEquals("", far.zone, "a zone over 64 characters was kept") + assertEquals(1, far.days.size, "a city with its place out of range lost its days") + assertEquals("", near.country, "a letter and a digit were taken as a country") + assertEquals("America/New_York", near.zone) + assertEquals("nz", kept.country) + assertEquals("Pacific/Auckland", kept.zone) + } + + @Test + fun `a body without a real date or a made time is refused`() { + val k = key() + for (b in listOf(body { remove("date") }, body(date = "2026-13-45"), body(date = "04/10/2026"), body { remove("made") })) { + assertEquals(TodayFile.Refusal.BAD_BODY, refused(TodayFile.verify(envelope(b, k), keysFor(k))), b) + } + assertEquals(TodayFile.Refusal.BAD_BODY, refused(TodayFile.verify(envelope("[]", k), keysFor(k)))) + } + + @Test + fun `lists over their bounds are cut to them`() { + val k = key() + val b = + body { + put("headlines", JSONArray((1..25).map { JSONObject().put("t", "H$it").put("u", "https://e.com/$it") })) + put("markets", JSONArray((1..15).map { JSONObject().put("sym", "S$it").put("name", "N").put("last", "1") })) + put( + "weather", + JSONArray( + (1..160).map { i -> + JSONObject().put("city", "C$i").put( + "days", + JSONArray((1..7).map { JSONObject().put("d", "2026-10-0$it").put("hi", 1).put("lo", 0) }), + ) + }, + ), + ) + put("notes", JSONArray((1..30).map { "note $it" })) + } + val bundle = good(TodayFile.verify(envelope(b, k), keysFor(k))) + assertEquals(TodayFile.MAX_HEADLINES, bundle.headlines.size) + assertEquals(TodayFile.MAX_MARKETS, bundle.markets.size) + assertEquals(TodayFile.MAX_CITIES, bundle.weather.size) + assertTrue(bundle.weather.all { it.days.size == TodayFile.MAX_DAYS }) + assertEquals(TodayFile.MAX_NOTES, bundle.notes.size) + assertEquals("H1", bundle.headlines.first().title, "the first ones are kept, in order") + } + + @Test + fun `strings over their bounds are cut, never inside a character`() { + val k = key() + // 199 letters and then a flag, which is two code points and four chars. + val title = "a".repeat(199) + "\uD83C\uDDEC\uD83C\uDDE7" + "tail" + val b = + body { + put("headlines", JSONArray().put(JSONObject().put("t", title).put("u", "https://e.com/").put("src", "s".repeat(120)))) + put("site", JSONObject().put("t", "T").put("u", "https://e.org/").put("d", "d".repeat(400))) + } + val bundle = good(TodayFile.verify(envelope(b, k), keysFor(k))) + val h = bundle.headlines.single() + assertEquals("a".repeat(199) + "\uD83C\uDDEC\uD83C\uDDE7", h.title) + assertEquals(TodayFile.MAX_NAME, h.source.length) + assertEquals(TodayFile.MAX_DESC, bundle.site!!.description!!.length) + } + + @Test + fun `a link that is not http or https drops its entry, not the bundle`() { + val k = key() + val b = + body { + put( + "headlines", + JSONArray() + .put(JSONObject().put("t", "script").put("u", "javascript:alert(1)")) + .put(JSONObject().put("t", "intent").put("u", "intent://x#Intent;end")) + .put(JSONObject().put("t", "no host").put("u", "https:///path")) + .put(JSONObject().put("t", "kept").put("u", "http://example.com/ok")), + ) + put("site", JSONObject().put("t", "Name").put("u", "file:///etc/passwd")) + } + val bundle = good(TodayFile.verify(envelope(b, k), keysFor(k))) + assertEquals(listOf("kept"), bundle.headlines.map { it.title }) + assertNull(bundle.site) + } + + @Test + fun `missing sections are absent, and odd values are left out`() { + val k = key() + val b = + body { + remove("markets") + remove("post") + put( + "weather", + JSONArray() + .put( + JSONObject() + .put("city", "Oslo") + .put("now", JSONObject().put("c", 3.6).put("sky", "volcanic")) + .put( + "days", + JSONArray() + .put(JSONObject().put("d", "2026-10-04").put("hi", "warm").put("lo", 1)) + .put(JSONObject().put("d", "2026-10-05").put("hi", 5).put("lo", -2).put("sky", "snow")), + ), + ).put(JSONObject().put("cc", "XX")), + ) + } + val bundle = good(TodayFile.verify(envelope(b, k), keysFor(k))) + assertTrue(bundle.markets.isEmpty()) + assertNull(bundle.post) + val oslo = bundle.weather.single() + assertEquals(TodayFile.Now(4, null), oslo.now, "a fraction rounds, an unknown sky draws none") + assertEquals(listOf(TodayFile.Day("2026-10-05", 5, -2, TodayFile.Sky.SNOW)), oslo.days) + } + + @Test + fun `a key that is not 32 bytes is not trusted in any shape`() { + val keys = TodayFile.Keys(listOf("", "AAAA", "not base64 at all", DEV_PUBLIC)) + assertEquals(setOf("a9a94e74"), keys.byKid.keys) + } +} diff --git a/client/src/test/resources/today/today-sample.json b/client/src/test/resources/today/today-sample.json new file mode 100644 index 0000000..81b74e8 --- /dev/null +++ b/client/src/test/resources/today/today-sample.json @@ -0,0 +1 @@ +{"v":1,"kid":"a9a94e74","body":"eyJ2IjoxLCJkYXRlIjoiMjAyNi0xMC0wNCIsIm1hZGUiOjE3OTExMTYyMzgsImhlYWRsaW5lcyI6W3sidCI6IuKAmExldmVsIG9mIHBldHRpbmVzc+KAmTogTlJM4oCZcyBlcGljIGZpbmFsIHNlcnZlIGluIEdyYW5kIEZpbmFsIHNhZ2EgYXMgdmVyZGljdCBpbiBvbiBlbnRlcnRhaW5tZW50IiwidSI6Imh0dHBzOi8vZmxhbWVuZXQuaW8vMjAyNi8xMC8wNC9sZXZlbC1vZi1wZXR0aW5lc3MtbnJscy1lcGljLWZpbmFsLXNlcnZlLWluLWdyYW5kLWZpbmFsLXNhZ2EtYXMtdmVyZGljdC1pbi1vbi1lbnRlcnRhaW5tZW50LWZveC1zcG9ydHMvIiwic3JjIjoiRm94IFNwb3J0cyJ9LHsidCI6IlNjaWVudGlzdHMganVzdCBmb3VuZCBhIG5ldyB3YXkgdG8gbWFrZSBoeWRyb2dlbiBmcm9tIHdhdGVyIiwidSI6Imh0dHBzOi8vZmxhbWVuZXQuaW8vMjAyNi8xMC8wNC9zY2llbnRpc3RzLWp1c3QtZm91bmQtYS1uZXctd2F5LXRvLW1ha2UtaHlkcm9nZW4tZnJvbS13YXRlci1zY2llbmNlZGFpbHkvIiwic3JjIjoiU2NpZW5jZURhaWx5In0seyJ0IjoiVHJ1bXAgZGVmaWFudCBhYm91dCBtaWR0ZXJtIGNoYW5jZXMgYXMgaGUgcmFsbGllcyBmb3IgUmVwdWJsaWNhbnMgaW4gT2hpbyIsInUiOiJodHRwczovL2ZsYW1lbmV0LmlvLzIwMjYvMTAvMDQvdHJ1bXAtZGVmaWFudC1hYm91dC1taWR0ZXJtLWNoYW5jZXMtYXMtaGUtcmFsbGllcy1mb3ItcmVwdWJsaWNhbnMtaW4tb2hpby1hbC1qYXplZXJhLyIsInNyYyI6IkFsIEphemVlcmEifSx7InQiOiJXaGF0IHNjaWVudGlzdHMgZGlzY292ZXJlZCBhYm91dCBkb2dzIGFuZCBodW1hbiBlbW90aW9ucyIsInUiOiJodHRwczovL2ZsYW1lbmV0LmlvLzIwMjYvMTAvMDQvd2hhdC1zY2llbnRpc3RzLWRpc2NvdmVyZWQtYWJvdXQtZG9ncy1hbmQtaHVtYW4tZW1vdGlvbnMteWFob28vIiwic3JjIjoiWWFob28ifSx7InQiOiJTY2llbnRpc3RzIFRlc3RlZCBXaGV0aGVyIExlZnRvdmVycyBSZWFsbHkgVGFzdGUgQmV0dGVyIFRoZSBOZXh0IERheS4gT25lIERpc2ggU3Rvb2QgT3V0LiIsInUiOiJodHRwczovL2ZsYW1lbmV0LmlvLzIwMjYvMTAvMDQvc2NpZW50aXN0cy10ZXN0ZWQtd2hldGhlci1sZWZ0b3ZlcnMtcmVhbGx5LXRhc3RlLWJldHRlci10aGUtbmV4dC1kYXktb25lLWRpc2gtc3Rvb2Qtb3V0LXNjaWVuY2VhbGVydC8iLCJzcmMiOiJTY2llbmNlQWxlcnQifSx7InQiOiJBdXJvcmFzIGFwcGVhciBpbiBicm9hZCBkYXlsaWdodCBhcyBTbWlsZSBzcGFjZWNyYWZ0IHNoYXJlcyBlZXJpZSBmaXJzdCBpbWFnZXMgb2YgRWFydGgiLCJ1IjoiaHR0cHM6Ly9mbGFtZW5ldC5pby8yMDI2LzEwLzA0L2F1cm9yYXMtYXBwZWFyLWluLWJyb2FkLWRheWxpZ2h0LWFzLXNtaWxlLXNwYWNlY3JhZnQtc2hhcmVzLWVlcmllLWZpcnN0LWltYWdlcy1vZi1lYXJ0aC1saXZlLXNjaWVuY2UvIiwic3JjIjoiTGl2ZSBTY2llbmNlIn0seyJ0IjoiSG93IGFuIFV6YmVrIHRydWNrIGRyaXZlcuKAmXMgZmF0YWwgY3Jhc2ggZXhwb3NlZCB0cm91YmxpbmcgZnJhdWQgdHJlbmQgaW4gTWlkZGxlIEFtZXJpY2E6IOKAmENoYW1lbGVvbiBjYXJyaWVyc+KAmSIsInUiOiJodHRwczovL2ZsYW1lbmV0LmlvLzIwMjYvMTAvMDQvaG93LWFuLXV6YmVrLXRydWNrLWRyaXZlcnMtZmF0YWwtY3Jhc2gtZXhwb3NlZC10cm91YmxpbmctZnJhdWQtdHJlbmQtaW4tbWlkZGxlLWFtZXJpY2EtY2hhbWVsZW9uLWNhcnJpZXJzLWZveC1uZXdzLyIsInNyYyI6IkZveCBOZXdzIn0seyJ0IjoiTGF0dGVyLWRheSBTYWludHMgd29tZW4gdHVybiBhd2F5IGZyb20gc2NhbmRhbC1wbGFndWVkIFJlcHVibGljYW4gY2FuZGlkYXRlIGluIEFyaXpvbmEiLCJ1IjoiaHR0cHM6Ly9mbGFtZW5ldC5pby8yMDI2LzEwLzA0L2xhdHRlci1kYXktc2FpbnRzLXdvbWVuLXR1cm4tYXdheS1mcm9tLXNjYW5kYWwtcGxhZ3VlZC1yZXB1YmxpY2FuLWNhbmRpZGF0ZS1pbi1hcml6b25hLW5wci8iLCJzcmMiOiJOUFIifSx7InQiOiJVLlMuIE1hcmluZSBhcnJlc3RlZCBvbiBzdXNwaWNpb24gb2Yga2lsbGluZyBKYXBhbmVzZSB3b21hbiBpbiBPa2luYXdhIiwidSI6Imh0dHBzOi8vZmxhbWVuZXQuaW8vMjAyNi8xMC8wNC91LXMtbWFyaW5lLWFycmVzdGVkLW9uLXN1c3BpY2lvbi1vZi1raWxsaW5nLWphcGFuZXNlLXdvbWFuLWluLW9raW5hd2EtbmJjLW5ld3MvIiwic3JjIjoiTkJDIE5ld3MifSx7InQiOiJGbHlkdWJhaSBjYXB0YWluIHNheXMgY29waWxvdCBhc2tlZCB0byBwcmF5LCB0aGVuIHN0cnVjayBoaW0gZnJvbSBiZWhpbmQ7IGRlc2NyaWJlcyBleGFjdGx5IGhvdyBoZSBvcGVuZWQgY29ja3BpdCBkb29yIiwidSI6Imh0dHBzOi8vZmxhbWVuZXQuaW8vMjAyNi8xMC8wNC9mbHlkdWJhaS1jYXB0YWluLXNheXMtY29waWxvdC1hc2tlZC10by1wcmF5LXRoZW4tc3RydWNrLWhpbS1mcm9tLWJlaGluZC1kZXNjcmliZXMtZXhhY3RseS1ob3ctaGUtb3BlbmVkLWNvY2twaXQtZG9vci10aGUtdGltZXMtb2YtaXNyYWVsLyIsInNyYyI6IlRoZSBUaW1lcyBvZiBJc3JhZWwifSx7InQiOiJTZWFyY2hlcnMgZmluZCBkZWJyaXMgb2YgQm9zdG9uLWJvdW5kIG1lZGljYWwgcGxhbmUgdGhhdCB3ZW50IG1pc3Npbmcgb2ZmIE5hbnR1Y2tldCIsInUiOiJodHRwczovL2ZsYW1lbmV0LmlvLzIwMjYvMTAvMDQvc2VhcmNoZXJzLWZpbmQtZGVicmlzLW9mLWJvc3Rvbi1ib3VuZC1tZWRpY2FsLXBsYW5lLXRoYXQtd2VudC1taXNzaW5nLW9mZi1uYW50dWNrZXQtdGhlLWJvc3Rvbi1nbG9iZS8iLCJzcmMiOiJUaGUgQm9zdG9uIEdsb2JlIn0seyJ0IjoiVGVubmVzc2VlIENvbW1pc3Npb25lciBSZXNpZ25zIEFmdGVyIEZhaWxlZCBFeGVjdXRpb24gb2YgQ2hyaXN0YSBQaWtlIiwidSI6Imh0dHBzOi8vZmxhbWVuZXQuaW8vMjAyNi8xMC8wNC90ZW5uZXNzZWUtY29tbWlzc2lvbmVyLXJlc2lnbnMtYWZ0ZXItZmFpbGVkLWV4ZWN1dGlvbi1vZi1jaHJpc3RhLXBpa2UtdGhlLW5ldy15b3JrLXRpbWVzLyIsInNyYyI6IlRoZSBOZXcgWW9yayBUaW1lcyJ9LHsidCI6IkFsaWVuIFRlY2hub2xvZ3kgRGVzZXJ2ZXMgYW4gWFBSSVpFIiwidSI6Imh0dHBzOi8vZmxhbWVuZXQuaW8vMjAyNi8xMC8wNC9hbGllbi10ZWNobm9sb2d5LWRlc2VydmVzLWFuLXhwcml6ZS1hdmktbG9lYi1tZWRpdW0vIiwic3JjIjoiQXZpIExvZWIg4oCTIE1lZGl1bSJ9LHsidCI6IllvdSBDYW4gTm93IFVzZSBZb3VyIEFuZHJvaWQgVGFibGV0IEFzIEEgR2FtaW5nIEhhbmRoZWxkIC0gSGVyZSdzIEhvdyIsInUiOiJodHRwczovL2ZsYW1lbmV0LmlvLzIwMjYvMTAvMDQveW91LWNhbi1ub3ctdXNlLXlvdXItYW5kcm9pZC10YWJsZXQtYXMtYS1nYW1pbmctaGFuZGhlbGQtaGVyZXMtaG93LWJnci1jb20vIiwic3JjIjoiYmdyLmNvbSJ9LHsidCI6IkNvcm5lbGwgcHJlc2lkZW50IHNheXMgcmFwZSBhbGxlZ2F0aW9ucyByYWlzZSDigJhoZWFydGJyZWFraW5nIGFuZCB0cm91YmxpbmcgaXNzdWVz4oCZIiwidSI6Imh0dHBzOi8vZmxhbWVuZXQuaW8vMjAyNi8xMC8wNC9jb3JuZWxsLXByZXNpZGVudC1zYXlzLXJhcGUtYWxsZWdhdGlvbnMtcmFpc2UtaGVhcnRicmVha2luZy1hbmQtdHJvdWJsaW5nLWlzc3Vlcy10aGUtd2FzaGluZ3Rvbi1wb3N0LyIsInNyYyI6IlRoZSBXYXNoaW5ndG9uIFBvc3QifSx7InQiOiLigJhGMSAyNeKAmSBBbmQgT3RoZXIgR2FtZXMgSm9pbmluZyBQUyBQbHVzIEluIE9jdG9iZXIgMjAyNiIsInUiOiJodHRwczovL2ZsYW1lbmV0LmlvLzIwMjYvMTAvMDQvZjEtMjUtYW5kLW90aGVyLWdhbWVzLWpvaW5pbmctcHMtcGx1cy1pbi1vY3RvYmVyLTIwMjYtYXVndXN0bWFuLXNpbmdhcG9yZS8iLCJzcmMiOiJBdWd1c3RNYW4gU2luZ2Fwb3JlIn0seyJ0IjoiRXhjbHVzaXZlIHwgTmV3IEFJIEN6YXIgVW52ZWlscyBHb2FscywgTWVtYmVycyBvZiBXaGl0ZSBIb3VzZSBUYXNrIEZvcmNlIiwidSI6Imh0dHBzOi8vZmxhbWVuZXQuaW8vMjAyNi8xMC8wNC9leGNsdXNpdmUtbmV3LWFpLWN6YXItdW52ZWlscy1nb2Fscy1tZW1iZXJzLW9mLXdoaXRlLWhvdXNlLXRhc2stZm9yY2Utd3NqLyIsInNyYyI6IldTSiJ9LHsidCI6IlBsYXkgUGxheVN0YXRpb24gMiBBbmQgWEJveCAxIEdhbWVzIE9uIFlvdXIgSmFpbGJyb2tlbiBQUzUiLCJ1IjoiaHR0cHM6Ly9mbGFtZW5ldC5pby8yMDI2LzEwLzA0L3BsYXktcGxheXN0YXRpb24tMi1hbmQteGJveC0xLWdhbWVzLW9uLXlvdXItamFpbGJyb2tlbi1wczUtaGFja2FkYXkvIiwic3JjIjoiSGFja2FkYXkifSx7InQiOiJUcnVtcCBhbm5vdW5jZXMgJDkwIHBheW1lbnRzIGZvciBzZW5pb3JzIG9uIE1lZGljYXJlIGFoZWFkIG9mIG1pZHRlcm0gZWxlY3Rpb25zIGZvciBwcmVtaXVtIGNvc3RzIiwidSI6Imh0dHBzOi8vZmxhbWVuZXQuaW8vMjAyNi8xMC8wNC90cnVtcC1hbm5vdW5jZXMtOTAtcGF5bWVudHMtZm9yLXNlbmlvcnMtb24tbWVkaWNhcmUtYWhlYWQtb2YtbWlkdGVybS1lbGVjdGlvbnMtZm9yLXByZW1pdW0tY29zdHMtYXAtbmV3cy8iLCJzcmMiOiJBUCBOZXdzIn0seyJ0IjoiTHVsYSwgQm9sc29uYXJvIGluIERlYWQgSGVhdCBhcyBCcmF6aWwgUHJlcGFyZXMgdG8gVm90ZSIsInUiOiJodHRwczovL2ZsYW1lbmV0LmlvLzIwMjYvMTAvMDQvbHVsYS1ib2xzb25hcm8taW4tZGVhZC1oZWF0LWFzLWJyYXppbC1wcmVwYXJlcy10by12b3RlLWJsb29tYmVyZy1jb20vIiwic3JjIjoiQmxvb21iZXJnLmNvbSJ9XSwibWFya2V0cyI6W3sic3ltIjoiXkRKSSIsIm5hbWUiOiJEb3cgSm9uZXMgSW5kdXN0cmlhbCBBdmVyYWdlIiwibGFzdCI6IjUxLDE3Ni45NiIsImNoZyI6IisyNTAuNDAiLCJwY3QiOiIrMC40OSJ9LHsic3ltIjoiXkdTUEMiLCJuYW1lIjoiUyZQIDUwMCIsImxhc3QiOiI3LDcyMi43MiIsImNoZyI6Iis1Ni4yNyIsInBjdCI6IiswLjczIn0seyJzeW0iOiJeSVhJQyIsIm5hbWUiOiJOQVNEQVEgQ29tcG9zaXRlIiwibGFzdCI6IjI3LDE5MC44NiIsImNoZyI6IiszMTkuMjciLCJwY3QiOiIrMS4xOSJ9LHsic3ltIjoiXkZUU0UiLCJuYW1lIjoiRlRTRSAxMDAgKFVLKSIsImxhc3QiOiIxMCw0NjEuOTUiLCJjaGciOiIrMzMuNjgiLCJwY3QiOiIrMC4zMiJ9LHsic3ltIjoiXkdEQVhJIiwibmFtZSI6IkRBWCAoR2VybWFueSkiLCJsYXN0IjoiMjUsMjMxLjIwIiwiY2hnIjoiKzI5MS44NSIsInBjdCI6IisxLjE3In0seyJzeW0iOiJeRkNISSIsIm5hbWUiOiJDQUMgNDAgKEZyYW5jZSkiLCJsYXN0IjoiNyw4OTcuMTkiLCJjaGciOiIrNjEuODgiLCJwY3QiOiIrMC43OSJ9LHsic3ltIjoiXk4yMjUiLCJuYW1lIjoiTmlra2VpIDIyNSAoSmFwYW4pIiwibGFzdCI6IjY4LDMwOS40NiIsImNoZyI6Ii02NDcuMjYiLCJwY3QiOiItMC45NCJ9LHsic3ltIjoiXkhTSSIsIm5hbWUiOiJIYW5nIFNlbmcgKEhvbmcgS29uZykiLCJsYXN0IjoiMjMsOTcyLjI5IiwiY2hnIjoiLTY0MC45OCIsInBjdCI6Ii0yLjYwIn0seyJzeW0iOiIwMDAwMDEuU1MiLCJuYW1lIjoiU2hhbmdoYWkgQ29tcG9zaXRlIChDaGluYSkiLCJsYXN0IjoiMyw4NDIuMTkiLCJjaGciOiIrMTEuNzQiLCJwY3QiOiIrMC4zMSJ9XSwid2VhdGhlciI6W3siY2l0eSI6Ik5ldyBZb3JrIiwiY2MiOiJVUyIsInR6IjoiQW1lcmljYS9OZXdfWW9yayIsImxhdCI6NDAuNzEsImxvbiI6LTc0LjAxLCJub3ciOnsiYyI6MTQsInNreSI6ImNsZWFyIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjE2LCJsbyI6MTMsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjIwLCJsbyI6MTEsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MTgsImxvIjoxMCwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIyLCJsbyI6MTMsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjUsImxvIjoxNiwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJMb3MgQW5nZWxlcyIsImNjIjoiVVMiLCJ0eiI6IkFtZXJpY2EvTG9zX0FuZ2VsZXMiLCJsYXQiOjM0LjA1LCJsb24iOi0xMTguMjQsIm5vdyI6eyJjIjoyNSwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MzgsImxvIjoyNCwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjQwLCJsbyI6MjQsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjozNiwibG8iOjI4LCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MzUsImxvIjoyNywic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjMzLCJsbyI6MjcsInNreSI6ImNsZWFyIn1dfSx7ImNpdHkiOiJDaGljYWdvIiwiY2MiOiJVUyIsInR6IjoiQW1lcmljYS9DaGljYWdvIiwibGF0Ijo0MS44OCwibG9uIjotODcuNjMsIm5vdyI6eyJjIjoxMCwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MTksImxvIjoxMCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoxNSwibG8iOjksInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyMiwibG8iOjExLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI0LCJsbyI6MTYsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTksImxvIjoxNCwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJIb3VzdG9uIiwiY2MiOiJVUyIsInR6IjoiQW1lcmljYS9DaGljYWdvIiwibGF0IjoyOS43NiwibG9uIjotOTUuMzcsIm5vdyI6eyJjIjoyNCwic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI2LCJsbyI6MjMsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI5LCJsbyI6MjIsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzMsImxvIjoyNCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMywibG8iOjI1LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyNywibG8iOjI0LCJza3kiOiJzdG9ybSJ9XX0seyJjaXR5IjoiUGhvZW5peCIsImNjIjoiVVMiLCJ0eiI6IkFtZXJpY2EvUGhvZW5peCIsImxhdCI6MzMuNDUsImxvbiI6LTExMi4wNywibm93Ijp7ImMiOjI2LCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozNywibG8iOjI1LCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MzYsImxvIjoyNCwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjM1LCJsbyI6MjksInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MzcsImxvIjozMSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozNywibG8iOjI5LCJza3kiOiJjbGVhciJ9XX0seyJjaXR5IjoiU2FuIEZyYW5jaXNjbyIsImNjIjoiVVMiLCJ0eiI6IkFtZXJpY2EvTG9zX0FuZ2VsZXMiLCJsYXQiOjM3Ljc3LCJsb24iOi0xMjIuNDIsIm5vdyI6eyJjIjoyMCwic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjM0LCJsbyI6MTgsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjcsImxvIjoxNCwic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyOCwibG8iOjIwLCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjcsImxvIjoxOCwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI2LCJsbyI6MTksInNreSI6ImNsZWFyIn1dfSx7ImNpdHkiOiJTZWF0dGxlIiwiY2MiOiJVUyIsInR6IjoiQW1lcmljYS9Mb3NfQW5nZWxlcyIsImxhdCI6NDcuNjEsImxvbiI6LTEyMi4zMywibm93Ijp7ImMiOjEwLCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyMCwibG8iOjksInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MTksImxvIjoxMCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoxOSwibG8iOjExLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIwLCJsbyI6MTAsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjEsImxvIjoxMCwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJEZW52ZXIiLCJjYyI6IlVTIiwidHoiOiJBbWVyaWNhL0RlbnZlciIsImxhdCI6MzkuNzQsImxvbiI6LTEwNC45OSwibm93Ijp7ImMiOjEwLCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyOSwibG8iOjksInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMSwibG8iOjExLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI5LCJsbyI6MjAsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjgsImxvIjoxOSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyOSwibG8iOjE5LCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6IkRhbGxhcyIsImNjIjoiVVMiLCJ0eiI6IkFtZXJpY2EvQ2hpY2FnbyIsImxhdCI6MzIuNzgsImxvbiI6LTk2LjgsIm5vdyI6eyJjIjoyMiwic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI1LCJsbyI6MjIsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI3LCJsbyI6MTgsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjksImxvIjoyMCwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjMxLCJsbyI6MjIsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MzAsImxvIjoyMywic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiQXRsYW50YSIsImNjIjoiVVMiLCJ0eiI6IkFtZXJpY2EvTmV3X1lvcmsiLCJsYXQiOjMzLjc1LCJsb24iOi04NC4zOSwibm93Ijp7ImMiOjIyLCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjcsImxvIjoyMiwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjcsImxvIjoyMSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjcsImxvIjoxNywic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI4LCJsbyI6MTcsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MzAsImxvIjoxOSwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJNaWFtaSIsImNjIjoiVVMiLCJ0eiI6IkFtZXJpY2EvTmV3X1lvcmsiLCJsYXQiOjI1Ljc2LCJsb24iOi04MC4xOSwibm93Ijp7ImMiOjI3LCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyOSwibG8iOjI2LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI5LCJsbyI6MjQsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzAsImxvIjoyNSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MzAsImxvIjoyNywic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjMxLCJsbyI6MjcsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IkJvc3RvbiIsImNjIjoiVVMiLCJ0eiI6IkFtZXJpY2EvTmV3X1lvcmsiLCJsYXQiOjQyLjM2LCJsb24iOi03MS4wNiwibm93Ijp7ImMiOjEwLCJza3kiOiJwYXJ0bHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MTYsImxvIjo4LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoxOSwibG8iOjksInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MTUsImxvIjo3LCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjAsImxvIjo4LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjIyLCJsbyI6MTUsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6Ildhc2hpbmd0b24sIEQuQy4iLCJjYyI6IlVTIiwidHoiOiJBbWVyaWNhL05ld19Zb3JrIiwibGF0IjozOC45MSwibG9uIjotNzcuMDQsIm5vdyI6eyJjIjoxNiwic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjE4LCJsbyI6MTUsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjIyLCJsbyI6MTIsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjEsImxvIjoxMSwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI1LCJsbyI6MTEsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozMCwibG8iOjE1LCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6IkFuY2hvcmFnZSIsImNjIjoiVVMiLCJ0eiI6IkFtZXJpY2EvQW5jaG9yYWdlIiwibGF0Ijo2MS4yMiwibG9uIjotMTQ5LjksIm5vdyI6eyJjIjo0LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6NiwibG8iOjMsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjEwLCJsbyI6NCwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6OSwibG8iOjMsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjgsImxvIjo1LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjo2LCJsbyI6NCwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJIb25vbHVsdSIsImNjIjoiVVMiLCJ0eiI6IlBhY2lmaWMvSG9ub2x1bHUiLCJsYXQiOjIxLjMxLCJsb24iOi0xNTcuODYsIm5vdyI6eyJjIjoyMywic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjgsImxvIjoyMywic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjgsImxvIjoyMiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjksImxvIjoyMiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjgsImxvIjoyMiwic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyNywibG8iOjI0LCJza3kiOiJyYWluIn1dfSx7ImNpdHkiOiJUb3JvbnRvIiwiY2MiOiJDQSIsInR6IjoiQW1lcmljYS9Ub3JvbnRvIiwibGF0Ijo0My42NSwibG9uIjotNzkuMzgsIm5vdyI6eyJjIjoxMSwic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjIwLCJsbyI6MTAsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MTQsImxvIjo2LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjE1LCJsbyI6Nywic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoyNCwibG8iOjE0LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjE5LCJsbyI6MTIsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6Ik1vbnRyZWFsIiwiY2MiOiJDQSIsInR6IjoiQW1lcmljYS9Ub3JvbnRvIiwibGF0Ijo0NS41LCJsb24iOi03My41Nywibm93Ijp7ImMiOjUsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoxNiwibG8iOjQsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjEyLCJsbyI6NCwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MTIsImxvIjo0LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjE1LCJsbyI6Niwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTcsImxvIjoxMSwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiVmFuY291dmVyIiwiY2MiOiJDQSIsInR6IjoiQW1lcmljYS9WYW5jb3V2ZXIiLCJsYXQiOjQ5LjI4LCJsb24iOi0xMjMuMTIsIm5vdyI6eyJjIjoxMSwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MTgsImxvIjoxMSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoxOCwibG8iOjEwLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjE2LCJsbyI6MTIsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MTcsImxvIjoxMiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxOCwibG8iOjEzLCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6Ik1leGljbyBDaXR5IiwiY2MiOiJNWCIsInR6IjoiQW1lcmljYS9NZXhpY29fQ2l0eSIsImxhdCI6MTkuNDMsImxvbiI6LTk5LjEzLCJub3ciOnsiYyI6MTUsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyMCwibG8iOjE1LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjIsImxvIjoxNCwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIyLCJsbyI6MTQsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoyMSwibG8iOjE1LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjEsImxvIjoxNSwic2t5Ijoic3Rvcm0ifV19LHsiY2l0eSI6Ikd1YXRlbWFsYSBDaXR5IiwiY2MiOiJHVCIsInR6IjoiQW1lcmljYS9HdWF0ZW1hbGEiLCJsYXQiOjE0LjYzLCJsb24iOi05MC41MSwibm93Ijp7ImMiOjE3LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjMsImxvIjoxNywic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjQsImxvIjoxNiwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjYsImxvIjoxNiwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjUsImxvIjoxNiwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjYsImxvIjoxNiwic2t5IjoicmFpbiJ9XX0seyJjaXR5IjoiUGFuYW1hIENpdHkiLCJjYyI6IlBBIiwidHoiOiJBbWVyaWNhL1BhbmFtYSIsImxhdCI6OC45OCwibG9uIjotNzkuNTIsIm5vdyI6eyJjIjoyNSwic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjMxLCJsbyI6MjQsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjMyLCJsbyI6MjUsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjozMSwibG8iOjI1LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjksImxvIjoyNCwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjksImxvIjoyNCwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiSGF2YW5hIiwiY2MiOiJDVSIsInR6IjoiQW1lcmljYS9IYXZhbmEiLCJsYXQiOjIzLjExLCJsb24iOi04Mi4zNywibm93Ijp7ImMiOjI1LCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMiwibG8iOjI1LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMiwibG8iOjI1LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjozMiwibG8iOjI0LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MzEsImxvIjoyNCwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI5LCJsbyI6MjQsInNreSI6InN0b3JtIn1dfSx7ImNpdHkiOiJLaW5nc3RvbiIsImNjIjoiSk0iLCJ0eiI6IkFtZXJpY2EvSmFtYWljYSIsImxhdCI6MTguMDIsImxvbiI6LTc2LjgsIm5vdyI6eyJjIjoyNiwic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjMxLCJsbyI6MjMsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMCwibG8iOjIzLCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzIsImxvIjoyNCwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MzEsImxvIjoyMywic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI5LCJsbyI6MjIsInNreSI6InN0b3JtIn1dfSx7ImNpdHkiOiJTYW50byBEb21pbmdvIiwiY2MiOiJETyIsInR6IjoiQW1lcmljYS9TYW50b19Eb21pbmdvIiwibGF0IjoxOC40OSwibG9uIjotNjkuOTMsIm5vdyI6eyJjIjoyMywic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI5LCJsbyI6MjMsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMCwibG8iOjIzLCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzEsImxvIjoyMywic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMiwibG8iOjI0LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MzAsImxvIjoyNCwic2t5Ijoic3Rvcm0ifV19LHsiY2l0eSI6IlNhbiBKdWFuIiwiY2MiOiJQUiIsInR6IjoiQW1lcmljYS9QdWVydG9fUmljbyIsImxhdCI6MTguNDcsImxvbiI6LTY2LjExLCJub3ciOnsiYyI6MjUsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyOSwibG8iOjIzLCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjksImxvIjoyNSwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjksImxvIjoyNiwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI5LCJsbyI6MjYsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyOSwibG8iOjI2LCJza3kiOiJkcml6emxlIn1dfSx7ImNpdHkiOiJTw6NvIFBhdWxvIiwiY2MiOiJCUiIsInR6IjoiQW1lcmljYS9TYW9fUGF1bG8iLCJsYXQiOi0yMy41NSwibG9uIjotNDYuNjMsIm5vdyI6eyJjIjoyMywic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjQsImxvIjoxNywic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI4LCJsbyI6MTcsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNCwibG8iOjE5LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjYsImxvIjoxOCwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjMwLCJsbyI6MTcsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IlJpbyBkZSBKYW5laXJvIiwiY2MiOiJCUiIsInR6IjoiQW1lcmljYS9TYW9fUGF1bG8iLCJsYXQiOi0yMi45MSwibG9uIjotNDMuMTcsIm5vdyI6eyJjIjoyNCwic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI4LCJsbyI6MTksInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI5LCJsbyI6MjIsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI5LCJsbyI6MjIsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI1LCJsbyI6MjIsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI1LCJsbyI6MjIsInNreSI6ImNsb3VkeSJ9XX0seyJjaXR5IjoiQnJhc8OtbGlhIiwiY2MiOiJCUiIsInR6IjoiQW1lcmljYS9TYW9fUGF1bG8iLCJsYXQiOi0xNS43OSwibG9uIjotNDcuODgsIm5vdyI6eyJjIjoyMywic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI2LCJsbyI6MTksInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI2LCJsbyI6MTgsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNywibG8iOjE4LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMCwibG8iOjE5LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjMwLCJsbyI6MjAsInNreSI6InBhcnRseSJ9XX0seyJjaXR5IjoiQnVlbm9zIEFpcmVzIiwiY2MiOiJBUiIsInR6IjoiQW1lcmljYS9BcmdlbnRpbmEvQnVlbm9zX0FpcmVzIiwibGF0IjotMzQuNiwibG9uIjotNTguMzgsIm5vdyI6eyJjIjoxNiwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjMsImxvIjoxMSwic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMCwibG8iOjE0LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIwLCJsbyI6MTMsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjE4LCJsbyI6MTUsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjE4LCJsbyI6MTUsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IlNhbnRpYWdvIiwiY2MiOiJDTCIsInR6IjoiQW1lcmljYS9TYW50aWFnbyIsImxhdCI6LTMzLjQ1LCJsb24iOi03MC42Nywibm93Ijp7ImMiOjExLCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MTksImxvIjo5LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMSwibG8iOjExLCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoxNiwibG8iOjEyLCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoxNywibG8iOjExLCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxOCwibG8iOjgsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IkxpbWEiLCJjYyI6IlBFIiwidHoiOiJBbWVyaWNhL0xpbWEiLCJsYXQiOi0xMi4wNSwibG9uIjotNzcuMDQsIm5vdyI6eyJjIjoyMCwic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI1LCJsbyI6MjAsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjUsImxvIjoyMCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNiwibG8iOjIwLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI0LCJsbyI6MjAsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjUsImxvIjoyMSwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJCb2dvdMOhIiwiY2MiOiJDTyIsInR6IjoiQW1lcmljYS9Cb2dvdGEiLCJsYXQiOjQuNzEsImxvbiI6LTc0LjA3LCJub3ciOnsiYyI6MTMsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyMSwibG8iOjEwLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMywibG8iOjksInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIyLCJsbyI6MTIsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIxLCJsbyI6MTIsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjIwLCJsbyI6MTEsInNreSI6InJhaW4ifV19LHsiY2l0eSI6IkNhcmFjYXMiLCJjYyI6IlZFIiwidHoiOiJBbWVyaWNhL0NhcmFjYXMiLCJsYXQiOjEwLjQ4LCJsb24iOi02Ni45LCJub3ciOnsiYyI6MjQsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyNywibG8iOjE4LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjksImxvIjoxOSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjgsImxvIjoyMCwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjgsImxvIjoyMSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjgsImxvIjoyMCwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiUXVpdG8iLCJjYyI6IkVDIiwidHoiOiJBbWVyaWNhL0d1YXlhcXVpbCIsImxhdCI6LTAuMTgsImxvbiI6LTc4LjQ3LCJub3ciOnsiYyI6MTEsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoxOCwibG8iOjEwLCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoxOSwibG8iOjEwLCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyMCwibG8iOjEwLCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoxOCwibG8iOjEwLCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxNywibG8iOjEwLCJza3kiOiJyYWluIn1dfSx7ImNpdHkiOiJNb250ZXZpZGVvIiwiY2MiOiJVWSIsInR6IjoiQW1lcmljYS9Nb250ZXZpZGVvIiwibGF0IjotMzQuOSwibG9uIjotNTYuMTYsIm5vdyI6eyJjIjoxNCwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MTcsImxvIjoxMywic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoxNywibG8iOjEyLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjE2LCJsbyI6MTEsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MTYsImxvIjoxNCwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTUsImxvIjoxNSwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJMYSBQYXoiLCJjYyI6IkJPIiwidHoiOiJBbWVyaWNhL0xhX1BheiIsImxhdCI6LTE2LjQ5LCJsb24iOi02OC4xMiwibm93Ijp7ImMiOjEwLCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyMCwibG8iOjIsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjEsImxvIjoyLCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIxLCJsbyI6MSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoyMiwibG8iOjEsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjMsImxvIjo0LCJza3kiOiJwYXJ0bHkifV19LHsiY2l0eSI6IkFzdW5jacOzbiIsImNjIjoiUFkiLCJ0eiI6IkFtZXJpY2EvQXN1bmNpb24iLCJsYXQiOi0yNS4yNiwibG9uIjotNTcuNTgsIm5vdyI6eyJjIjoyMywic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI4LCJsbyI6MTgsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI1LCJsbyI6MjEsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNywibG8iOjIwLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMSwibG8iOjIzLCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjksImxvIjoyMiwic2t5Ijoic3Rvcm0ifV19LHsiY2l0eSI6IkxvbmRvbiIsImNjIjoiR0IiLCJ0eiI6IkV1cm9wZS9Mb25kb24iLCJsYXQiOjUxLjUxLCJsb24iOi0wLjEzLCJub3ciOnsiYyI6MTgsInNreSI6ImNsZWFyIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjIxLCJsbyI6MTIsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjIsImxvIjoxMywic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoxOCwibG8iOjE0LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjE4LCJsbyI6MTEsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjE1LCJsbyI6OCwic2t5IjoicGFydGx5In1dfSx7ImNpdHkiOiJEdWJsaW4iLCJjYyI6IklFIiwidHoiOiJFdXJvcGUvRHVibGluIiwibGF0Ijo1My4zNSwibG9uIjotNi4yNiwibm93Ijp7ImMiOjE4LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MTksImxvIjoxMiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMCwibG8iOjE3LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjE3LCJsbyI6MTEsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjEzLCJsbyI6OSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxNCwibG8iOjcsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IlBhcmlzIiwiY2MiOiJGUiIsInR6IjoiRXVyb3BlL1BhcmlzIiwibGF0Ijo0OC44NiwibG9uIjoyLjM1LCJub3ciOnsiYyI6MjIsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyNCwibG8iOjE0LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI0LCJsbyI6MTMsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjQsImxvIjoxMywic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoxNiwibG8iOjEyLCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTQsImxvIjo5LCJza3kiOiJyYWluIn1dfSx7ImNpdHkiOiJCZXJsaW4iLCJjYyI6IkRFIiwidHoiOiJFdXJvcGUvQmVybGluIiwibGF0Ijo1Mi41MiwibG9uIjoxMy40LCJub3ciOnsiYyI6MjAsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyMCwibG8iOjEwLCJza3kiOiJmb2cifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjIxLCJsbyI6OSwic2t5IjoiZm9nIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyMiwibG8iOjExLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIzLCJsbyI6MTAsInNreSI6ImZvZyJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTgsImxvIjoxMSwic2t5IjoicmFpbiJ9XX0seyJjaXR5IjoiTWFkcmlkIiwiY2MiOiJFUyIsInR6IjoiRXVyb3BlL01hZHJpZCIsImxhdCI6NDAuNDIsImxvbiI6LTMuNywibm93Ijp7ImMiOjE5LCJza3kiOiJyYWluIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjIwLCJsbyI6MTYsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMCwibG8iOjE2LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjAsImxvIjoxNiwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIxLCJsbyI6MTUsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjIwLCJsbyI6MTQsInNreSI6InBhcnRseSJ9XX0seyJjaXR5IjoiQmFyY2Vsb25hIiwiY2MiOiJFUyIsInR6IjoiRXVyb3BlL01hZHJpZCIsImxhdCI6NDEuMzksImxvbiI6Mi4xNywibm93Ijp7ImMiOjIyLCJza3kiOiJyYWluIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjIyLCJsbyI6MTksInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNSwibG8iOjIwLCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjcsImxvIjoxOSwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjUsImxvIjoyMCwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjMsImxvIjoxOCwic2t5IjoicmFpbiJ9XX0seyJjaXR5IjoiTGlzYm9uIiwiY2MiOiJQVCIsInR6IjoiRXVyb3BlL0xpc2JvbiIsImxhdCI6MzguNzIsImxvbiI6LTkuMTQsIm5vdyI6eyJjIjoyMywic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI1LCJsbyI6MjAsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNiwibG8iOjE5LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIzLCJsbyI6MTksInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIzLCJsbyI6MTcsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjYsImxvIjoxNywic2t5IjoiY2xlYXIifV19LHsiY2l0eSI6IlJvbWUiLCJjYyI6IklUIiwidHoiOiJFdXJvcGUvUm9tZSIsImxhdCI6NDEuOSwibG9uIjoxMi41LCJub3ciOnsiYyI6MjcsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyNywibG8iOjE4LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI2LCJsbyI6MTcsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIxLCJsbyI6MTgsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI2LCJsbyI6MTcsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjMsImxvIjoyMCwic2t5Ijoic3Rvcm0ifV19LHsiY2l0eSI6IkFtc3RlcmRhbSIsImNjIjoiTkwiLCJ0eiI6IkV1cm9wZS9BbXN0ZXJkYW0iLCJsYXQiOjUyLjM3LCJsb24iOjQuOSwibm93Ijp7ImMiOjE5LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjAsImxvIjoxNSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMSwibG8iOjE0LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIyLCJsbyI6MTUsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MTksImxvIjoxMywic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTMsImxvIjoxMCwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiQnJ1c3NlbHMiLCJjYyI6IkJFIiwidHoiOiJFdXJvcGUvQnJ1c3NlbHMiLCJsYXQiOjUwLjg1LCJsb24iOjQuMzUsIm5vdyI6eyJjIjoyMiwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjMsImxvIjoxNCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMywibG8iOjE1LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIzLCJsbyI6MTQsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjIsImxvIjoxMSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTYsImxvIjo4LCJza3kiOiJyYWluIn1dfSx7ImNpdHkiOiJadXJpY2giLCJjYyI6IkNIIiwidHoiOiJFdXJvcGUvWnVyaWNoIiwibGF0Ijo0Ny4zOCwibG9uIjo4LjU0LCJub3ciOnsiYyI6MjMsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyNCwibG8iOjExLCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMiwibG8iOjE1LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIyLCJsbyI6MTEsInNreSI6ImZvZyJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjMsImxvIjoxMSwic2t5IjoiZm9nIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxNywibG8iOjgsInNreSI6InJhaW4ifV19LHsiY2l0eSI6IlZpZW5uYSIsImNjIjoiQVQiLCJ0eiI6IkV1cm9wZS9WaWVubmEiLCJsYXQiOjQ4LjIxLCJsb24iOjE2LjM3LCJub3ciOnsiYyI6MjIsInNreSI6ImNsZWFyIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjIzLCJsbyI6MTAsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjMsImxvIjoxNSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNCwibG8iOjEyLCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI0LCJsbyI6MTAsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTksImxvIjoxMSwic2t5IjoicmFpbiJ9XX0seyJjaXR5IjoiUHJhZ3VlIiwiY2MiOiJDWiIsInR6IjoiRXVyb3BlL1ByYWd1ZSIsImxhdCI6NTAuMDgsImxvbiI6MTQuNDQsIm5vdyI6eyJjIjoxOSwic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjIwLCJsbyI6OCwic2t5IjoiZm9nIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMCwibG8iOjEwLCJza3kiOiJmb2cifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIxLCJsbyI6OCwic2t5IjoiZm9nIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoyMiwibG8iOjgsInNreSI6ImZvZyJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTcsImxvIjo2LCJza3kiOiJyYWluIn1dfSx7ImNpdHkiOiJXYXJzYXciLCJjYyI6IlBMIiwidHoiOiJFdXJvcGUvV2Fyc2F3IiwibGF0Ijo1Mi4yMywibG9uIjoyMS4wMSwibm93Ijp7ImMiOjE4LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MTksImxvIjoxMSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MTgsImxvIjoxMiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyMSwibG8iOjEzLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIwLCJsbyI6OSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyMiwibG8iOjExLCJza3kiOiJkcml6emxlIn1dfSx7ImNpdHkiOiJCdWRhcGVzdCIsImNjIjoiSFUiLCJ0eiI6IkV1cm9wZS9CdWRhcGVzdCIsImxhdCI6NDcuNSwibG9uIjoxOS4wNCwibm93Ijp7ImMiOjIzLCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjQsImxvIjoxMSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNSwibG8iOjEzLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI1LCJsbyI6MTUsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjMsImxvIjo5LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI1LCJsbyI6MTIsInNreSI6InJhaW4ifV19LHsiY2l0eSI6IkJlbGdyYWRlIiwiY2MiOiJSUyIsInR6IjoiRXVyb3BlL0JlbGdyYWRlIiwibGF0Ijo0NC43OSwibG9uIjoyMC40NSwibm93Ijp7ImMiOjIzLCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyNCwibG8iOjE0LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI0LCJsbyI6MTMsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjUsImxvIjoxNCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoyNCwibG8iOjExLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI2LCJsbyI6MTMsInNreSI6InBhcnRseSJ9XX0seyJjaXR5IjoiQnVjaGFyZXN0IiwiY2MiOiJSTyIsInR6IjoiRXVyb3BlL0J1Y2hhcmVzdCIsImxhdCI6NDQuNDMsImxvbiI6MjYuMSwibm93Ijp7ImMiOjIzLCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyMywibG8iOjUsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNCwibG8iOjYsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjUsImxvIjo3LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI1LCJsbyI6OSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyNSwibG8iOjEwLCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6IkF0aGVucyIsImNjIjoiR1IiLCJ0eiI6IkV1cm9wZS9BdGhlbnMiLCJsYXQiOjM3Ljk4LCJsb24iOjIzLjczLCJub3ciOnsiYyI6MjIsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyMiwibG8iOjE1LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI0LCJsbyI6MTQsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNiwibG8iOjE0LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI2LCJsbyI6MTMsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjUsImxvIjoxNCwic2t5IjoicGFydGx5In1dfSx7ImNpdHkiOiJJc3RhbmJ1bCIsImNjIjoiVFIiLCJ0eiI6IkV1cm9wZS9Jc3RhbmJ1bCIsImxhdCI6NDEuMDEsImxvbiI6MjguOTgsIm5vdyI6eyJjIjoyMCwic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjIxLCJsbyI6MTUsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjEsImxvIjoxNCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyMSwibG8iOjE1LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIyLCJsbyI6MTUsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjAsImxvIjoxMywic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJLeWl2IiwiY2MiOiJVQSIsInR6IjoiRXVyb3BlL0tpZXYiLCJsYXQiOjUwLjQ1LCJsb24iOjMwLjUyLCJub3ciOnsiYyI6MTgsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoxOCwibG8iOjYsInNreSI6ImZvZyJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MTgsImxvIjoxMSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoxOCwibG8iOjEwLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIxLCJsbyI6MTAsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjMsImxvIjoxMywic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJNb3Njb3ciLCJjYyI6IlJVIiwidHoiOiJFdXJvcGUvTW9zY293IiwibGF0Ijo1NS43NiwibG9uIjozNy42Miwibm93Ijp7ImMiOjEyLCJza3kiOiJwYXJ0bHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MTIsImxvIjoyLCJza3kiOiJmb2cifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjEzLCJsbyI6OSwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MTMsImxvIjo4LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoxNCwibG8iOjksInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTYsImxvIjo2LCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6IlN0b2NraG9sbSIsImNjIjoiU0UiLCJ0eiI6IkV1cm9wZS9TdG9ja2hvbG0iLCJsYXQiOjU5LjMzLCJsb24iOjE4LjA3LCJub3ciOnsiYyI6MTUsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoxNiwibG8iOjEyLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjE2LCJsbyI6MTIsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjE2LCJsbyI6MTEsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MTMsImxvIjo4LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjEzLCJsbyI6MTAsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6Ik9zbG8iLCJjYyI6Ik5PIiwidHoiOiJFdXJvcGUvT3NsbyIsImxhdCI6NTkuOTEsImxvbiI6MTAuNzUsIm5vdyI6eyJjIjoxNCwic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjE1LCJsbyI6OCwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MTYsImxvIjoxMiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MTcsImxvIjo5LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjEzLCJsbyI6OCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjo5LCJsbyI6Nywic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiQ29wZW5oYWdlbiIsImNjIjoiREsiLCJ0eiI6IkV1cm9wZS9Db3BlbmhhZ2VuIiwibGF0Ijo1NS42OCwibG9uIjoxMi41Nywibm93Ijp7ImMiOjE2LCJza3kiOiJwYXJ0bHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MTcsImxvIjoxMiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMCwibG8iOjEzLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoxOCwibG8iOjEyLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoxNSwibG8iOjExLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxNSwibG8iOjExLCJza3kiOiJkcml6emxlIn1dfSx7ImNpdHkiOiJIZWxzaW5raSIsImNjIjoiRkkiLCJ0eiI6IkV1cm9wZS9IZWxzaW5raSIsImxhdCI6NjAuMTcsImxvbiI6MjQuOTQsIm5vdyI6eyJjIjoxNCwic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjE1LCJsbyI6MTAsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjE0LCJsbyI6OSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MTUsImxvIjoxMiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MTQsImxvIjo4LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjEzLCJsbyI6NSwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiUmV5a2phdsOtayIsImNjIjoiSVMiLCJ0eiI6IkF0bGFudGljL1JleWtqYXZpayIsImxhdCI6NjQuMTUsImxvbiI6LTIxLjk0LCJub3ciOnsiYyI6OSwic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjEwLCJsbyI6Nywic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6NywibG8iOjMsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjksImxvIjoyLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjcsImxvIjoxLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjo4LCJsbyI6NSwic2t5IjoicmFpbiJ9XX0seyJjaXR5IjoiQ2Fpcm8iLCJjYyI6IkVHIiwidHoiOiJBZnJpY2EvQ2Fpcm8iLCJsYXQiOjMwLjA0LCJsb24iOjMxLjI0LCJub3ciOnsiYyI6MzAsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMCwibG8iOjE4LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI5LCJsbyI6MTksInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjksImxvIjoyMCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMCwibG8iOjE5LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjMxLCJsbyI6MjAsInNreSI6InBhcnRseSJ9XX0seyJjaXR5IjoiQ2FzYWJsYW5jYSIsImNjIjoiTUEiLCJ0eiI6IkFmcmljYS9DYXNhYmxhbmNhIiwibGF0IjozMy41NywibG9uIjotNy41OSwibm93Ijp7ImMiOjI5LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MzAsImxvIjoyMSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMCwibG8iOjIzLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI5LCJsbyI6MjIsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI1LCJsbyI6MjEsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjUsImxvIjoxOSwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJBbGdpZXJzIiwiY2MiOiJEWiIsInR6IjoiQWZyaWNhL0FsZ2llcnMiLCJsYXQiOjM2Ljc1LCJsb24iOjMuMDYsIm5vdyI6eyJjIjozMiwic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjMzLCJsbyI6MjUsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjgsImxvIjoyMywic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyOSwibG8iOjIyLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI4LCJsbyI6MjMsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjcsImxvIjoyMSwic2t5IjoicmFpbiJ9XX0seyJjaXR5IjoiVHVuaXMiLCJjYyI6IlROIiwidHoiOiJBZnJpY2EvVHVuaXMiLCJsYXQiOjM2LjgxLCJsb24iOjEwLjE4LCJub3ciOnsiYyI6MzAsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMCwibG8iOjIxLCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjMyLCJsbyI6MjIsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzMsImxvIjoyMiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMiwibG8iOjIxLCJza3kiOiJmb2cifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjMxLCJsbyI6MjIsInNreSI6InN0b3JtIn1dfSx7ImNpdHkiOiJEYWthciIsImNjIjoiU04iLCJ0eiI6IkFmcmljYS9EYWthciIsImxhdCI6MTQuNzIsImxvbiI6LTE3LjQ3LCJub3ciOnsiYyI6MjksInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyOSwibG8iOjIzLCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjksImxvIjoyNSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjksImxvIjoyNiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MzAsImxvIjoyNSwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjMwLCJsbyI6MjQsInNreSI6InJhaW4ifV19LHsiY2l0eSI6IkFiaWRqYW4iLCJjYyI6IkNJIiwidHoiOiJBZnJpY2EvQWJpZGphbiIsImxhdCI6NS4zNiwibG9uIjotNC4wMSwibm93Ijp7ImMiOjI5LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjksImxvIjoyNSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjksImxvIjoyNCwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjgsImxvIjoyNCwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI4LCJsbyI6MjMsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI5LCJsbyI6MjQsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IkFjY3JhIiwiY2MiOiJHSCIsInR6IjoiQWZyaWNhL0FjY3JhIiwibGF0Ijo1LjYsImxvbiI6LTAuMTksIm5vdyI6eyJjIjozMCwic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjMwLCJsbyI6MjUsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjMwLCJsbyI6MjQsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjMxLCJsbyI6MjQsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjMwLCJsbyI6MjQsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyOCwibG8iOjI0LCJza3kiOiJkcml6emxlIn1dfSx7ImNpdHkiOiJMYWdvcyIsImNjIjoiTkciLCJ0eiI6IkFmcmljYS9MYWdvcyIsImxhdCI6Ni41MiwibG9uIjozLjM4LCJub3ciOnsiYyI6MjgsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyOCwibG8iOjI1LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyOCwibG8iOjI1LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyOCwibG8iOjI0LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjgsImxvIjoyNSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjksImxvIjoyNSwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiS2hhcnRvdW0iLCJjYyI6IlNEIiwidHoiOiJBZnJpY2EvS2hhcnRvdW0iLCJsYXQiOjE1LjUsImxvbiI6MzIuNTYsIm5vdyI6eyJjIjo0Miwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6NDIsImxvIjozMSwic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjo0MSwibG8iOjMxLCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjQwLCJsbyI6MzAsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjo0MSwibG8iOjMwLCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjM4LCJsbyI6MjgsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IkFkZGlzIEFiYWJhIiwiY2MiOiJFVCIsInR6IjoiQWZyaWNhL0FkZGlzX0FiYWJhIiwibGF0Ijo5LjAzLCJsb24iOjM4Ljc0LCJub3ciOnsiYyI6MjMsInNreSI6ImNsZWFyIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjIzLCJsbyI6MTAsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjMsImxvIjoxMCwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjMsImxvIjoxMCwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjIsImxvIjo5LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjIyLCJsbyI6OCwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJLYW1wYWxhIiwiY2MiOiJVRyIsInR6IjoiQWZyaWNhL0thbXBhbGEiLCJsYXQiOjAuMzUsImxvbiI6MzIuNTgsIm5vdyI6eyJjIjoyNywic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjgsImxvIjoxOSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjgsImxvIjoxOSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjksImxvIjoxOSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjgsImxvIjoxOSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjcsImxvIjoxOSwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiTmFpcm9iaSIsImNjIjoiS0UiLCJ0eiI6IkFmcmljYS9OYWlyb2JpIiwibGF0IjotMS4yOSwibG9uIjozNi44Miwibm93Ijp7ImMiOjI3LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjcsImxvIjoxNiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjgsImxvIjoxNiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjgsImxvIjoxNiwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjUsImxvIjoxNiwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjQsImxvIjoxNiwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiS2luc2hhc2EiLCJjYyI6IkNEIiwidHoiOiJBZnJpY2EvS2luc2hhc2EiLCJsYXQiOi00LjQ0LCJsb24iOjE1LjI3LCJub3ciOnsiYyI6MzEsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMiwibG8iOjIzLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMiwibG8iOjIyLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjozMSwibG8iOjIyLCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMCwibG8iOjIyLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozMiwibG8iOjIyLCJza3kiOiJyYWluIn1dfSx7ImNpdHkiOiJEYXIgZXMgU2FsYWFtIiwiY2MiOiJUWiIsInR6IjoiQWZyaWNhL0Rhcl9lc19TYWxhYW0iLCJsYXQiOi02Ljc5LCJsb24iOjM5LjIxLCJub3ciOnsiYyI6MjksInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMCwibG8iOjI1LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMCwibG8iOjIzLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjozMCwibG8iOjI0LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMCwibG8iOjI0LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozMSwibG8iOjIzLCJza3kiOiJkcml6emxlIn1dfSx7ImNpdHkiOiJMdWFuZGEiLCJjYyI6IkFPIiwidHoiOiJBZnJpY2EvTHVhbmRhIiwibGF0IjotOC44NCwibG9uIjoxMy4yOSwibm93Ijp7ImMiOjI1LCJza3kiOiJwYXJ0bHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjUsImxvIjoyMiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjYsImxvIjoyMiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNiwibG8iOjIyLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI2LCJsbyI6MjIsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjYsImxvIjoyMiwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJIYXJhcmUiLCJjYyI6IlpXIiwidHoiOiJBZnJpY2EvSGFyYXJlIiwibGF0IjotMTcuODMsImxvbiI6MzEuMDMsIm5vdyI6eyJjIjoyMywic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjIzLCJsbyI6MTIsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjQsImxvIjoxMywic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjQsImxvIjoxNiwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI2LCJsbyI6MTUsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI2LCJsbyI6MTYsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IkFudGFuYW5hcml2byIsImNjIjoiTUciLCJ0eiI6IkluZGlhbi9BbnRhbmFuYXJpdm8iLCJsYXQiOi0xOC44OCwibG9uIjo0Ny41MSwibm93Ijp7ImMiOjI2LCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyNiwibG8iOjE1LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI2LCJsbyI6MTcsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI0LCJsbyI6MTUsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjYsImxvIjoxNiwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjUsImxvIjoxNCwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiSm9oYW5uZXNidXJnIiwiY2MiOiJaQSIsInR6IjoiQWZyaWNhL0pvaGFubmVzYnVyZyIsImxhdCI6LTI2LjIsImxvbiI6MjguMDUsIm5vdyI6eyJjIjoxOSwic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjE5LCJsbyI6MTAsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjE5LCJsbyI6MTEsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIxLCJsbyI6MTEsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI0LCJsbyI6MTMsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjQsImxvIjoxMywic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJDYXBlIFRvd24iLCJjYyI6IlpBIiwidHoiOiJBZnJpY2EvSm9oYW5uZXNidXJnIiwibGF0IjotMzMuOTIsImxvbiI6MTguNDIsIm5vdyI6eyJjIjoyOCwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjgsImxvIjoxMywic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNiwibG8iOjE1LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjE3LCJsbyI6MTIsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIwLCJsbyI6MTIsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTgsImxvIjoxMiwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJUb2t5byIsImNjIjoiSlAiLCJ0eiI6IkFzaWEvVG9reW8iLCJsYXQiOjM1LjY4LCJsb24iOjEzOS42OSwibm93Ijp7ImMiOjE5LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjMsImxvIjoxNiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMywibG8iOjE3LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI3LCJsbyI6MTksInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjUsImxvIjoxNywic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyMywibG8iOjE1LCJza3kiOiJkcml6emxlIn1dfSx7ImNpdHkiOiJTZW91bCIsImNjIjoiS1IiLCJ0eiI6IkFzaWEvU2VvdWwiLCJsYXQiOjM3LjU3LCJsb24iOjEyNi45OCwibm93Ijp7ImMiOjE5LCJza3kiOiJwYXJ0bHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjMsImxvIjoxNCwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MTksImxvIjoxNCwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjAsImxvIjoxMSwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIyLCJsbyI6MTIsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyNCwibG8iOjEyLCJza3kiOiJjbGVhciJ9XX0seyJjaXR5IjoiQmVpamluZyIsImNjIjoiQ04iLCJ0eiI6IkFzaWEvU2hhbmdoYWkiLCJsYXQiOjM5LjksImxvbiI6MTE2LjQxLCJub3ciOnsiYyI6MTcsInNreSI6ImNsZWFyIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjIxLCJsbyI6MTUsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjUsImxvIjoxMiwic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNSwibG8iOjE0LCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjcsImxvIjoxNCwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI3LCJsbyI6MTUsInNreSI6ImNsZWFyIn1dfSx7ImNpdHkiOiJTaGFuZ2hhaSIsImNjIjoiQ04iLCJ0eiI6IkFzaWEvU2hhbmdoYWkiLCJsYXQiOjMxLjIzLCJsb24iOjEyMS40Nywibm93Ijp7ImMiOjIwLCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjEsImxvIjoxOSwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjIsImxvIjoxNiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyMiwibG8iOjEzLCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjUsImxvIjoxMywic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI1LCJsbyI6MTUsInNreSI6ImNsb3VkeSJ9XX0seyJjaXR5IjoiSG9uZyBLb25nIiwiY2MiOiJISyIsInR6IjoiQXNpYS9Ib25nX0tvbmciLCJsYXQiOjIyLjMyLCJsb24iOjExNC4xNywibm93Ijp7ImMiOjI0LCJza3kiOiJkcml6emxlIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjMwLCJsbyI6MjQsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMCwibG8iOjIyLCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI2LCJsbyI6MjAsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjgsImxvIjoyMCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyOCwibG8iOjIzLCJza3kiOiJkcml6emxlIn1dfSx7ImNpdHkiOiJUYWlwZWkiLCJjYyI6IlRXIiwidHoiOiJBc2lhL1RhaXBlaSIsImxhdCI6MjUuMDMsImxvbiI6MTIxLjU3LCJub3ciOnsiYyI6MjYsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMywibG8iOjI0LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyOCwibG8iOjIzLCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjUsImxvIjoyMiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjYsImxvIjoyMSwic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyOCwibG8iOjIxLCJza3kiOiJwYXJ0bHkifV19LHsiY2l0eSI6IlVsYWFuYmFhdGFyIiwiY2MiOiJNTiIsInR6IjoiQXNpYS9VbGFhbmJhYXRhciIsImxhdCI6NDcuODksImxvbiI6MTA2LjkxLCJub3ciOnsiYyI6Mywic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6OSwibG8iOi0yLCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MTMsImxvIjoxLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjE4LCJsbyI6Miwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoxMiwibG8iOjMsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTcsImxvIjoyLCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6Ik1hbmlsYSIsImNjIjoiUEgiLCJ0eiI6IkFzaWEvTWFuaWxhIiwibGF0IjoxNC42LCJsb24iOjEyMC45OCwibm93Ijp7ImMiOjI3LCJza3kiOiJkcml6emxlIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjMzLCJsbyI6MjcsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMiwibG8iOjI2LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzIsImxvIjoyNSwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjMxLCJsbyI6MjUsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI5LCJsbyI6MjQsInNreSI6InN0b3JtIn1dfSx7ImNpdHkiOiJIYW5vaSIsImNjIjoiVk4iLCJ0eiI6IkFzaWEvSG9fQ2hpX01pbmgiLCJsYXQiOjIxLjAzLCJsb24iOjEwNS44Mywibm93Ijp7ImMiOjI2LCJza3kiOiJyYWluIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjMxLCJsbyI6MjQsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNSwibG8iOjIyLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI4LCJsbyI6MjIsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjksImxvIjoyMCwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI5LCJsbyI6MjAsInNreSI6ImNsb3VkeSJ9XX0seyJjaXR5IjoiSG8gQ2hpIE1pbmggQ2l0eSIsImNjIjoiVk4iLCJ0eiI6IkFzaWEvSG9fQ2hpX01pbmgiLCJsYXQiOjEwLjgyLCJsb24iOjEwNi42Mywibm93Ijp7ImMiOjI2LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MzIsImxvIjoyNSwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjMyLCJsbyI6MjUsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjozMSwibG8iOjI1LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MzAsImxvIjoyNCwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjMxLCJsbyI6MjQsInNreSI6InN0b3JtIn1dfSx7ImNpdHkiOiJCYW5na29rIiwiY2MiOiJUSCIsInR6IjoiQXNpYS9CYW5na29rIiwibGF0IjoxMy43NiwibG9uIjoxMDAuNSwibm93Ijp7ImMiOjI5LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MzYsImxvIjoyNiwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjMzLCJsbyI6MjYsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjMxLCJsbyI6MjQsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMSwibG8iOjI0LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MzEsImxvIjoyNCwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiS3VhbGEgTHVtcHVyIiwiY2MiOiJNWSIsInR6IjoiQXNpYS9LdWFsYV9MdW1wdXIiLCJsYXQiOjMuMTQsImxvbiI6MTAxLjY5LCJub3ciOnsiYyI6MjgsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMiwibG8iOjI0LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MzEsImxvIjoyNCwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzIsImxvIjoyMywic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MzIsImxvIjoyNCwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MzIsImxvIjoyNCwic2t5IjoicmFpbiJ9XX0seyJjaXR5IjoiU2luZ2Fwb3JlIiwiY2MiOiJTRyIsInR6IjoiQXNpYS9TaW5nYXBvcmUiLCJsYXQiOjEuMzUsImxvbiI6MTAzLjgyLCJub3ciOnsiYyI6MjYsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMiwibG8iOjI0LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMSwibG8iOjI0LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzIsImxvIjoyMywic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MzIsImxvIjoyMiwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MzIsImxvIjoyMywic2t5IjoicmFpbiJ9XX0seyJjaXR5IjoiSmFrYXJ0YSIsImNjIjoiSUQiLCJ0eiI6IkFzaWEvSmFrYXJ0YSIsImxhdCI6LTYuMjEsImxvbiI6MTA2Ljg1LCJub3ciOnsiYyI6MjUsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozNSwibG8iOjI1LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMiwibG8iOjI1LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjozMywibG8iOjI2LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMiwibG8iOjI0LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozMywibG8iOjI1LCJza3kiOiJkcml6emxlIn1dfSx7ImNpdHkiOiJEaGFrYSIsImNjIjoiQkQiLCJ0eiI6IkFzaWEvRGhha2EiLCJsYXQiOjIzLjgxLCJsb24iOjkwLjQxLCJub3ciOnsiYyI6MzIsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozNSwibG8iOjI4LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozNCwibG8iOjI3LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjozMywibG8iOjI2LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMywibG8iOjI0LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozMCwibG8iOjI0LCJza3kiOiJyYWluIn1dfSx7ImNpdHkiOiJLb2xrYXRhIiwiY2MiOiJJTiIsInR6IjoiQXNpYS9Lb2xrYXRhIiwibGF0IjoyMi41NywibG9uIjo4OC4zNiwibm93Ijp7ImMiOjMwLCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMywibG8iOjI3LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozNCwibG8iOjI2LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjozNCwibG8iOjI2LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMiwibG8iOjI1LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjksImxvIjoyNCwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiRGVsaGkiLCJjYyI6IklOIiwidHoiOiJBc2lhL0tvbGthdGEiLCJsYXQiOjI4LjYxLCJsb24iOjc3LjIxLCJub3ciOnsiYyI6MzIsInNreSI6ImNsZWFyIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjM0LCJsbyI6MjUsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozNSwibG8iOjI1LCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzUsImxvIjoyNCwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjMzLCJsbyI6MjMsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MzMsImxvIjoyNCwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiTXVtYmFpIiwiY2MiOiJJTiIsInR6IjoiQXNpYS9Lb2xrYXRhIiwibGF0IjoxOS4wOCwibG9uIjo3Mi44OCwibm93Ijp7ImMiOjMyLCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozNSwibG8iOjI2LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMywibG8iOjI1LCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzMsImxvIjoyNCwic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozNSwibG8iOjI1LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozNiwibG8iOjI2LCJza3kiOiJkcml6emxlIn1dfSx7ImNpdHkiOiJCZW5nYWx1cnUiLCJjYyI6IklOIiwidHoiOiJBc2lhL0tvbGthdGEiLCJsYXQiOjEyLjk3LCJsb24iOjc3LjU5LCJub3ciOnsiYyI6MjYsInNreSI6InN0b3JtIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjMwLCJsbyI6MjEsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI4LCJsbyI6MjAsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI4LCJsbyI6MjAsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoyOCwibG8iOjIwLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyOCwibG8iOjIwLCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6IkNoZW5uYWkiLCJjYyI6IklOIiwidHoiOiJBc2lhL0tvbGthdGEiLCJsYXQiOjEzLjA4LCJsb24iOjgwLjI3LCJub3ciOnsiYyI6MzAsInNreSI6ImNsZWFyIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjM0LCJsbyI6MjgsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MzQsImxvIjoyNywic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjMzLCJsbyI6MjYsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjMxLCJsbyI6MjYsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjMyLCJsbyI6MjYsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IkNvbG9tYm8iLCJjYyI6IkxLIiwidHoiOiJBc2lhL0NvbG9tYm8iLCJsYXQiOjYuOTMsImxvbiI6NzkuODYsIm5vdyI6eyJjIjoyNywic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjMxLCJsbyI6MjUsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI5LCJsbyI6MjUsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjMwLCJsbyI6MjQsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjMwLCJsbyI6MjQsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozMCwibG8iOjI0LCJza3kiOiJyYWluIn1dfSx7ImNpdHkiOiJLYXRobWFuZHUiLCJjYyI6Ik5QIiwidHoiOiJBc2lhL0thdGhtYW5kdSIsImxhdCI6MjcuNzIsImxvbiI6ODUuMzIsIm5vdyI6eyJjIjoyMCwic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI0LCJsbyI6MTcsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNCwibG8iOjE2LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjQsImxvIjoxNSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjMsImxvIjoxNSwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI0LCJsbyI6MTQsInNreSI6InN0b3JtIn1dfSx7ImNpdHkiOiJLYXJhY2hpIiwiY2MiOiJQSyIsInR6IjoiQXNpYS9LYXJhY2hpIiwibGF0IjoyNC44NiwibG9uIjo2Ny4wMSwibm93Ijp7ImMiOjI5LCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMSwibG8iOjI3LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjMwLCJsbyI6MjcsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjMwLCJsbyI6MjcsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjMwLCJsbyI6MjYsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozMSwibG8iOjI1LCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6IlRhc2hrZW50IiwiY2MiOiJVWiIsInR6IjoiQXNpYS9UYXNoa2VudCIsImxhdCI6NDEuMywibG9uIjo2OS4yNCwibm93Ijp7ImMiOjIwLCJza3kiOiJkcml6emxlIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI1LCJsbyI6MTMsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjE3LCJsbyI6MTAsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjE5LCJsbyI6OCwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIxLCJsbyI6OSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyNCwibG8iOjExLCJza3kiOiJwYXJ0bHkifV19LHsiY2l0eSI6IkFsbWF0eSIsImNjIjoiS1oiLCJ0eiI6IkFzaWEvQWxtYXR5IiwibGF0Ijo0My4yMiwibG9uIjo3Ni44NSwibm93Ijp7ImMiOjIzLCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjcsImxvIjoxNCwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MTcsImxvIjoxMCwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MTMsImxvIjo5LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjE2LCJsbyI6OCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxOCwibG8iOjksInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IlRlaHJhbiIsImNjIjoiSVIiLCJ0eiI6IkFzaWEvVGVocmFuIiwibGF0IjozNS42OSwibG9uIjo1MS4zOSwibm93Ijp7ImMiOjI2LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjcsImxvIjoxNCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNiwibG8iOjEzLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI2LCJsbyI6MTQsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjgsImxvIjoxNywic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyOSwibG8iOjE1LCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6IkJhZ2hkYWQiLCJjYyI6IklRIiwidHoiOiJBc2lhL0JhZ2hkYWQiLCJsYXQiOjMzLjMyLCJsb24iOjQ0LjM3LCJub3ciOnsiYyI6MzMsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMywibG8iOjIzLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjM0LCJsbyI6MjQsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzcsImxvIjoyNiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMCwibG8iOjI2LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjM1LCJsbyI6MjUsInNreSI6ImNsb3VkeSJ9XX0seyJjaXR5IjoiUml5YWRoIiwiY2MiOiJTQSIsInR6IjoiQXNpYS9SaXlhZGgiLCJsYXQiOjI0LjcxLCJsb24iOjQ2LjY4LCJub3ciOnsiYyI6MzgsInNreSI6ImNsZWFyIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjM4LCJsbyI6MjksInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozOCwibG8iOjI4LCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzgsImxvIjoyOCwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjM5LCJsbyI6MjcsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozOSwibG8iOjI4LCJza3kiOiJjbGVhciJ9XX0seyJjaXR5IjoiRG9oYSIsImNjIjoiUUEiLCJ0eiI6IkFzaWEvUWF0YXIiLCJsYXQiOjI1LjI5LCJsb24iOjUxLjUzLCJub3ciOnsiYyI6MzYsInNreSI6ImNsZWFyIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjM5LCJsbyI6MzAsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjo0MSwibG8iOjI5LCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6NDAsImxvIjozMSwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjM2LCJsbyI6MjksInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozOCwibG8iOjI2LCJza3kiOiJjbGVhciJ9XX0seyJjaXR5IjoiRHViYWkiLCJjYyI6IkFFIiwidHoiOiJBc2lhL0R1YmFpIiwibGF0IjoyNS4yLCJsb24iOjU1LjI3LCJub3ciOnsiYyI6MzQsInNreSI6ImNsZWFyIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjM2LCJsbyI6MzAsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozNSwibG8iOjI5LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjM3LCJsbyI6MjksInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6NDAsImxvIjoyNywic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozOCwibG8iOjI5LCJza3kiOiJjbGVhciJ9XX0seyJjaXR5IjoiVGVsIEF2aXYiLCJjYyI6IklMIiwidHoiOiJBc2lhL0plcnVzYWxlbSIsImxhdCI6MzIuMDksImxvbiI6MzQuNzgsIm5vdyI6eyJjIjoyOSwic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI5LCJsbyI6MjAsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjcsImxvIjoyMSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNiwibG8iOjIxLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI2LCJsbyI6MTksInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjgsImxvIjoyMCwic2t5IjoicGFydGx5In1dfSx7ImNpdHkiOiJCZWlydXQiLCJjYyI6IkxCIiwidHoiOiJBc2lhL0JlaXJ1dCIsImxhdCI6MzMuODksImxvbiI6MzUuNSwibm93Ijp7ImMiOjI3LCJza3kiOiJwYXJ0bHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjgsImxvIjoyMywic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNSwibG8iOjIyLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI1LCJsbyI6MjEsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjUsImxvIjoyMSwic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyNywibG8iOjIxLCJza3kiOiJwYXJ0bHkifV19LHsiY2l0eSI6IlZsYWRpdm9zdG9rIiwiY2MiOiJSVSIsInR6IjoiQXNpYS9WbGFkaXZvc3RvayIsImxhdCI6NDMuMTIsImxvbiI6MTMxLjg5LCJub3ciOnsiYyI6MTMsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoxNSwibG8iOjEyLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoxNiwibG8iOjExLCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoxNywibG8iOjEwLCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjE4LCJsbyI6MTEsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTgsImxvIjoxMiwic2t5IjoicGFydGx5In1dfSx7ImNpdHkiOiJTeWRuZXkiLCJjYyI6IkFVIiwidHoiOiJBdXN0cmFsaWEvU3lkbmV5IiwibGF0IjotMzMuODcsImxvbiI6MTUxLjIxLCJub3ciOnsiYyI6MTYsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyNCwibG8iOjE0LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMCwibG8iOjE0LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNywibG8iOjE1LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoxNywibG8iOjE1LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxOCwibG8iOjEzLCJza3kiOiJkcml6emxlIn1dfSx7ImNpdHkiOiJNZWxib3VybmUiLCJjYyI6IkFVIiwidHoiOiJBdXN0cmFsaWEvTWVsYm91cm5lIiwibGF0IjotMzcuODEsImxvbiI6MTQ0Ljk2LCJub3ciOnsiYyI6MTEsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoxNSwibG8iOjExLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjE4LCJsbyI6OSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoxOCwibG8iOjEwLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoxNSwibG8iOjEwLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjIyLCJsbyI6Nywic2t5IjoicGFydGx5In1dfSx7ImNpdHkiOiJCcmlzYmFuZSIsImNjIjoiQVUiLCJ0eiI6IkF1c3RyYWxpYS9CcmlzYmFuZSIsImxhdCI6LTI3LjQ3LCJsb24iOjE1My4wMywibm93Ijp7ImMiOjIwLCJza3kiOiJwYXJ0bHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjYsImxvIjoxNSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyOCwibG8iOjE3LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI5LCJsbyI6MTgsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI2LCJsbyI6MTgsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyMSwibG8iOjE2LCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6IlBlcnRoIiwiY2MiOiJBVSIsInR6IjoiQXVzdHJhbGlhL1BlcnRoIiwibGF0IjotMzEuOTUsImxvbiI6MTE1Ljg2LCJub3ciOnsiYyI6MTYsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoxOSwibG8iOjEyLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjIwLCJsbyI6MTIsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjMsImxvIjoxMiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoyMCwibG8iOjE1LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxOCwibG8iOjEyLCJza3kiOiJyYWluIn1dfSx7ImNpdHkiOiJBdWNrbGFuZCIsImNjIjoiTloiLCJ0eiI6IlBhY2lmaWMvQXVja2xhbmQiLCJsYXQiOi0zNi44NSwibG9uIjoxNzQuNzYsIm5vdyI6eyJjIjoxMSwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDUiLCJoaSI6MTUsImxvIjo5LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoxNywibG8iOjExLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoyMCwibG8iOjEwLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxOSwibG8iOjEyLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA5IiwiaGkiOjE5LCJsbyI6MTQsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IldlbGxpbmd0b24iLCJjYyI6Ik5aIiwidHoiOiJQYWNpZmljL0F1Y2tsYW5kIiwibGF0IjotNDEuMjksImxvbiI6MTc0Ljc4LCJub3ciOnsiYyI6OCwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDUiLCJoaSI6MTMsImxvIjo3LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjE0LCJsbyI6MTEsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MTYsImxvIjoxMiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxMywibG8iOjExLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOSIsImhpIjoxMywibG8iOjExLCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6IlBvcnQgTW9yZXNieSIsImNjIjoiUEciLCJ0eiI6IlBhY2lmaWMvUG9ydF9Nb3Jlc2J5IiwibGF0IjotOS40NCwibG9uIjoxNDcuMTgsIm5vdyI6eyJjIjoyNCwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjgsImxvIjoyMiwic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNywibG8iOjIxLCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI3LCJsbyI6MjEsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoyNywibG8iOjIxLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI3LCJsbyI6MjIsInNreSI6ImNsb3VkeSJ9XX0seyJjaXR5IjoiU3V2YSIsImNjIjoiRkoiLCJ0eiI6IlBhY2lmaWMvRmlqaSIsImxhdCI6LTE4LjEyLCJsb24iOjE3OC40NSwibm93Ijp7ImMiOjIxLCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjYsImxvIjoyMCwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjcsImxvIjoyMSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjcsImxvIjoyMiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjcsImxvIjoyMiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDkiLCJoaSI6MjYsImxvIjoyMSwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiTm91bcOpYSIsImNjIjoiTkMiLCJ0eiI6IlBhY2lmaWMvTm91bWVhIiwibGF0IjotMjIuMjgsImxvbiI6MTY2LjQ2LCJub3ciOnsiYyI6MTksInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyNCwibG8iOjE3LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNCwibG8iOjE4LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNCwibG8iOjE5LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI1LCJsbyI6MTgsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjQsImxvIjoxOCwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiTWNNdXJkbyBTdGF0aW9uIiwiY2MiOiJBUSIsInR6IjoiQW50YXJjdGljYS9NY011cmRvIiwibGF0IjotNzcuODUsImxvbiI6MTY2LjY4LCJub3ciOnsiYyI6LTIxLCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDUiLCJoaSI6LTEzLCJsbyI6LTIxLCJza3kiOiJzbm93In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjotNiwibG8iOi0xOCwic2t5Ijoic25vdyJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6LTgsImxvIjotMTksInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6LTE3LCJsbyI6LTIzLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA5IiwiaGkiOi0xOSwibG8iOi0yNCwic2t5IjoiY2xvdWR5In1dfV0sInNpdGUiOnsidCI6IkNoaWxkcmVuc0Jvb2tTaG9wLmNvbSIsInUiOiJodHRwOi8vd3d3LmNoaWxkcmVuc2Jvb2tzaG9wLmNvbS8iLCJkIjoiVXNlZCBhbmQgc2Vjb25kaGFuZCBjaGlsZHJlbidzIGJvb2tzaG9wIG5lYXIgSGF5IG9uIFd5ZSwgd2l0aCBhIHNlYXJjaGFibGUgb25saW5lIGNhdGFsb2d1ZSBhbmQgd29ybGR3aWRlIG9yZGVyaW5nLiJ9LCJub3RlIjoiRHJhZnRlZCBmcm9tIHRoaXMgc2l0ZeKAmXMgb3duIHBhZ2VzIHdpdGggc29mdHdhcmUsIHRoZW4gcmV3cml0dGVuIGFuZCBjaGVja2VkLiBOb3QgdGhlIHdvcmRzIG9mIHdob2V2ZXIgcnVucyBpdC4iLCJub3RlcyI6WyJIZWFkbGluZXMgbGluayB0byBGbGFtZU5ldCBOZXdzLCB3aGljaCBsaW5rcyB0byBlYWNoIHB1Ymxpc2hlciIsIk1hcmtldCBwcmljZXMgYXJlIGRlbGF5ZWQgYW5kIGZvciBpbmZvcm1hdGlvbiBvbmx5IiwiV2VhdGhlciBkYXRhIGJ5IE9wZW4tTWV0ZW8uY29tIl19","sig":"asyh/AzjogJ1IBxpWQAOTxRfOoFYAF3Y67UEIlIGx0rJMjUM8X5Ume0InYqjuthd+76pTgkRxIWIhzJcEEGsCg=="} \ No newline at end of file diff --git a/engine/src/main/kotlin/io/flamenet/e2e/BackupPayload.kt b/engine/src/main/kotlin/io/flamenet/e2e/BackupPayload.kt index f70d9b3..b8eccbc 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/BackupPayload.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/BackupPayload.kt @@ -47,10 +47,35 @@ object BackupPayload { private const val GROUP_PREFIX = "group:" private const val ADDRESS_PREFIX = "addr:" + private const val USER_PREFIX = "user:" /** Federated threads, which the iPhone has nowhere to put. */ private const val ADDRESS_THREADS = "addrThreads" + /** + * History-row keys left out of the shared shape on purpose, each with its + * reason. Every other key a row can carry is translated below, and the + * app's `HistoryRowsCarryEveryFieldTest` fails on a row key that is in + * neither place. + */ + val NOT_CARRIED: Map<String, String> = + mapOf( + "rxp" to + "Reactions sent to the author alone (§6.9). §16.2.1 lists no key for them; " + + "the iPhone writes reactionsPrivate on group messages, and carrying that is " + + "its own change, not part of cards and events.", + ChatEvents.ROW_PROVISIONAL to + "Bookkeeping for an event whose rev 1 has not arrived yet. A restored event " + + "counts its organizer as settled, which refuses a takeover rather than " + + "allowing one.", + "gameOutOfStep" to + "A game whose two boards stopped agreeing. A restored game is replayed from " + + "its log, and the next move shows again whether the boards agree.", + "gameOffer" to + "A draw offer not yet answered. §6.33's backup shape does not keep one, so " + + "a restored game has no offer open and either side can make it again.", + ) + // -- Writing -------------------------------------------------------------. /** Turn [internal], as [Vault.exportPayload] produces it, into the shared shape. */ @@ -82,8 +107,10 @@ object BackupPayload { ) } + // A one-to-one thread is keyed by the user id alone, as the + // iPhone keys it; `user:` is only how this app names it. else -> { - threads.put(tid, mapArray(messages, ::messageToShared)) + threads.put(tid.removePrefix(USER_PREFIX), mapArray(messages, ::messageToShared)) } } } @@ -239,9 +266,30 @@ object BackupPayload { m.optJSONObject("stk")?.let { out.put("stk", it) } m.optJSONObject("lnk")?.let { out.put("lnk", it) } m.optString("delivery").takeIf { it.isNotEmpty() }?.let { out.put("delivery", it.lowercase()) } + carryCardsAndEvents(m, out) return out } + /** + * §6.31, §6.32 and §6.33, the same in both directions: a card is its wire + * object as it travelled, an event is the wire `ev` of the latest accepted + * revision with its organizer and the answers so far, and a game is + * `{"id","t","me","log","r","rby"}` on its start message, `rby` the side + * that sent `r`. All under the names the wire and the iPhone use. + */ + private fun carryCardsAndEvents( + from: JSONObject, + to: JSONObject, + ) { + from.optJSONObject("card")?.let { to.put("card", it) } + from.optJSONObject("game")?.let { to.put("game", it) } + from.optJSONObject(ChatEvents.ROW_EVENT)?.let { to.put(ChatEvents.ROW_EVENT, it) } + WireJson.long(from, ChatEvents.ROW_ORGANIZER)?.let { to.put(ChatEvents.ROW_ORGANIZER, it) } + from.optJSONObject(ChatEvents.ROW_ANSWERS)?.takeIf { it.length() > 0 }?.let { + to.put(ChatEvents.ROW_ANSWERS, it) + } + } + /** * The kind lives beside `att` here and inside it over there, and the sizes * and duration are spelled out rather than abbreviated. @@ -303,6 +351,9 @@ object BackupPayload { s.optString("key").ifEmpty { s.optString("id") }.ifEmpty { s.optLong("at").toString() }, ) s.optJSONObject("set")?.let { out.put("set", it) } + // §6.15 for stories: when its words changed, in milliseconds, only when + // they did. Who it was sent to stays on the phone (DESIGN item 26). + s.optLong("edited", 0L).takeIf { it > 0L }?.let { out.put("edited", it) } // Whether the strip has been watched. It is the difference between a // strip remembering what you watched and one showing everything as // new, so a phone that holds the mark without writing it relights @@ -326,7 +377,10 @@ object BackupPayload { val threads = JSONObject() shared.optJSONObject("threads")?.let { all -> for (tid in all.keys()) { - all.optJSONArray(tid)?.let { threads.put(tid, mapArray(it, ::messageFromShared)) } + // Back under the name this app reads. A file from before the + // prefix came off carries it already. + val internalId = if (tid.isNotEmpty() && tid.all(Char::isDigit)) USER_PREFIX + tid else tid + all.optJSONArray(tid)?.let { threads.put(internalId, mapArray(it, ::messageFromShared)) } } } shared.optJSONObject("groups")?.let { all -> @@ -464,6 +518,7 @@ object BackupPayload { m.optJSONObject("lnk")?.let { out.put("lnk", it) } // Stored upper case here, lower case on the wire and in the file. m.optString("delivery").takeIf { it.isNotEmpty() }?.let { out.put("delivery", it.uppercase()) } + carryCardsAndEvents(m, out) return out } @@ -521,6 +576,7 @@ object BackupPayload { s.optString("gid").takeIf { it.isNotEmpty() }?.let { out.put("gid", it) } s.optString("stream").takeIf { it.isNotEmpty() }?.let { out.put("stream", it) } s.optJSONObject("set")?.let { out.put("set", it) } + s.optLong("edited", 0L).takeIf { it > 0L }?.let { out.put("edited", it) } return out } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/ChatEvents.kt b/engine/src/main/kotlin/io/flamenet/e2e/ChatEvents.kt new file mode 100644 index 0000000..1289dfc --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/ChatEvents.kt @@ -0,0 +1,200 @@ +package io.flamenet.e2e + +import org.json.JSONObject + +/** + * Events and the answers to them (§6.32), as rules a conversation applies. + * + * An event is one card in a conversation that keeps changing after it + * arrives: the organizer revises or cancels it and every member answers it. + * Each of those is its own envelope, they arrive in any order, and any member + * can send one claiming anything, so the rules live here, apart from storage + * and screens, where they can be tested on their own. + */ +object ChatEvents { + /** Where one event stands on this device. */ + data class State( + /** The latest revision accepted. */ + val event: Content.Event, + /** Whoever sent rev 1, by ratchet identity. Only they may revise it. */ + val organizer: Long, + /** + * True while rev 1 has not arrived and [organizer] is only the first + * sender seen. A later rev 1 from somebody else replaces them. + */ + val organizerProvisional: Boolean = false, + /** Each member's answer, by user id. */ + val answers: Map<Long, Answer> = emptyMap(), + ) { + /** Who gave [answer], in id order so every device lists them alike. */ + fun who(answer: String): List<Long> = answers.filterValues { it.r == answer }.keys.sorted() + + /** + * The count for [me]'s next answer: the unix second it is given, or + * one past an answer already sent at or after that second (§6.32). + */ + fun nextNumber( + me: Long, + nowSeconds: Long, + ): Long = maxOf(nowSeconds, (answers[me]?.n ?: 0L) + 1) + } + + /** One member's answer, and the count that orders it against their others. */ + data class Answer( + val r: String, + val n: Long, + ) + + enum class Change { + /** Not seen here before: a new card. */ + NEW, + + /** The organizer changed or cancelled it, and the card shows that now. */ + REVISED, + + /** + * Rev 1 arrived late. From the organizer already assumed, nothing + * shows; from somebody else, they take over with its content and the + * card is redrawn. Never news either way (DESIGN 36). + */ + SETTLED, + + /** An older or equal revision, or a sender who is not the organizer. */ + IGNORED, + } + + data class Folded( + val change: Change, + /** The state to keep, or null when nothing was known and nothing taken. */ + val state: State?, + /** What a revision replaced, so a notice can name what the reader knew. */ + val before: Content.Event? = null, + ) + + /** + * Fold one event envelope from [sender] into what is known of it. + * + * A higher revision from the organizer replaces the whole event; anything + * else is ignored. The organizer is whoever sent rev 1. Until rev 1 has + * been seen the first sender stands in for them, and a rev 1 from somebody + * else then takes over along with its content, because what the stand-in + * sent was never theirs to send. That takeover is settled, not revised: + * it never notifies (DESIGN 36). + */ + fun fold( + current: State?, + sender: Long, + incoming: Content.Event, + ): Folded { + if (current == null) { + return Folded(Change.NEW, State(incoming, sender, organizerProvisional = incoming.rev > 1)) + } + require(current.event.id == incoming.id) { "folding one event into another" } + if (current.organizerProvisional && incoming.rev == 1) { + if (sender == current.organizer) { + return Folded(Change.SETTLED, current.copy(organizerProvisional = false)) + } + return Folded( + Change.SETTLED, + current.copy(event = incoming, organizer = sender, organizerProvisional = false), + ) + } + if (sender != current.organizer || incoming.rev <= current.event.rev) { + return Folded(Change.IGNORED, current) + } + return Folded(Change.REVISED, current.copy(event = incoming), before = current.event) + } + + /** + * Apply one answer from [sender]. The highest count from each sender + * wins, so an older answer arriving late changes nothing. Null when the + * answer changes nothing. + */ + fun answer( + current: State, + sender: Long, + rsvp: Content.Rsvp, + ): State? { + if (rsvp.eventId != current.event.id || rsvp.answer !in Content.Rsvp.ANSWERS) return null + val held = current.answers[sender] + if (held != null && !beats(rsvp.n, rsvp.answer, held)) return null + return current.copy(answers = current.answers + (sender to Answer(rsvp.answer, rsvp.n))) + } + + /** + * Whether an answer counted [n] replaces [held]. A higher count wins; on + * an equal count the answer that sorts higher wins (`yes` over `no` over + * `maybe`), so every device lands on the same answer whatever order the + * two arrived in. That happens when one person answers on two of their + * own devices in the same second. + */ + fun beats( + n: Long, + r: String, + held: Answer, + ): Boolean = n > held.n || (n == held.n && r > held.r) + + // -- On a history row ------------------------------------------------------. + + /** The event's wire object, as the latest accepted revision. */ + const val ROW_EVENT = "ev" + + /** The organizer's user id. */ + const val ROW_ORGANIZER = "org" + + /** `{"<user id>": {"r": "yes", "n": 3}}`, the backup shape as well. */ + const val ROW_ANSWERS = "rsvp" + + /** Present, and true, only while the organizer is provisional. */ + const val ROW_PROVISIONAL = "orgp" + + /** Write [state] onto a history row, leaving its other keys alone. */ + fun toRow( + state: State, + row: JSONObject, + ): JSONObject { + row.put(ROW_EVENT, JSONObject(state.event.toJson())) + row.put(ROW_ORGANIZER, state.organizer) + if (state.answers.isEmpty()) row.remove(ROW_ANSWERS) else row.put(ROW_ANSWERS, answersJson(state.answers)) + if (state.organizerProvisional) row.put(ROW_PROVISIONAL, true) else row.remove(ROW_PROVISIONAL) + return row + } + + /** + * Read an event back off a history row, or null when the row holds none + * or holds one that cannot be drawn. + * + * A row with no organizer reads as organizer 0, which nobody is, so no + * revision of it is ever accepted. Refusing to change something whose + * owner is unknown is the narrow reading. + */ + fun fromRow(row: JSONObject): State? { + val event = row.optJSONObject(ROW_EVENT)?.let(Content.Event::fromJson) ?: return null + return State( + event = event, + organizer = WireJson.long(row, ROW_ORGANIZER) ?: 0L, + organizerProvisional = WireJson.flag(row, ROW_PROVISIONAL), + answers = answersFrom(row.optJSONObject(ROW_ANSWERS)), + ) + } + + /** Ids are text as object keys and integers as values (§16.2.1). */ + private fun answersJson(answers: Map<Long, Answer>): JSONObject { + val out = JSONObject() + answers.forEach { (who, a) -> out.put(who.toString(), JSONObject().put("r", a.r).put("n", a.n)) } + return out + } + + private fun answersFrom(o: JSONObject?): Map<Long, Answer> { + if (o == null) return emptyMap() + val out = mutableMapOf<Long, Answer>() + for (key in o.keys()) { + val who = key.toLongOrNull() ?: continue + val a = o.optJSONObject(key) ?: continue + val r = WireJson.string(a, "r")?.takeIf { it in Content.Rsvp.ANSWERS } ?: continue + val n = WireJson.long(a, "n") ?: continue + out[who] = Answer(r, n) + } + return out + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/ContactCard.kt b/engine/src/main/kotlin/io/flamenet/e2e/ContactCard.kt index 48a8f6b..8b6f89c 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/ContactCard.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/ContactCard.kt @@ -53,7 +53,11 @@ data class ContactCard( val at = s.indexOf('@') if (at < 0) return null val fnid = s.substring(0, at).trim() - val relay = s.substring(at + 1).trim().lowercase() + val typed = s.substring(at + 1).trim() + // A relay's name is ASCII, checked before lowercasing, which turns + // a Kelvin sign into an ASCII k (DESIGN 41). + if (typed.any { it.code >= 0x80 }) return null + val relay = typed.lowercase() if (fnid.isEmpty() || relay.isEmpty() || '.' !in relay) return null // A second `@` means this is not one address. if ('@' in relay) return null diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index cc76f8f..1ebcc9b 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -59,6 +59,13 @@ data class Content( * the `mid` of the message being edited. */ val editOf: String? = null, + /** + * The edit is of a STORY's caption, and [editOf] is the story id (§6.15 + * for stories, DESIGN item 26), never a message in a thread. + */ + val editsStory: Boolean = false, + /** Set iff this takes a story, or a whole set, down for everyone (§6.35). */ + val unpost: Unpost? = null, /** * Set iff this envelope pins or unpins a message (§6.19): the id being * pinned, or the empty string to take the pin down. @@ -78,7 +85,350 @@ data class Content( * arriving as a wrong tally. */ val pollMemberSet: String? = null, + /** Set iff this is a greeting card (§6.31). */ + val card: Card? = null, + /** Set iff this is an event, or a revision or cancel of one (§6.32). */ + val event: Event? = null, + /** Set iff this is an answer to an event (§6.32). Never a bubble. */ + val rsvp: Rsvp? = null, + /** Set iff this is a game, a move in one, or its end or a draw offer (§6.33). */ + val game: Game? = null, ) { + /** + * A game in a one-to-one chat (§6.33): which game, which side the SENDER + * of this envelope plays, and on a move or an ending the whole log so + * far. Positions never travel; each side replays the log. + */ + data class Game( + /** Stable for the life of the game. */ + val id: String, + /** `chess`, `checkers`, `reversi` or `tictactoe`. */ + val type: String, + /** The side the sender of this envelope plays, `first` or `second`. */ + val mine: String, + /** Every ply so far, in order. Absent on the start. */ + val log: List<String>? = null, + /** On a `gend`: one of [ENDINGS]. */ + val ending: String? = null, + ) { + /** The game object, keys in the order §6.33 gives: id, t, mine, log, r. */ + fun toJson(): String { + val parts = + mutableListOf( + "\"id\":${WireJson.quote(id)}", + "\"t\":${WireJson.quote(type)}", + "\"mine\":${WireJson.quote(mine)}", + ) + log?.let { moves -> parts += "\"log\":" + moves.joinToString(",", "[", "]") { WireJson.quote(it) } } + ending?.let { parts += "\"r\":${WireJson.quote(it)}" } + return parts.joinToString(",", "{", "}") + } + + companion object { + val TYPES = listOf("chess", "checkers", "reversi", "tictactoe") + const val FIRST = "first" + const val SECOND = "second" + val ENDINGS = setOf("resign", "draw-offer", "draw-accept", "draw-decline", "decline", "cancel") + + /** + * Null when it names no game, a game this build does not know, no + * side, a log that is not a list of strings, or, when [withEnding], + * an ending that is not one of [ENDINGS]. A start that cannot be + * read falls through to the unsupported placeholder; a move or an + * ending is dropped. + * + * @param withEnding false for a start or a move, where an `r` is a + * field that kind does not use and is ignored (DESIGN 44). + */ + fun fromJson( + o: JSONObject, + withEnding: Boolean = true, + ): Game? { + // Any id that is there, a space included (DESIGN 36). + val id = WireJson.string(o, "id")?.takeIf { it.isNotEmpty() } ?: return null + val type = WireJson.string(o, "t")?.takeIf { it in TYPES } ?: return null + val mine = WireJson.string(o, "mine")?.takeIf { it == FIRST || it == SECOND } ?: return null + val log = + if (o.has("log")) { + val array = o.optJSONArray("log") ?: return null + (0 until array.length()).map { array.opt(it) as? String ?: return null } + } else { + null + } + val ending = if (withEnding && o.has("r")) WireJson.string(o, "r")?.takeIf { it in ENDINGS } ?: return null else null + return Game(id, type, mine, log, ending) + } + } + } + + /** + * A story taken down for everyone (§6.35, DESIGN items 25, 33 and 44): one + * story by the id §6.20 replies use, or a whole set (§6.23) by its id, + * never both. A receiver removes only what it holds from the sender of the + * envelope. + */ + data class Unpost( + val storyId: String? = null, + val setId: String? = null, + ) { + companion object { + /** Null when it names neither a story nor a set, or both. */ + fun fromJson(o: JSONObject): Unpost? { + val sid = WireJson.string(o, "sid")?.takeIf { it.isNotBlank() } + val set = WireJson.string(o, "set")?.takeIf { it.isNotBlank() } + if ((sid == null) == (set == null)) return null + return Unpost(sid, set) + } + } + } + + /** + * A greeting card: a design, the words inside, and optionally a moment + * before which the receiver's app keeps it closed. + * + * Kept as it arrived. The lines are bounded when they are drawn, not here, + * and an unknown design keeps its id, so a backup or a forward carries + * what the sender chose. + */ + data class Card( + /** One of [DESIGNS]. Anything else draws as [FALLBACK_DESIGN]. */ + val design: String, + /** The words inside. May be empty. */ + val msg: String, + /** The salutation line. */ + val to: String? = null, + /** The signature line. */ + val from: String? = null, + /** Unix seconds. Until then the RECEIVER shows the card sealed. */ + val open: Long? = null, + ) { + /** The design to draw. One this build does not know draws as `hello`. */ + val shownDesign: String get() = if (design in DESIGNS) design else FALLBACK_DESIGN + + /** The lines as they are drawn: bounded, and absent when blank. */ + val shownTo: String? get() = to?.takeIf { it.isNotBlank() }?.let { bounded(it, MAX_TO) } + val shownFrom: String? get() = from?.takeIf { it.isNotBlank() }?.let { bounded(it, MAX_FROM) } + val shownMsg: String get() = bounded(msg, MAX_MSG) + + /** + * When the receiver's copy opens, epoch millis, or null if it was + * never sealed. Bounded by [MAX_SEAL_DAYS] from when it arrived, so a + * sender's clock or a hostile value cannot keep a card shut for good. + * Measured from [receivedAtMillis], this device's own record time, + * like every other age (§6.11). + */ + fun opensAtMillis(receivedAtMillis: Long): Long? { + val seconds = open ?: return null + val asked = if (seconds > Long.MAX_VALUE / 1000) Long.MAX_VALUE else seconds * 1000 + return minOf(asked, receivedAtMillis + MAX_SEAL_DAYS * DAY_MILLIS) + } + + /** + * Whether this copy stays closed at [nowMillis]. The sender's own copy + * never does. Nothing has to arrive for it to open: the moment passes + * and the next look finds it open. + */ + fun isSealed( + mine: Boolean, + receivedAtMillis: Long, + nowMillis: Long, + ): Boolean { + if (mine) return false + val opens = opensAtMillis(receivedAtMillis) ?: return false + return nowMillis < opens + } + + /** The card object, keys in the order §6.31 gives: d, to, msg, from, open. */ + fun toJson(): String { + val parts = mutableListOf("\"d\":${WireJson.quote(design)}") + to?.let { parts += "\"to\":${WireJson.quote(it)}" } + parts += "\"msg\":${WireJson.quote(msg)}" + from?.let { parts += "\"from\":${WireJson.quote(it)}" } + open?.let { parts += "\"open\":$it" } + return parts.joinToString(",", "{", "}") + } + + companion object { + /** The nine flamenet.io designs, in the order the site offers them. */ + val DESIGNS = + listOf("birthday", "thanks", "thinking", "congrats", "getwell", "love", "season", "hello", "sympathy") + + const val FALLBACK_DESIGN = "hello" + + /** Render bounds, in characters. */ + const val MAX_TO = 60 + const val MAX_FROM = 60 + const val MAX_MSG = 2000 + + /** + * How far ahead a sender may seal a card, and how long a receiver + * keeps one closed at most, counted from when it arrived. + */ + const val MAX_SEAL_DAYS = 400L + + private const val DAY_MILLIS = 86_400_000L + + /** + * The `open` a sender writes for a chosen moment: whole seconds, + * capped at [MAX_SEAL_DAYS] ahead, and null when the moment has + * already passed, because a card sealed until the past is just a + * card. + */ + fun openValue( + chosenMillis: Long, + nowMillis: Long, + ): Long? { + val capped = minOf(chosenMillis, nowMillis + MAX_SEAL_DAYS * DAY_MILLIS) + if (capped <= nowMillis) return null + return Math.floorDiv(capped, 1000L) + } + + /** Lenient, like the iPhone: a field of the wrong type reads as absent. */ + fun fromJson(o: JSONObject): Card = + Card( + design = WireJson.string(o, "d") ?: FALLBACK_DESIGN, + msg = WireJson.string(o, "msg").orEmpty(), + to = WireJson.string(o, "to"), + from = WireJson.string(o, "from"), + open = WireJson.long(o, "open"), + ) + } + } + + /** + * An event, timed or all-day. One envelope per revision: a higher [rev] + * replaces the whole event, and a cancel is a revision that says so. + */ + data class Event( + /** Stable across revisions. */ + val id: String, + val rev: Int, + val title: String, + /** Unix seconds; null for an all-day event. */ + val start: Long? = null, + val end: Long? = null, + /** `yyyy-MM-dd`, for an all-day event only. */ + val day: String? = null, + /** How many days an all-day event runs, when more than one. */ + val days: Int? = null, + /** The organizer's IANA zone, for a timed event only. */ + val tz: String? = null, + val place: String? = null, + val note: String? = null, + val cancelled: Boolean = false, + ) { + val allDay: Boolean get() = start == null + + /** The text as it is drawn: bounded, and absent when blank. */ + val shownTitle: String get() = bounded(title, MAX_TITLE) + val shownPlace: String? get() = place?.takeIf { it.isNotBlank() }?.let { bounded(it, MAX_PLACE) } + val shownNote: String? get() = note?.takeIf { it.isNotBlank() }?.let { bounded(it, MAX_NOTE) } + + /** + * The `ev` object, keys in the order §6.32 gives: id, rev, title, then start + * and end or day and days, then tz, place, note, cancel. An all-day + * event carries no zone, and `days` only when it is more than one. + */ + fun toJson(): String { + val parts = + mutableListOf( + "\"id\":${WireJson.quote(id)}", + "\"rev\":$rev", + "\"title\":${WireJson.quote(title)}", + ) + if (start != null) { + parts += "\"start\":$start" + end?.let { parts += "\"end\":$it" } + tz?.let { parts += "\"tz\":${WireJson.quote(it)}" } + } else if (day != null) { + parts += "\"day\":${WireJson.quote(day)}" + days?.takeIf { it > 1 }?.let { parts += "\"days\":$it" } + } + place?.let { parts += "\"place\":${WireJson.quote(it)}" } + note?.let { parts += "\"note\":${WireJson.quote(it)}" } + if (cancelled) parts += "\"cancel\":1" + return parts.joinToString(",", "{", "}") + } + + companion object { + /** Render bounds, in characters. */ + const val MAX_TITLE = 120 + const val MAX_PLACE = 200 + const val MAX_NOTE = 1000 + + private val DAY = Regex("""\d{4}-\d{2}-\d{2}""") + + /** The most days an event runs (DESIGN 45). */ + const val MAX_DAYS = 31 + + /** [days] held to 1 to [MAX_DAYS], or null for a single day. */ + fun clampDays(days: Long): Int? = days.coerceIn(1L, MAX_DAYS.toLong()).toInt().takeIf { it > 1 } + + /** Whether [s] is a real calendar day written `yyyy-MM-dd`. */ + fun isDay(s: String): Boolean = DAY.matches(s) && runCatching { java.time.LocalDate.parse(s) }.isSuccess + + /** + * Null when the event cannot be drawn: no id, no revision, no + * title, or no time at all. Such an envelope falls through to the + * unsupported placeholder rather than vanishing. + * + * An end before the start is dropped rather than refused, and a + * timed event wins over a `day` sent beside it. + */ + fun fromJson(o: JSONObject): Event? { + // Any id that is there, a space included (DESIGN 36). + val id = WireJson.string(o, "id")?.takeIf { it.isNotEmpty() } ?: return null + val rev = WireJson.long(o, "rev")?.takeIf { it in 1..Int.MAX_VALUE.toLong() } ?: return null + val title = WireJson.string(o, "title")?.takeIf { it.isNotBlank() } ?: return null + val start = WireJson.long(o, "start") + val day = if (start == null) WireJson.string(o, "day")?.takeIf(::isDay) else null + if (start == null && day == null) return null + return Event( + id = id, + rev = rev.toInt(), + title = title, + start = start, + end = if (start != null) WireJson.long(o, "end")?.takeIf { it >= start } else null, + day = day, + days = if (day != null) WireJson.long(o, "days")?.let(::clampDays) else null, + tz = if (start != null) WireJson.string(o, "tz")?.takeIf { it.isNotEmpty() } else null, + place = WireJson.string(o, "place"), + note = WireJson.string(o, "note"), + cancelled = WireJson.flag(o, "cancel"), + ) + } + } + } + + /** An answer to an event: one member's yes, maybe or no, counted up. */ + data class Rsvp( + val eventId: String, + /** [YES], [MAYBE] or [NO]. */ + val answer: String, + /** + * The unix second the answer was given, or one past the sender's last + * if that was later. The highest wins, ties going to the higher `r`. + */ + val n: Long, + ) { + fun toJson(): String = "{\"eid\":${WireJson.quote(eventId)},\"r\":${WireJson.quote(answer)},\"n\":$n}" + + companion object { + const val YES = "yes" + const val MAYBE = "maybe" + const val NO = "no" + val ANSWERS = setOf(YES, MAYBE, NO) + + /** Null when it names no event, gives no known answer, or has no count from 1. */ + fun fromJson(o: JSONObject): Rsvp? { + val eid = WireJson.string(o, "eid")?.takeIf { it.isNotEmpty() } ?: return null + val r = WireJson.string(o, "r")?.takeIf { it in ANSWERS } ?: return null + val n = WireJson.long(o, "n")?.takeIf { it >= 1 } ?: return null + return Rsvp(eid, r, n) + } + } + } + /** * A sticker: an id, never an image. */ @@ -193,17 +543,29 @@ data class Content( * True when this belongs in a thread. */ val isRenderable: Boolean - get() = reaction == null && call == null && pins == null && editOf == null && !isControl + // An answer to an event is never a bubble, even one sent without the + // `ctl` its kind always carries. + get() = + reaction == null && call == null && pins == null && editOf == null && !isControl && + kind !in Kind.NEVER_DRAWN /** * Control traffic: consumed by a layer, never shown. */ val isControl: Boolean - // Both shapes accepted. - get() = - kind in Kind.CONTROL || - raw?.optBoolean("ctl", false) == true || - (raw?.optInt("ctl", 0) ?: 0) != 0 + get() { + if (kind in Kind.CONTROL) return true + val o = raw ?: return false + if (!o.has("ctl")) return false + // Carrying `ctl` is any value but 0, false or null (DESIGN 43, + // §6.10), so `"1"` counts as `1` and `true` do. + return when (val v = o.opt("ctl")) { + null, JSONObject.NULL -> false + is Boolean -> v + is Number -> v.toDouble() != 0.0 + else -> true + } + } /** * True when the sender is passing on somebody else's words (§6.16). @@ -220,6 +582,9 @@ data class Content( val lifetimeSeconds: Int? get() { if (kind == Kind.DISAPPEAR) return null + // A card, an event or a game keeps the chat's timer; a `secs` of + // its own is ignored in 0.32 (DESIGN 46). + if (kind in Kind.CHAT_TIMER_ONLY) return null val secs = raw?.optInt("secs", 0) ?: 0 return if (secs > 0) secs else null } @@ -333,6 +698,36 @@ data class Content( */ const val POLL_SUM = "psum" + /** A greeting card (§6.31). Renderable. */ + const val CARD = "card" + + /** An event, or a revision or cancel of one (§6.32). Renderable. */ + const val EVENT = "event" + + /** An answer to an event (§6.32). Control traffic, never a bubble. */ + const val RSVP = "rsvp" + + /** A game started in a one-to-one chat (§6.33). Renderable: the board lives on it. */ + const val GAME = "game" + + /** A move in a game, carrying the whole log (§6.33). Control traffic. */ + const val GAME_MOVE = "gmove" + + /** A game's end, or a draw offer or its answer (§6.33). Control traffic. */ + const val GAME_END = "gend" + + /** A story, or a whole set, taken down for everyone (§6.35). Control traffic. */ + const val UNPOST = "unpost" + + /** + * Kinds that are never a bubble, even sent without the `ctl` they + * always carry. + */ + val NEVER_DRAWN = setOf(RSVP, GAME_MOVE, GAME_END, UNPOST) + + /** Kinds that follow the chat's timer and ignore a `secs` of their own (DESIGN 46). */ + val CHAT_TIMER_ONLY = setOf(CARD, EVENT, GAME, GAME_MOVE, GAME_END) + /** * Consumed, never shown. */ @@ -372,6 +767,13 @@ data class Content( POLL, POLL_SHARE, POLL_SUM, + CARD, + EVENT, + RSVP, + GAME, + GAME_MOVE, + GAME_END, + UNPOST, ) } @@ -379,6 +781,28 @@ data class Content( /** A plaintext is an envelope iff it starts with exactly this AND parses. */ private const val MARKER = "{\"fnc\":" + /** + * [text] cut to at most [max] characters as a reader counts them, so + * a bound never splits an emoji or a letter from its accent. + */ + fun bounded( + text: String, + max: Int, + ): String { + if (text.length <= max) return text + val breaks = java.text.BreakIterator.getCharacterInstance() + breaks.setText(text) + var end = 0 + var count = 0 + while (count < max) { + val next = breaks.next() + if (next == java.text.BreakIterator.DONE) return text + end = next + count++ + } + return text.substring(0, end) + } + // -- Encoding --------------------------------------------------------. /** @@ -556,6 +980,123 @@ data class Content( return "{" + parts.joinToString(",") + "}" } + /** + * A greeting card (§6.31). Keys in the order both apps write: + * fnc, kind, fwd, mid, card, gid, gname. + */ + fun card( + card: Card, + messageId: String, + groupId: String? = null, + groupName: String? = null, + forwarded: Boolean = false, + ): String = renderable(Kind.CARD, "card", card.toJson(), messageId, groupId, groupName, forwarded) + + /** + * An event, or a revision or cancel of one (§6.32). Every + * revision carries its own fresh [messageId]; receivers fold it into + * the card by `ev.id`. Keys: fnc, kind, fwd, mid, ev, gid, gname. + */ + fun event( + event: Event, + messageId: String, + groupId: String? = null, + groupName: String? = null, + forwarded: Boolean = false, + ): String = renderable(Kind.EVENT, "ev", event.toJson(), messageId, groupId, groupName, forwarded) + + /** + * An answer to an event, as control traffic so an older build drops + * it in silence. Fanned out like any group message, and carries no + * group name, like the poll's control traffic: fnc, kind, ctl, rsvp, + * gid. + */ + fun rsvp( + rsvp: Rsvp, + groupId: String? = null, + ): String { + val parts = + mutableListOf( + "\"fnc\":1", + "\"kind\":${WireJson.quote(Kind.RSVP)}", + "\"ctl\":1", + "\"rsvp\":${rsvp.toJson()}", + ) + groupId?.let { parts += "\"gid\":${WireJson.quote(it)}" } + return parts.joinToString(",", "{", "}") + } + + /** + * Take one story down for everyone (§6.35, DESIGN item 33): + * fnc, kind, ctl, st, sid. No `gid`, even for a group story. + */ + fun unpostStory(storyId: String): String = + "{\"fnc\":1,\"kind\":\"unpost\",\"ctl\":1,\"st\":1,\"sid\":${WireJson.quote(storyId)}}" + + /** Take a whole set down (§6.23 and §6.35): the set's id in place of a story's. */ + fun unpostStorySet(setId: String): String = + "{\"fnc\":1,\"kind\":\"unpost\",\"ctl\":1,\"st\":1,\"set\":${WireJson.quote(setId)}}" + + /** + * New words for one of this account's stories (§6.15 for stories, + * DESIGN item 26): fnc, kind, st, caption, re, where `re.mid` is the + * story id and `author` is 0, as every edit writes it. + */ + fun storyEdit( + storyId: String, + caption: String, + ): String { + require(caption.isNotBlank()) { "an edit never empties a story; take it down instead" } + val re = "{\"mid\":${WireJson.quote(storyId)},\"author\":0,\"preview\":\"\"}" + return "{\"fnc\":1,\"kind\":\"edit\",\"st\":1,\"caption\":${WireJson.quote(caption)},\"re\":$re}" + } + + /** + * A game started in a one-to-one chat (§6.33): fnc, kind, mid, game. + * Games are never forwarded and never sent to a group. + */ + fun game( + game: Game, + messageId: String, + ): String = renderable(Kind.GAME, "game", game.copy(log = null, ending = null).toJson(), messageId, null, null, false) + + /** A move: the whole log, as control traffic. fnc, kind, ctl, game. */ + fun gameMove(game: Game): String = gameControl(Kind.GAME_MOVE, game.copy(log = game.log.orEmpty(), ending = null)) + + /** An ending, a draw offer or its answer: the log and `r`. fnc, kind, ctl, game. */ + fun gameEnd(game: Game): String { + require(game.ending in Game.ENDINGS) { "a gend needs one of ${Game.ENDINGS}" } + return gameControl(Kind.GAME_END, game.copy(log = game.log.orEmpty())) + } + + private fun gameControl( + kind: String, + game: Game, + ): String = "{\"fnc\":1,\"kind\":${WireJson.quote(kind)},\"ctl\":1,\"game\":${game.toJson()}}" + + /** + * The shared shape of the 0.32 renderable kinds. Strings go through + * [WireJson.quote], so the bytes are the iPhone's on every platform. + */ + private fun renderable( + kind: String, + key: String, + body: String, + messageId: String, + groupId: String?, + groupName: String?, + forwarded: Boolean, + ): String { + val parts = mutableListOf("\"fnc\":1", "\"kind\":${WireJson.quote(kind)}") + // `1`, not `true`, as every other flag here. + if (forwarded) parts += "\"fwd\":1" + parts += "\"mid\":${WireJson.quote(messageId)}" + parts += "\"$key\":$body" + groupId?.let { parts += "\"gid\":${WireJson.quote(it)}" } + groupName?.takeIf { it.isNotEmpty() }?.let { parts += "\"gname\":${WireJson.quote(it)}" } + return parts.joinToString(",", "{", "}") + } + /** * An edit of an earlier message (§6.15). */ @@ -878,7 +1419,65 @@ data class Content( } } - // §6.15, a replacement for the text of an earlier message. + // §6.31 and §6.32. Read here because neither is text. One whose + // body is missing or unusable falls through to the placeholder + // below, because somebody meant to send something. Either one + // carrying `ctl` is consumed as control by `isRenderable`. + if (kind == Kind.CARD) { + json.optJSONObject("card")?.let { body -> + return Content( + "", + kind = kind, + raw = json, + groupId = groupId, + messageId = messageId, + card = Card.fromJson(body), + ) + } + } + if (kind == Kind.EVENT) { + json.optJSONObject("ev")?.let(Event::fromJson)?.let { event -> + return Content( + "", + kind = kind, + raw = json, + groupId = groupId, + messageId = messageId, + event = event, + ) + } + } + // §6.33. A start is drawn as a board, one-to-one only: a game in a + // group, or one this build cannot read, falls through to the + // placeholder. A move or an ending is never a bubble. + if (kind == Kind.GAME && groupId == null) { + json.optJSONObject("game")?.let { Game.fromJson(it, withEnding = false) }?.let { game -> + return Content("", kind = kind, raw = json, messageId = messageId, game = game) + } + } + if (kind == Kind.GAME_MOVE || kind == Kind.GAME_END) { + return Content( + "", + kind = kind, + raw = json, + groupId = groupId, + game = json.optJSONObject("game")?.let { Game.fromJson(it, withEnding = kind == Kind.GAME_END) }, + ) + } + // Never a bubble, whatever it carries. A malformed one has no + // `rsvp` and is dropped by whoever reads it. + if (kind == Kind.RSVP) { + return Content( + "", + kind = kind, + raw = json, + groupId = groupId, + rsvp = json.optJSONObject("rsvp")?.let(Rsvp::fromJson), + ) + } + + // §6.15, a replacement for the text of an earlier message, or with + // `st` the caption of a story, whose id is then `re.mid`. if (kind == Kind.EDIT && about != null) { return Content( caption, @@ -887,9 +1486,17 @@ data class Content( groupId = groupId, about = about, editOf = about.messageId, + editsStory = aboutAStory, ) } + // §6.35. Never a bubble. `st` and `gid` are not consulted: the + // receiver finds the story by its author and id. One naming + // neither a story nor a set, or both, is dropped (DESIGN item 44). + if (kind == Kind.UNPOST) { + return Content("", kind = kind, raw = json, unpost = Unpost.fromJson(json)) + } + // §6.19, a pin changes a banner and is never a bubble. if (kind == Kind.PIN && about != null) { return Content( diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 3ecd581..9af59cd 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -100,6 +100,7 @@ class Vault( private const val REPLY_REMINDER = "reply-reminder" private const val ROSTER = "roster" private const val GROUP_NAMES = "group-names" + private const val GROUP_LIST = "group-list" private const val DRAFTS = "drafts" private const val GROUP_SECRETS = "group-secrets" private const val DEVICE_OWNER_PREFIX = "devowner/" @@ -560,6 +561,17 @@ class Vault( * A live stream this story embeds, if it is one (§6.13). */ val streamUrl: String? = null, + /** + * When its caption was last changed after posting (§6.15 for stories), + * epoch millis by the relay's clock for one received; null if never. + */ + val editedAt: Long? = null, + /** + * Who this account sent its own story to, so a take-down or an edit + * reaches exactly them (§6.35). Null for a story received, or posted + * before this was kept. + */ + val sentTo: List<Long>? = null, ) { /** Where the expiry actually lives, on the story, or on its picture. */ val expires: Double get() = expiresAt ?: ref?.expires ?: 0.0 @@ -779,6 +791,8 @@ class Vault( expiresAt = if (o.has("expires")) o.optDouble("expires") else null, set = o.optJSONObject("set")?.let { Content.StorySet.fromJson(it) }, gid = o.optString("gid").takeIf { it.isNotEmpty() }, + editedAt = o.optLong("edited", 0L).takeIf { it > 0L }, + sentTo = o.optJSONArray("to")?.let { a -> (0 until a.length()).map { a.optLong(it) } }, ) } } @@ -816,6 +830,8 @@ class Vault( story.ref?.let { row.put("ref", it.toJson()) } story.set?.let { row.put("set", it.toJson()) } story.gid?.let { row.put("gid", it) } + story.editedAt?.let { row.put("edited", it) } + story.sentTo?.let { to -> row.put("to", org.json.JSONArray().also { a -> to.forEach { a.put(it) } }) } array.put(row) } return array @@ -1626,6 +1642,18 @@ class Vault( offServer: Boolean, ) = writeJson(ROSTER, JSONObject().put("items", items).put("offServer", offServer)) + /** Whether a roster was ever kept here, empty or not. */ + fun hasRoster(): Boolean = readJson(ROSTER) != null + + /** + * The groups as the relay last listed them, so the app can open to them + * with the relay out of reach (DESIGN 47). Null when never listed here. + * Rebuilt from the relay, so not carried in a backup. + */ + fun loadGroupList(): JSONArray? = readJson(GROUP_LIST)?.optJSONArray("groups") + + fun saveGroupList(groups: JSONArray) = writeJson(GROUP_LIST, JSONObject().put("groups", groups)) + /** Rewrite a thread wholesale, for restating a message's delivery state. */ fun replaceHistory( threadId: String, diff --git a/engine/src/main/kotlin/io/flamenet/e2e/WireJson.kt b/engine/src/main/kotlin/io/flamenet/e2e/WireJson.kt new file mode 100644 index 0000000..7996ddd --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/WireJson.kt @@ -0,0 +1,84 @@ +package io.flamenet.e2e + +import org.json.JSONObject + +/** + * JSON pieces for the hand-ordered envelopes of the 0.32 kinds (cards, events, + * RSVPs), written to give the same bytes as the iPhone. + * + * `JSONObject.quote` cannot do that here. The Android framework's copy escapes + * every `/` as `\/` and the JVM copy the tests run against does not, so one + * encoder would write two different envelopes depending on where it ran, and + * neither matches the iPhone, which writes `America/New_York` as is. + */ +object WireJson { + /** + * A JSON string the way Swift's `JSONEncoder` writes one with + * `.withoutEscapingSlashes`: quote and backslash escaped, the five short + * control escapes, any other control character as `\u00xx` in lower case, + * and everything else as itself. + */ + fun quote(value: String): String { + val out = StringBuilder(value.length + 2) + out.append('"') + for (c in value) { + when (c) { + '"' -> out.append("\\\"") + '\\' -> out.append("\\\\") + '\b' -> out.append("\\b") + '\u000C' -> out.append("\\f") + '\n' -> out.append("\\n") + '\r' -> out.append("\\r") + '\t' -> out.append("\\t") + else -> + if (c < ' ') { + out.append("\\u00") + out.append(HEX[c.code shr 4]) + out.append(HEX[c.code and 0xF]) + } else { + out.append(c) + } + } + } + out.append('"') + return out.toString() + } + + private val HEX = "0123456789abcdef".toCharArray() + + /** + * A whole number, written either way an app writes one: an integer, or a + * number with a fraction, rounded down. A string or a boolean is not a + * number here, which is what the iPhone's decoder says too. + */ + fun long( + o: JSONObject, + key: String, + ): Long? = + when (val v = o.opt(key)) { + is Int -> v.toLong() + is Long -> v + is Number -> v.toDouble().takeIf { it.isFinite() && kotlin.math.abs(it) < 9e15 }?.let { Math.floor(it).toLong() } + else -> null + } + + /** + * A flag, written as `1`, `0`, `true` or `false`. Anything else, absence + * included, is off. + */ + fun flag( + o: JSONObject, + key: String, + ): Boolean = + when (val v = o.opt(key)) { + is Boolean -> v + is Number -> v.toDouble() != 0.0 + else -> false + } + + /** A string field, or null when it is absent or not a string. */ + fun string( + o: JSONObject, + key: String, + ): String? = o.opt(key) as? String +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/BackupPayloadTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/BackupPayloadTest.kt index 64ebe17..361ea47 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/BackupPayloadTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/BackupPayloadTest.kt @@ -53,7 +53,7 @@ class BackupPayloadTest { .put( "threads", JSONObject() - .put("7", JSONArray().put(message())) + .put("user:7", JSONArray().put(message())) .put("group:gid-1", JSONArray().put(message())) .put("addr:someone@elsewhere.example", JSONArray().put(message())), ).put( @@ -252,7 +252,7 @@ class BackupPayloadTest { @Test fun `a message survives the round trip whole`() { - val m = roundTrip().getJSONObject("threads").getJSONArray("7").getJSONObject(0) + val m = roundTrip().getJSONObject("threads").getJSONArray("user:7").getJSONObject(0) assertEquals("still on for saturday", m.getString("text")) assertEquals("mid-1", m.getString("mid")) assertEquals(1_700_000_000_000L, m.getLong("at")) @@ -399,7 +399,7 @@ class BackupPayloadTest { val plain = Backup.open(back, "correct horse battery") val restored = BackupPayload.fromShared(JSONObject(String(plain, Charsets.UTF_8))) - val m = restored.getJSONObject("threads").getJSONArray("7").getJSONObject(0) + val m = restored.getJSONObject("threads").getJSONArray("user:7").getJSONObject(0) assertEquals("still on for saturday", m.getString("text")) assertEquals("mid-0", m.getJSONObject("re").getString("mid")) assertEquals(3600, m.getInt("secs")) @@ -467,7 +467,7 @@ class BackupPayloadTest { fun `an iphone backup lands in this app's own storage shape`() { val out = BackupPayload.fromShared(asTheIphoneWritesIt()) - val m = out.getJSONObject("threads").getJSONArray("7").getJSONObject(0) + val m = out.getJSONObject("threads").getJSONArray("user:7").getJSONObject(0) assertEquals("hello", m.getString("text")) assertEquals(3600, m.getInt("secs"), "lifetime becomes this app's secs") assertTrue(m.getBoolean("fwd"), "forwarded becomes this app's fwd") @@ -508,7 +508,7 @@ class BackupPayloadTest { JSONObject().put( "threads", JSONObject().put( - "7", + "user:7", JSONArray().put( JSONObject() .put("text", "") @@ -533,7 +533,7 @@ class BackupPayloadTest { BackupPayload .fromShared(shared) .getJSONObject("threads") - .getJSONArray("7") + .getJSONArray("user:7") .getJSONObject(0) .getJSONObject("att") assertEquals("contract.pdf", back.getString("filename")) diff --git a/engine/src/test/kotlin/io/flamenet/e2e/BackupThreadKeyTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/BackupThreadKeyTest.kt new file mode 100644 index 0000000..3f97f86 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/BackupThreadKeyTest.kt @@ -0,0 +1,77 @@ +package io.flamenet.e2e + +import org.json.JSONArray +import org.json.JSONObject +import javax.crypto.KeyGenerator +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * A one-to-one thread crosses a backup keyed by the user id alone (§16.2.1), + * the way the iPhone writes and reads it, and comes back under the name this + * app reads it by. Through the vault's own export and import, because a + * check of the payload alone passed while every restored thread was lost. + */ +class BackupThreadKeyTest { + private class MapStorage : Vault.Storage { + val records = LinkedHashMap<String, ByteArray>() + + override fun read(name: String) = records[name] + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + + override fun names() = records.keys.toList() + } + + private fun vault() = Vault(MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + + private val at = 1_700_000_000_000L + + private fun restore(file: JSONObject): Vault = + vault().also { it.mergeImportedPayload(BackupPayload.fromShared(JSONObject(file.toString())), nowSeconds = at / 1000.0) } + + private fun firstText( + v: Vault, + thread: String, + ) = v.history(thread, now = at).getJSONObject(0).getString("text") + + @Test + fun `a one-to-one thread leaves under the user id and comes back where the app reads it`() { + val phone = vault() + val row = JSONObject().put("text", "still on for saturday").put("mine", false).put("at", at).put("author", 7L).put("mid", "m-1") + phone.replaceHistory(Vault.threadForUser(7), JSONArray().put(row)) + phone.replaceHistory(Vault.threadForGroup("g-1"), JSONArray().put(JSONObject(row.toString()).put("mid", "m-2"))) + + val file = BackupPayload.toShared(phone.exportPayload()) + assertEquals(setOf("7"), file.getJSONObject("threads").keys().asSequence().toSet(), "the iPhone reads only numeric thread keys") + + val restored = restore(file) + assertEquals(setOf(Vault.threadForUser(7), Vault.threadForGroup("g-1")), restored.threads().toSet()) + assertEquals("still on for saturday", firstText(restored, Vault.threadForUser(7))) + } + + @Test + fun `an iPhone file's one-to-one threads land where the app reads them`() { + val file = JSONObject("""{"v":1,"threads":{"7":[{"mine":false,"at":$at,"text":"from the iPhone","author":7,"mid":"m-1"}]}}""") + val restored = restore(file) + assertEquals(listOf(Vault.threadForUser(7)), restored.threads()) + assertEquals("from the iPhone", firstText(restored, Vault.threadForUser(7))) + } + + @Test + fun `a file this app wrote before still restores into the same thread`() { + val file = JSONObject("""{"v":1,"threads":{"user:7":[{"mine":true,"at":$at,"text":"older file","author":1,"mid":"m-1"}]}}""") + val restored = restore(file) + assertEquals(listOf(Vault.threadForUser(7)), restored.threads(), "a second prefix would make a thread nobody reads") + assertEquals("older file", firstText(restored, Vault.threadForUser(7))) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/CardEventBackupTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/CardEventBackupTest.kt new file mode 100644 index 0000000..9d90cca --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/CardEventBackupTest.kt @@ -0,0 +1,125 @@ +package io.flamenet.e2e + +import org.json.JSONArray +import org.json.JSONObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Cards and events in the shared backup shape (§16.2.1, §6.31 + * and §6.32): the card as its wire object, the event as its latest accepted + * `ev` with `org` and `rsvp` beside it on the same message object. + */ +class CardEventBackupTest { + private val card = + Content.Card(design = "birthday", msg = "Have a lovely day.", to = "Sam", from = "Pat", open = 1767225600) + + private val event = + ChatEvents.State( + Content.Event(id = "e-1", rev = 3, title = "Picnic", start = 1767225600, tz = "America/New_York"), + organizer = 7, + answers = mapOf(7L to ChatEvents.Answer("yes", 1), 9L to ChatEvents.Answer("maybe", 4)), + ) + + private fun internal(vararg rows: JSONObject) = + JSONObject().put("threads", JSONObject().put("user:9", JSONArray().also { a -> rows.forEach { a.put(it) } })) + + private fun cardRow() = + JSONObject() + .put("author", 9L) + .put("mid", "m-card") + .put("text", "") + .put("mine", false) + .put("at", 1_700_000_000_000L) + .put("card", JSONObject(card.toJson())) + + private fun eventRow() = + ChatEvents.toRow( + event, + JSONObject().put("author", 7L).put("mid", "m-ev").put("text", "").put("mine", false).put("at", 1_700_000_100_000L), + ) + + @Test + fun `a card travels as its wire object and comes back whole`() { + val shared = BackupPayload.toShared(internal(cardRow())) + val m = shared.getJSONObject("threads").getJSONArray("9").getJSONObject(0) + assertEquals(JSONObject(card.toJson()).toString(), m.getJSONObject("card").toString()) + assertEquals("", m.getString("text"), "a card's words belong in card, never in text") + + val back = BackupPayload.fromShared(shared).getJSONObject("threads").getJSONArray("user:9").getJSONObject(0) + assertEquals(card, Content.Card.fromJson(back.getJSONObject("card"))) + } + + @Test + fun `an event travels with its organizer and every answer`() { + val shared = BackupPayload.toShared(internal(eventRow())) + val m = shared.getJSONObject("threads").getJSONArray("9").getJSONObject(0) + assertEquals(3, m.getJSONObject("ev").getInt("rev")) + assertEquals(7L, m.getLong("org")) + assertEquals("maybe", m.getJSONObject("rsvp").getJSONObject("9").getString("r")) + assertEquals(4, m.getJSONObject("rsvp").getJSONObject("9").getInt("n")) + + val back = BackupPayload.fromShared(shared).getJSONObject("threads").getJSONArray("user:9").getJSONObject(0) + assertEquals(event, ChatEvents.fromRow(back)) + } + + @Test + fun `a provisional organizer is not carried, so a restored event is settled`() { + val provisional = + ChatEvents.toRow( + event.copy(organizerProvisional = true), + JSONObject().put("text", "").put("mine", false).put("at", 1L), + ) + val shared = BackupPayload.toShared(internal(provisional)) + val m = shared.getJSONObject("threads").getJSONArray("9").getJSONObject(0) + assertFalse(m.has(ChatEvents.ROW_PROVISIONAL)) + assertTrue(ChatEvents.ROW_PROVISIONAL in BackupPayload.NOT_CARRIED, "left out without a reason") + val back = BackupPayload.fromShared(shared).getJSONObject("threads").getJSONArray("user:9").getJSONObject(0) + assertFalse(ChatEvents.fromRow(back)!!.organizerProvisional) + } + + @Test + fun `an event with no answers writes no rsvp`() { + val shared = BackupPayload.toShared(internal(ChatEvents.toRow(event.copy(answers = emptyMap()), JSONObject().put("at", 1L)))) + assertFalse(shared.getJSONObject("threads").getJSONArray("9").getJSONObject(0).has("rsvp")) + } + + /** + * Transcribed from the shape §16.2.1 gives cards and events, not generated by the iPhone: it + * proves this app reads the shape both sides agreed on, and an iPhone + * build should be checked against the same object. + */ + @Test + fun `a card and an event as the iPhone writes them land in this app's storage`() { + val shared = + JSONObject( + """ + {"v":1, + "groups":{"g-1":[ + {"mine":false,"at":1700000000000,"text":"","author":9,"mid":"m-c", + "card":{"d":"thanks","msg":"Ta","from":"Ada","open":1767225600}}, + {"mine":true,"at":1700000000500,"text":"","author":1,"mid":"m-e", + "ev":{"id":"e-9","rev":2,"title":"Fair","day":"2026-07-04","days":2,"place":"The green"}, + "org":1,"rsvp":{"1":{"r":"yes","n":2},"9":{"r":"no","n":1}}}]}} + """.trimIndent(), + ) + val rows = BackupPayload.fromShared(shared).getJSONObject("threads").getJSONArray("group:g-1") + val c = Content.Card.fromJson(rows.getJSONObject(0).getJSONObject("card")) + assertEquals(Content.Card(design = "thanks", msg = "Ta", from = "Ada", open = 1767225600), c) + val e = ChatEvents.fromRow(rows.getJSONObject(1))!! + assertEquals("e-9", e.event.id) + assertEquals(2, e.event.days) + assertEquals(1L, e.organizer) + assertEquals(listOf(1L), e.who("yes")) + assertEquals(listOf(9L), e.who("no")) + } + + @Test + fun `every key left out has a written reason`() { + for ((key, reason) in BackupPayload.NOT_CARRIED) { + assertTrue(reason.length > 40, "the reason for leaving out $key says nothing") + } + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/CardEventEnvelopeTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/CardEventEnvelopeTest.kt new file mode 100644 index 0000000..576d637 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/CardEventEnvelopeTest.kt @@ -0,0 +1,448 @@ +package io.flamenet.e2e + +import org.json.JSONObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Cards, events and answers on the wire (§6.31 and §6.32). + * + * Both apps build these envelopes by hand and have to write the same bytes, + * so each example in §6.31 and §6.32 is pinned exactly: encode it, compare every byte, + * then decode it and encode it again. + */ +class CardEventEnvelopeTest { + private val card = + Content.Card(design = "birthday", msg = "Have a lovely day.", to = "Sam", from = "Pat", open = 1767225600) + + private val picnic = + Content.Event( + id = "<uuid>", + rev = 1, + title = "Picnic", + start = 1767225600, + end = 1767232800, + tz = "America/New_York", + place = "Riverside park, by the boathouse", + note = "Bring a blanket", + ) + + // -- The spec's examples, byte for byte ------------------------------------. + + @Test + fun `the card example is written exactly as spec section 6-31 shows it`() { + val wire = Content.card(card, messageId = "<id>") + assertEquals( + """{"fnc":1,"kind":"card","mid":"<id>","card":{"d":"birthday","to":"Sam","msg":"Have a lovely day.","from":"Pat","open":1767225600}}""", + wire, + ) + val read = Content.parse(wire) + assertEquals(card, read.card) + assertEquals("<id>", read.messageId) + assertTrue(read.isRenderable) + assertEquals(wire, Content.card(read.card!!, read.messageId!!)) + } + + @Test + fun `the event example is written exactly as spec section 6-32 shows it`() { + val wire = Content.event(picnic, messageId = "<id>") + assertEquals( + """{"fnc":1,"kind":"event","mid":"<id>","ev":{"id":"<uuid>","rev":1,"title":"Picnic","start":1767225600,""" + + """"end":1767232800,"tz":"America/New_York","place":"Riverside park, by the boathouse",""" + + """"note":"Bring a blanket"}}""", + wire, + ) + val read = Content.parse(wire) + assertEquals(picnic, read.event) + assertTrue(read.isRenderable) + assertEquals(wire, Content.event(read.event!!, read.messageId!!)) + } + + @Test + fun `an all-day event carries day and days and no zone or times`() { + val holiday = + Content.Event(id = "<uuid>", rev = 1, title = "Picnic", day = "2026-12-25", days = 2, tz = "America/New_York") + val wire = Content.event(holiday, messageId = "<id>") + assertEquals( + """{"fnc":1,"kind":"event","mid":"<id>","ev":{"id":"<uuid>","rev":1,"title":"Picnic","day":"2026-12-25","days":2}}""", + wire, + "an all-day event omits tz, start and end", + ) + val read = Content.parse(wire).event!! + assertTrue(read.allDay) + assertEquals("2026-12-25", read.day) + assertEquals(2, read.days) + assertNull(read.tz) + } + + @Test + fun `a one-day all-day event writes no days`() { + val one = Content.Event(id = "e", rev = 1, title = "Fair", day = "2026-07-04", days = 1) + assertEquals( + """{"fnc":1,"kind":"event","mid":"m","ev":{"id":"e","rev":1,"title":"Fair","day":"2026-07-04"}}""", + Content.event(one, messageId = "m"), + ) + } + + @Test + fun `a cancel carries the whole event and cancel 1 last`() { + val wire = Content.event(picnic.copy(rev = 2, cancelled = true), messageId = "<id2>") + assertEquals( + """{"fnc":1,"kind":"event","mid":"<id2>","ev":{"id":"<uuid>","rev":2,"title":"Picnic","start":1767225600,""" + + """"end":1767232800,"tz":"America/New_York","place":"Riverside park, by the boathouse",""" + + """"note":"Bring a blanket","cancel":1}}""", + wire, + ) + assertTrue(Content.parse(wire).event!!.cancelled) + } + + @Test + fun `the rsvp example is written exactly as spec section 6-32 shows it`() { + val wire = Content.rsvp(Content.Rsvp("<uuid>", "yes", 3)) + assertEquals("""{"fnc":1,"kind":"rsvp","ctl":1,"rsvp":{"eid":"<uuid>","r":"yes","n":3}}""", wire) + val read = Content.parse(wire) + assertEquals(Content.Rsvp("<uuid>", "yes", 3), read.rsvp) + assertFalse(read.isRenderable, "an answer drew a bubble") + assertTrue(read.isControl) + } + + // -- Groups and forwarding, in the agreed key order ------------------------. + + @Test + fun `a group card ends gid then gname, as a text does`() { + assertEquals( + """{"fnc":1,"kind":"card","mid":"m-1","card":{"d":"hello","msg":""},"gid":"g-1","gname":"Saturday"}""", + Content.card(Content.Card(design = "hello", msg = ""), "m-1", groupId = "g-1", groupName = "Saturday"), + ) + } + + @Test + fun `a group event ends gid then gname`() { + val wire = Content.event(picnic, "m-1", groupId = "g-1", groupName = "Saturday") + assertTrue(wire.endsWith(""""note":"Bring a blanket"},"gid":"g-1","gname":"Saturday"}"""), wire) + assertEquals("g-1", Content.parse(wire).groupId) + } + + @Test + fun `a group rsvp carries the group and no name`() { + assertEquals( + """{"fnc":1,"kind":"rsvp","ctl":1,"rsvp":{"eid":"e-1","r":"maybe","n":1},"gid":"g-1"}""", + Content.rsvp(Content.Rsvp("e-1", "maybe", 1), groupId = "g-1"), + ) + } + + @Test + fun `a forwarded card says so after kind and before mid`() { + val wire = Content.card(card.copy(open = null), "m-2", forwarded = true) + assertEquals( + """{"fnc":1,"kind":"card","fwd":1,"mid":"m-2","card":{"d":"birthday","to":"Sam","msg":"Have a lovely day.","from":"Pat"}}""", + wire, + ) + assertTrue(Content.parse(wire).isForwarded) + } + + // -- Decoding what another build might send --------------------------------. + + @Test + fun `ctl wins over a card or an event`() { + for (spelling in listOf("1", "true")) { + val c = Content.parse("""{"fnc":1,"kind":"card","ctl":$spelling,"mid":"m","card":{"d":"love","msg":"x"}}""") + assertFalse(c.isRenderable, "a card with ctl:$spelling was drawn") + val e = + Content.parse( + """{"fnc":1,"kind":"event","ctl":$spelling,"mid":"m","ev":{"id":"e","rev":1,"title":"T","start":1}}""", + ) + assertFalse(e.isRenderable, "an event with ctl:$spelling was drawn") + } + } + + @Test + fun `flags are read as numbers or booleans`() { + val cancelled = + Content.parse( + """{"fnc":1,"kind":"event","fwd":true,"mid":"m","ev":{"id":"e","rev":2,"title":"T","start":1,"cancel":true}}""", + ) + assertTrue(cancelled.event!!.cancelled, "cancel:true was not read") + assertTrue(cancelled.isForwarded, "fwd:true was not read") + val notCancelled = + Content.parse("""{"fnc":1,"kind":"event","mid":"m","ev":{"id":"e","rev":2,"title":"T","start":1,"cancel":0}}""") + assertFalse(notCancelled.event!!.cancelled) + val answer = Content.parse("""{"fnc":1,"kind":"rsvp","ctl":true,"rsvp":{"eid":"e","r":"no","n":2}}""") + assertTrue(answer.isControl) + assertEquals("no", answer.rsvp!!.answer) + } + + @Test + fun `unknown fields are ignored at every level`() { + val read = + Content.parse( + """{"fnc":1,"kind":"card","mid":"m","later":{"x":1},"card":{"d":"thanks","msg":"Ta","sparkle":true,"open":5},"z":0}""", + ) + assertEquals(Content.Card(design = "thanks", msg = "Ta", open = 5), read.card) + val ev = + Content.parse( + """{"fnc":1,"kind":"event","mid":"m","ev":{"id":"e","rev":1,"title":"T","start":10,"colour":"red"}}""", + ).event + assertEquals(Content.Event(id = "e", rev = 1, title = "T", start = 10), ev) + val answer = Content.parse("""{"fnc":1,"kind":"rsvp","ctl":1,"rsvp":{"eid":"e","r":"yes","n":1,"why":"x"}}""") + assertEquals(Content.Rsvp("e", "yes", 1), answer.rsvp) + } + + @Test + fun `missing optional fields read as absent`() { + val bare = Content.parse("""{"fnc":1,"kind":"card","mid":"m","card":{"d":"love","msg":"x"}}""").card!! + assertNull(bare.to) + assertNull(bare.from) + assertNull(bare.open) + val ev = Content.parse("""{"fnc":1,"kind":"event","mid":"m","ev":{"id":"e","rev":1,"title":"T","start":10}}""").event!! + assertNull(ev.end) + assertNull(ev.tz) + assertNull(ev.place) + assertNull(ev.note) + assertFalse(ev.cancelled) + } + + @Test + fun `a card is read leniently, as the iPhone reads it`() { + // No design draws hello; no words is an empty card, not a broken one. + val read = Content.parse("""{"fnc":1,"kind":"card","mid":"m","card":{}}""").card!! + assertEquals(Content.Card.FALLBACK_DESIGN, read.design) + assertEquals("", read.msg) + // A field of the wrong type reads as absent rather than failing the card. + val odd = Content.parse("""{"fnc":1,"kind":"card","mid":"m","card":{"d":7,"msg":"hi","to":true,"open":"soon"}}""").card!! + assertEquals("hello", odd.design) + assertNull(odd.to) + assertNull(odd.open) + } + + @Test + fun `an unknown design keeps its id and draws as hello`() { + val read = Content.parse("""{"fnc":1,"kind":"card","mid":"m","card":{"d":"anniversary","msg":"x"}}""").card!! + assertEquals("anniversary", read.design, "the sender's choice must survive a backup or a forward") + assertEquals("hello", read.shownDesign) + for (d in Content.Card.DESIGNS) assertEquals(d, read.copy(design = d).shownDesign) + } + + @Test + fun `numbers with a fraction are rounded down`() { + val read = + Content.parse("""{"fnc":1,"kind":"card","mid":"m","card":{"d":"love","msg":"x","open":1767225600.9}}""").card!! + assertEquals(1767225600L, read.open) + val ev = + Content.parse("""{"fnc":1,"kind":"event","mid":"m","ev":{"id":"e","rev":2.0,"title":"T","start":10.5}}""").event!! + assertEquals(2, ev.rev) + assertEquals(10L, ev.start) + } + + @Test + fun `over-long fields are kept whole and bounded only when drawn`() { + val long = "x".repeat(5000) + val read = + Content.parse( + Content.card(Content.Card(design = "love", msg = long, to = long, from = long), "m"), + ).card!! + assertEquals(5000, read.msg.length, "a card is kept as it arrived") + assertEquals(Content.Card.MAX_MSG, read.shownMsg.length) + assertEquals(Content.Card.MAX_TO, read.shownTo!!.length) + assertEquals(Content.Card.MAX_FROM, read.shownFrom!!.length) + + val ev = + Content.parse( + Content.event(Content.Event(id = "e", rev = 1, title = long, start = 1, place = long, note = long), "m"), + ).event!! + assertEquals(5000, ev.title.length) + assertEquals(Content.Event.MAX_TITLE, ev.shownTitle.length) + assertEquals(Content.Event.MAX_PLACE, ev.shownPlace!!.length) + assertEquals(Content.Event.MAX_NOTE, ev.shownNote!!.length) + } + + @Test + fun `a bound never splits an emoji`() { + val flames = "\uD83D\uDD25".repeat(70) + val shown = Content.Card(design = "love", msg = "", to = flames).shownTo!! + assertEquals(60 * 2, shown.length, "sixty flames, each two UTF-16 units") + assertFalse(Character.isHighSurrogate(shown.last()), "the last flame was cut in half") + } + + @Test + fun `blank lines are not drawn`() { + val read = Content.Card(design = "love", msg = "x", to = " ", from = "") + assertNull(read.shownTo) + assertNull(read.shownFrom) + val ev = Content.Event(id = "e", rev = 1, title = "T", start = 1, place = " ", note = "") + assertNull(ev.shownPlace) + assertNull(ev.shownNote) + } + + @Test + fun `an event runs one to thirty-one days (DESIGN 45)`() { + fun days(n: String) = Content.parse("""{"fnc":1,"kind":"event","mid":"m","ev":{"id":"e","rev":1,"title":"T","day":"2026-07-04","days":$n}}""").event?.days + assertEquals(31, days("40"), "more than 31 days") + assertEquals(31, days("31")) + assertEquals(2, days("2")) + for (one in listOf("1", "0", "-3")) assertNull(days(one), "days:$one is one day") + } + + @Test + fun `a card, an event or a game keeps the chat's timer, not one of its own (DESIGN 46)`() { + fun timed(wire: String) = Content.parse(wire.replaceFirst("{\"fnc\":1,", "{\"fnc\":1,\"secs\":60,")) + val card = timed(Content.card(Content.Card(design = "birthday", msg = "Hi"), "m-c")) + assertNotNull(card.card) + assertNull(card.lifetimeSeconds, "a card kept a timer of its own") + val event = timed(Content.event(Content.Event(id = "e", rev = 1, title = "T", start = 1), "m-e")) + assertNotNull(event.event) + assertNull(event.lifetimeSeconds, "an event kept a timer of its own") + val game = timed(Content.game(Content.Game("g", "chess", "first"), "m-g")) + assertNotNull(game.game) + assertNull(game.lifetimeSeconds, "a game kept a timer of its own") + assertEquals(60, timed(Content.text("hi", messageId = "m-t")).lifetimeSeconds, "a text lost its own timer") + } + + @Test + fun `an event id needs only to be there, and a space is there (DESIGN 36)`() { + for (id in listOf(" ", " ")) { + val read = Content.parse("""{"fnc":1,"kind":"event","mid":"m","ev":{"id":"$id","rev":1,"title":"T","start":1}}""") + assertEquals(id, read.event?.id, "refused the id \"$id\"") + } + val answer = Content.parse("""{"fnc":1,"kind":"rsvp","ctl":1,"rsvp":{"eid":" ","r":"yes","n":1}}""") + assertEquals(" ", answer.rsvp?.eventId, "an answer to it could not name it") + } + + @Test + fun `an event that cannot be drawn becomes the placeholder, never nothing`() { + val cases = + listOf( + """{"id":"e","rev":1,"start":1}""", + """{"id":"e","rev":1,"title":" ","start":1}""", + """{"rev":1,"title":"T","start":1}""", + """{"id":"","rev":1,"title":"T","start":1}""", + """{"id":"e","title":"T","start":1}""", + """{"id":"e","rev":0,"title":"T","start":1}""", + """{"id":"e","rev":1,"title":"T"}""", + """{"id":"e","rev":1,"title":"T","day":"2026-02-30"}""", + """{"id":"e","rev":1,"title":"T","day":"tomorrow"}""", + ) + for (ev in cases) { + val read = Content.parse("""{"fnc":1,"kind":"event","mid":"m","ev":$ev}""") + assertNull(read.event, "accepted $ev") + assertEquals("[unsupported attachment]", read.text, "$ev vanished instead of showing a placeholder") + assertTrue(read.isRenderable) + } + val noBody = Content.parse("""{"fnc":1,"kind":"card","mid":"m"}""") + assertNull(noBody.card) + assertEquals("[unsupported attachment]", noBody.text) + } + + @Test + fun `an end before the start is dropped and a time wins over a day`() { + val read = + Content.parse( + """{"fnc":1,"kind":"event","mid":"m","ev":{"id":"e","rev":1,"title":"T","start":100,"end":50,"day":"2026-01-01"}}""", + ).event!! + assertEquals(100L, read.start) + assertNull(read.end) + assertNull(read.day) + assertFalse(read.allDay) + } + + @Test + fun `an answer that names nothing usable is dropped, never drawn`() { + val cases = + listOf( + """{"eid":"e","r":"perhaps","n":1}""", + """{"eid":"e","r":"yes"}""", + """{"eid":"","r":"yes","n":1}""", + """{"r":"yes","n":1}""", + """{"eid":"e","r":"yes","n":"1"}""", + """{"eid":"e","r":"yes","n":0}""", + ) + for (r in cases) { + val read = Content.parse("""{"fnc":1,"kind":"rsvp","ctl":1,"rsvp":$r}""") + assertNull(read.rsvp, "accepted $r") + assertFalse(read.isRenderable) + } + // Without its ctl, still never a bubble. + assertFalse(Content.parse("""{"fnc":1,"kind":"rsvp","rsvp":{"eid":"e","r":"yes","n":1}}""").isRenderable) + } + + @Test + fun `the kinds are ones this build knows`() { + for (kind in listOf(Content.Kind.CARD, Content.Kind.EVENT, Content.Kind.RSVP)) { + assertTrue(kind in Content.Kind.KNOWN) + } + assertFalse(Content.parse(Content.card(card, "m")).needsNewerClient) + } + + // -- Strings come out the way the iPhone writes them -----------------------. + + @Test + fun `strings are quoted like the iPhone's encoder`() { + assertEquals("\"America/New_York\"", WireJson.quote("America/New_York"), "a slash is written as itself") + assertEquals("\"say \\\"hi\\\"\"", WireJson.quote("say \"hi\"")) + assertEquals("\"a\\\\b\"", WireJson.quote("a\\b")) + assertEquals("\"1\\n2\\t3\\r4\\b5\\f6\"", WireJson.quote("1\n2\t3\r4\b5\u000C6")) + assertEquals("\"\\u0001\\u001f\"", WireJson.quote("\u0001\u001F")) + assertEquals("\"\uD83D\uDD25 \u2028 \u00E9 <\"", WireJson.quote("\uD83D\uDD25 \u2028 \u00E9 <"), "everything else as itself") + } + + @Test + fun `awkward words survive a round trip`() { + val words = "Line one\nLine \"two\" \\ / \u0007 \uD83C\uDF82" + val read = Content.parse(Content.card(Content.Card(design = "love", msg = words, to = words), "m")).card!! + assertEquals(words, read.msg) + assertEquals(words, read.to) + // And the bytes parse as JSON anywhere. + assertEquals(words, JSONObject(Content.card(Content.Card(design = "love", msg = words), "m")).getJSONObject("card").getString("msg")) + } + + // -- Sealed until -----------------------------------------------------------. + + private val day = 86_400_000L + + @Test + fun `a sealed card stays closed for the receiver until open, then opens by itself`() { + val arrived = 1_700_000_000_000L + val sealed = Content.Card(design = "birthday", msg = "x", open = (arrived + 3 * day) / 1000) + assertTrue(sealed.isSealed(mine = false, receivedAtMillis = arrived, nowMillis = arrived)) + assertTrue(sealed.isSealed(mine = false, receivedAtMillis = arrived, nowMillis = arrived + 3 * day - 1)) + assertFalse(sealed.isSealed(mine = false, receivedAtMillis = arrived, nowMillis = arrived + 3 * day)) + assertEquals(arrived + 3 * day, sealed.opensAtMillis(arrived)) + } + + @Test + fun `the sender's own copy is never sealed`() { + val arrived = 1_700_000_000_000L + val sealed = Content.Card(design = "birthday", msg = "x", open = (arrived + 3 * day) / 1000) + assertFalse(sealed.isSealed(mine = true, receivedAtMillis = arrived, nowMillis = arrived)) + } + + @Test + fun `a card with no open, or an open in the past, is never sealed`() { + val now = 1_700_000_000_000L + assertFalse(Content.Card(design = "love", msg = "x").isSealed(false, now, now)) + assertNull(Content.Card(design = "love", msg = "x").opensAtMillis(now)) + assertFalse(Content.Card(design = "love", msg = "x", open = now / 1000 - 60).isSealed(false, now, now)) + } + + @Test + fun `a receiver keeps a card closed at most 400 days from arrival`() { + val arrived = 1_700_000_000_000L + val forever = Content.Card(design = "love", msg = "x", open = Long.MAX_VALUE) + assertEquals(arrived + 400 * day, forever.opensAtMillis(arrived)) + assertFalse(forever.isSealed(false, arrived, arrived + 400 * day)) + } + + @Test + fun `a sender caps open at 400 days and never seals into the past`() { + val now = 1_700_000_000_000L + assertEquals((now + 400 * day) / 1000, Content.Card.openValue(now + 900 * day, now)) + assertEquals((now + 2 * day) / 1000, Content.Card.openValue(now + 2 * day + 999, now), "whole seconds, rounded down") + assertNull(Content.Card.openValue(now - 1, now)) + assertNull(Content.Card.openValue(now, now)) + assertNotNull(Content.Card.openValue(now + 1000, now)) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/ChatEventsTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/ChatEventsTest.kt new file mode 100644 index 0000000..d335ae5 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/ChatEventsTest.kt @@ -0,0 +1,236 @@ +package io.flamenet.e2e + +import org.json.JSONObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Who may change an event, and which answer counts (§6.32). + * + * Every rule here is about an envelope that arrives in the wrong order or from + * the wrong person, because that is all a member of a group needs to do to + * move somebody else's plans. + */ +class ChatEventsTest { + private val alice = 1L + private val bob = 2L + private val mallory = 3L + + private fun ev( + rev: Int, + title: String = "Picnic", + cancelled: Boolean = false, + ) = Content.Event(id = "e-1", rev = rev, title = title, start = 1_767_225_600, cancelled = cancelled) + + private fun settled(rev: Int = 1) = ChatEvents.State(ev(rev), organizer = alice) + + // -- Revisions -----------------------------------------------------------. + + @Test + fun `the first envelope seen makes a new card, organized by its sender`() { + val folded = ChatEvents.fold(null, alice, ev(1)) + assertEquals(ChatEvents.Change.NEW, folded.change) + assertEquals(alice, folded.state!!.organizer) + assertFalse(folded.state!!.organizerProvisional) + } + + @Test + fun `a higher revision from the organizer replaces the whole event`() { + val folded = ChatEvents.fold(settled(), alice, ev(2, title = "Barbecue")) + assertEquals(ChatEvents.Change.REVISED, folded.change) + assertEquals("Barbecue", folded.state!!.event.title) + assertEquals("Picnic", folded.before!!.title, "a notice names what the reader knew") + } + + @Test + fun `a lower or equal revision is ignored`() { + val current = settled(rev = 3) + assertEquals(ChatEvents.Change.IGNORED, ChatEvents.fold(current, alice, ev(3, "Again")).change) + assertEquals(ChatEvents.Change.IGNORED, ChatEvents.fold(current, alice, ev(2, "Older")).change) + assertSame(current, ChatEvents.fold(current, alice, ev(1)).state) + } + + @Test + fun `only the organizer can revise or cancel`() { + val current = settled() + val hijack = ChatEvents.fold(current, mallory, ev(9, title = "Moved to my place")) + assertEquals(ChatEvents.Change.IGNORED, hijack.change) + assertEquals("Picnic", hijack.state!!.event.title) + val cancel = ChatEvents.fold(current, bob, ev(2, cancelled = true)) + assertEquals(ChatEvents.Change.IGNORED, cancel.change) + assertFalse(cancel.state!!.event.cancelled) + } + + @Test + fun `somebody else's rev 1 for a known event is ignored`() { + val again = ChatEvents.fold(settled(), mallory, ev(1, title = "Mine now")) + assertEquals(ChatEvents.Change.IGNORED, again.change) + assertEquals(alice, again.state!!.organizer) + } + + @Test + fun `a cancel is a revision, and a later revision can bring it back`() { + val cancelled = ChatEvents.fold(settled(), alice, ev(2, cancelled = true)) + assertEquals(ChatEvents.Change.REVISED, cancelled.change) + assertTrue(cancelled.state!!.event.cancelled) + val back = ChatEvents.fold(cancelled.state, alice, ev(3)) + assertFalse(back.state!!.event.cancelled) + } + + @Test + fun `before rev 1 arrives the first sender stands in for the organizer`() { + val early = ChatEvents.fold(null, alice, ev(2, title = "Moved")) + assertEquals(ChatEvents.Change.NEW, early.change) + assertTrue(early.state!!.organizerProvisional) + // Their own later revisions still apply. + val later = ChatEvents.fold(early.state, alice, ev(3, title = "Moved again")) + assertEquals("Moved again", later.state!!.event.title) + assertTrue(later.state!!.organizerProvisional) + } + + @Test + fun `rev 1 from the stand-in settles them without changing anything`() { + val early = ChatEvents.fold(null, alice, ev(2, title = "Moved")).state + val settled = ChatEvents.fold(early, alice, ev(1)) + assertEquals(ChatEvents.Change.SETTLED, settled.change) + assertFalse(settled.state!!.organizerProvisional) + assertEquals("Moved", settled.state!!.event.title, "the older rev 1 must not undo rev 2") + } + + @Test + fun `rev 1 from somebody else replaces a stand-in and what they sent, and is never news`() { + val planted = ChatEvents.fold(null, mallory, ev(5, title = "Moved to my place")).state!! + .copy(answers = mapOf(bob to ChatEvents.Answer("yes", 1))) + val real = ChatEvents.fold(planted, alice, ev(1, title = "Picnic")) + // DESIGN 36: redrawn, never notified, so no "Moved to my place changed". + assertEquals(ChatEvents.Change.SETTLED, real.change) + assertNull(real.before, "a takeover kept words for a notice") + assertEquals(alice, real.state!!.organizer) + assertFalse(real.state!!.organizerProvisional) + assertEquals("Picnic", real.state!!.event.title) + assertEquals(1, real.state!!.event.rev) + assertEquals("yes", real.state!!.answers[bob]?.r, "answers to the event id are kept") + // And the stand-in is now just another member. + assertEquals(ChatEvents.Change.IGNORED, ChatEvents.fold(real.state, mallory, ev(6)).change) + } + + // -- Answers ---------------------------------------------------------------. + + @Test + fun `the highest count from each sender wins`() { + var state = settled() + state = ChatEvents.answer(state, bob, Content.Rsvp("e-1", "yes", 1))!! + state = ChatEvents.answer(state, bob, Content.Rsvp("e-1", "no", 3))!! + assertNull(ChatEvents.answer(state, bob, Content.Rsvp("e-1", "yes", 2)), "an older answer arrived late and won") + assertEquals("no", state.answers[bob]!!.r) + state = ChatEvents.answer(state, bob, Content.Rsvp("e-1", "maybe", 4))!! + assertEquals("maybe", state.answers[bob]!!.r) + } + + @Test + fun `on an equal count the answer that sorts higher wins, in either order`() { + val pairs = listOf("yes" to "no", "no" to "yes", "no" to "maybe", "maybe" to "no", "yes" to "maybe", "maybe" to "yes") + for ((first, second) in pairs) { + var state = ChatEvents.answer(settled(), bob, Content.Rsvp("e-1", first, 1_800_000_000))!! + state = ChatEvents.answer(state, bob, Content.Rsvp("e-1", second, 1_800_000_000)) ?: state + assertEquals(maxOf(first, second), state.answers[bob]!!.r, "$first then $second settled differently") + } + val held = ChatEvents.answer(settled(), bob, Content.Rsvp("e-1", "yes", 7))!! + assertNull(ChatEvents.answer(held, bob, Content.Rsvp("e-1", "yes", 7)), "a duplicate changed something") + } + + @Test + fun `each sender is counted on their own`() { + var state = settled() + state = ChatEvents.answer(state, bob, Content.Rsvp("e-1", "yes", 5))!! + state = ChatEvents.answer(state, mallory, Content.Rsvp("e-1", "no", 1))!! + state = ChatEvents.answer(state, alice, Content.Rsvp("e-1", "yes", 1))!! + assertEquals(listOf(alice, bob), state.who("yes")) + assertEquals(listOf(mallory), state.who("no")) + assertEquals(emptyList(), state.who("maybe")) + } + + @Test + fun `an answer for another event or with no known answer is ignored`() { + val state = settled() + assertNull(ChatEvents.answer(state, bob, Content.Rsvp("e-2", "yes", 1))) + assertNull(ChatEvents.answer(state, bob, Content.Rsvp("e-1", "perhaps", 1))) + } + + @Test + fun `my next answer is counted by the clock, or one past my last`() { + val state = settled().copy(answers = mapOf(alice to ChatEvents.Answer("yes", 1_800_000_000))) + assertEquals(1_800_000_500, state.nextNumber(alice, nowSeconds = 1_800_000_500)) + assertEquals(1_800_000_001, state.nextNumber(alice, nowSeconds = 1_800_000_000), "two answers in one second") + assertEquals(1_800_000_001, state.nextNumber(alice, nowSeconds = 1_700_000_000), "a clock set back") + assertEquals(1_700_000_000, state.nextNumber(bob, nowSeconds = 1_700_000_000)) + } + + // -- On a history row ------------------------------------------------------. + + @Test + fun `an event survives a history row whole`() { + val state = + ChatEvents.State( + ev(4, cancelled = true), + organizer = alice, + organizerProvisional = true, + answers = mapOf(bob to ChatEvents.Answer("maybe", 2), mallory to ChatEvents.Answer("no", 9)), + ) + val row = ChatEvents.toRow(state, JSONObject().put("mid", "m-1").put("text", "")) + assertEquals("m-1", row.getString("mid"), "other keys on the row were touched") + assertEquals(state, ChatEvents.fromRow(JSONObject(row.toString()))) + // The backup shape: ids as text keys, answers as {r, n}. + assertEquals("maybe", row.getJSONObject("rsvp").getJSONObject("2").getString("r")) + assertEquals(2, row.getJSONObject("rsvp").getJSONObject("2").getInt("n")) + assertEquals(1, row.getLong("org")) + } + + @Test + fun `a settled organizer and no answers leave nothing behind`() { + val row = + ChatEvents.toRow( + settled(), + JSONObject().put("orgp", true).put("rsvp", JSONObject().put("2", JSONObject().put("r", "yes").put("n", 1))), + ) + assertFalse(row.has("orgp"), "a provisional mark outlived rev 1") + assertFalse(row.has("rsvp")) + } + + @Test + fun `a row with no organizer accepts no revision`() { + val row = JSONObject().put("ev", JSONObject(ev(1).toJson())) + val state = ChatEvents.fromRow(row)!! + assertEquals(0L, state.organizer) + for (anyone in listOf(alice, bob, mallory)) { + assertEquals(ChatEvents.Change.IGNORED, ChatEvents.fold(state, anyone, ev(2)).change) + } + } + + @Test + fun `a row with no event, or one that cannot be drawn, has no state`() { + assertNull(ChatEvents.fromRow(JSONObject().put("text", "hi"))) + assertNull(ChatEvents.fromRow(JSONObject().put("ev", JSONObject().put("id", "e")))) + } + + @Test + fun `answers that cannot be read are dropped, the rest kept`() { + val row = + JSONObject() + .put("ev", JSONObject(ev(1).toJson())) + .put("org", alice) + .put( + "rsvp", + JSONObject() + .put("2", JSONObject().put("r", "yes").put("n", 1)) + .put("bob", JSONObject().put("r", "yes").put("n", 1)) + .put("3", JSONObject().put("r", "sure").put("n", 1)) + .put("4", JSONObject().put("r", "no")), + ) + assertEquals(mapOf(bob to ChatEvents.Answer("yes", 1)), ChatEvents.fromRow(row)!!.answers) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/ContactCardTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/ContactCardTest.kt index 0037040..8becfea 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/ContactCardTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/ContactCardTest.kt @@ -96,4 +96,16 @@ class ContactCardTest { assertNull(ContactCard.canonical(bad), "accepted $bad") } } + + /** + * DESIGN 41: a relay's name is ASCII. Lowercasing turns a Kelvin sign + * into an ASCII k, so it is refused before that, never folded. + */ + @Test + fun `a relay with a character outside ASCII is refused, not folded`() { + for (bad in listOf("fn1abc@\u212Aelay.example", "fn1abc@relay.\u212Aexample", "fn1abc@rel\u0430y.example")) { + assertNull(ContactCard.decode(bad), "accepted $bad") + } + assertEquals("fn1abc@relay.example", ContactCard.decode("fn1abc@Relay.Example")?.address) + } } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/ControlFlagInteropTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/ControlFlagInteropTest.kt index 03a0e23..58bc8ce 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/ControlFlagInteropTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/ControlFlagInteropTest.kt @@ -54,6 +54,19 @@ class ControlFlagInteropTest { assertTrue(!content.isRenderable, "control traffic must not reach the thread") } + /** DESIGN 43: carrying `ctl` is any value but 0, false or null, so "1" counts. */ + @Test + fun `any ctl but 0, false or null is control`() { + for (value in listOf("1", "true", "\"1\"", "\"0\"", "\"false\"", "\"\"", "2", "-1", "0.5", "[]", "{}")) { + val raw = """{"fnc":1,"kind":"some_future_thing","ctl":$value}""" + assertTrue(Content.parse(raw).isControl, "ctl:$value was shown to the user") + } + for (value in listOf("0", "false", "null", "0.0", "-0")) { + val raw = """{"fnc":1,"kind":"some_future_thing","ctl":$value}""" + assertTrue(!Content.parse(raw).isControl, "ctl:$value was taken for control") + } + } + /** An ordinary message is untouched by any of it. */ @Test fun `a plain message is not control traffic`() { diff --git a/engine/src/test/kotlin/io/flamenet/e2e/GameBackupTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/GameBackupTest.kt new file mode 100644 index 0000000..c4b14e6 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/GameBackupTest.kt @@ -0,0 +1,98 @@ +package io.flamenet.e2e + +import org.json.JSONArray +import org.json.JSONObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Games in the shared backup shape (§16.2.1 and §6.33): the start message + * carries `"game":{"id","t","me","log","r"}`, where `me` is the side this + * person plays and `r` the ending sent by `gend`, if any. + */ +class GameBackupTest { + private fun internal(vararg rows: JSONObject) = + JSONObject().put("threads", JSONObject().put("user:9", JSONArray().also { a -> rows.forEach { a.put(it) } })) + + /** The first message of the one thread in [payload], whatever the thread is called there. */ + private fun only(payload: JSONObject): JSONObject { + val threads = payload.getJSONObject("threads") + assertEquals(1, threads.length()) + return threads.getJSONArray(threads.keys().next()).getJSONObject(0) + } + + /** DESIGN item 14: `rby`, the side that sent `r`, written only with it. */ + private fun game() = + JSONObject() + .put("id", "g-1") + .put("t", "chess") + .put("me", "first") + .put("log", JSONArray(listOf("e2e4", "e7e5"))) + .put("r", "resign") + .put("rby", "second") + + private fun gameRow() = + JSONObject() + .put("author", 1L) + .put("mid", "m-game") + .put("text", "") + .put("mine", true) + .put("at", 1_700_000_000_000L) + .put("game", game()) + .put("gameOutOfStep", true) + .put("gameOffer", "second") + + @Test + fun `a game travels on its start message and comes back whole`() { + val shared = BackupPayload.toShared(internal(gameRow())) + val m = only(shared) + val g = m.getJSONObject("game") + assertEquals("g-1", g.getString("id")) + assertEquals("chess", g.getString("t")) + assertEquals("first", g.getString("me")) + assertEquals(JSONArray(listOf("e2e4", "e7e5")).toString(), g.getJSONArray("log").toString()) + assertEquals("resign", g.getString("r")) + assertEquals("", m.getString("text"), "a game has no words of its own") + assertEquals("m-game", m.getString("mid"), "replies and reactions point at the start message") + + val back = only(BackupPayload.fromShared(shared)) + assertEquals(game().toString(), back.getJSONObject("game").toString()) + } + + @Test + fun `what only this phone knows about a game is left out, with a reason`() { + val m = only(BackupPayload.toShared(internal(gameRow()))) + for (key in listOf("gameOutOfStep", "gameOffer")) { + assertFalse(m.has(key), "$key went into the backup") + assertTrue((BackupPayload.NOT_CARRIED[key]?.length ?: 0) > 40, "$key left out without a reason") + } + } + + /** + * Transcribed from the shape §6.33 and DESIGN item 14 give a game, not + * generated by the iPhone: the log as it travelled, and `rby` beside `r`. + */ + @Test + fun `a game as the iPhone writes it lands in this app's storage`() { + val shared = + JSONObject( + """ + {"v":1, + "threads":{"7":[ + {"mine":false,"at":1700000000000,"text":"","author":7,"mid":"m-g", + "game":{"id":"g-9","t":"reversi","me":"second","log":["d3","c3","pass"],"r":"draw-accept","rby":"first"}}]}} + """.trimIndent(), + ) + val row = only(BackupPayload.fromShared(shared)) + val g = row.getJSONObject("game") + assertEquals("g-9", g.getString("id")) + assertEquals("reversi", g.getString("t")) + assertEquals("second", g.getString("me")) + assertEquals(listOf("d3", "c3", "pass"), (0 until g.getJSONArray("log").length()).map { g.getJSONArray("log").getString(it) }) + assertEquals("draw-accept", g.getString("r")) + assertEquals("first", g.getString("rby")) + assertEquals("m-g", row.getString("mid")) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/GameEnvelopeTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/GameEnvelopeTest.kt new file mode 100644 index 0000000..1a1faea --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/GameEnvelopeTest.kt @@ -0,0 +1,227 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Games on the wire (§6.33). + * + * Both apps build these envelopes by hand and have to write the same bytes, so + * the start, the move and the ending in §6.33 are pinned exactly: encode, + * compare every byte, decode, encode again. Then what a receiver makes of a + * game it cannot read, one in a group, and one from a newer build. + */ +class GameEnvelopeTest { + private val start = Content.Game(id = "<uuid>", type = "chess", mine = "first") + private val twoPlies = listOf("e2e4", "e7e5") + + // -- The spec's examples, byte for byte ------------------------------------. + + @Test + fun `the start is written exactly as spec section 6-33 shows it`() { + val wire = Content.game(start, messageId = "<id>") + assertEquals("""{"fnc":1,"kind":"game","mid":"<id>","game":{"id":"<uuid>","t":"chess","mine":"first"}}""", wire) + val read = Content.parse(wire) + assertEquals(Content.Kind.GAME, read.kind) + assertEquals(start, read.game) + assertEquals("<id>", read.messageId) + assertTrue(read.isRenderable, "the start carries the board, so it is drawn") + assertFalse(read.isControl) + assertEquals(wire, Content.game(read.game!!, read.messageId!!)) + } + + @Test + fun `a move is written exactly as spec section 6-33 shows it`() { + val move = Content.Game("<uuid>", "chess", "second", twoPlies) + val wire = Content.gameMove(move) + assertEquals( + """{"fnc":1,"kind":"gmove","ctl":1,"game":{"id":"<uuid>","t":"chess","mine":"second","log":["e2e4","e7e5"]}}""", + wire, + ) + val read = Content.parse(wire) + assertEquals(Content.Kind.GAME_MOVE, read.kind) + assertEquals(move, read.game) + assertTrue(read.isControl, "a move without ctl would draw a bubble on an older build") + assertFalse(read.isRenderable) + assertEquals(wire, Content.gameMove(read.game!!)) + } + + @Test + fun `an ending is written exactly as spec section 6-33 shows it`() { + val end = Content.Game("<uuid>", "chess", "first", twoPlies, "resign") + val wire = Content.gameEnd(end) + assertEquals( + """{"fnc":1,"kind":"gend","ctl":1,"game":{"id":"<uuid>","t":"chess","mine":"first","log":["e2e4","e7e5"],"r":"resign"}}""", + wire, + ) + val read = Content.parse(wire) + assertEquals(Content.Kind.GAME_END, read.kind) + assertEquals(end, read.game) + assertTrue(read.isControl) + assertFalse(read.isRenderable) + assertEquals(wire, Content.gameEnd(read.game!!)) + } + + // -- What each envelope may carry -------------------------------------------. + + @Test + fun `every ending in spec section 6-33 reads back as itself`() { + assertEquals(setOf("resign", "draw-offer", "draw-accept", "draw-decline", "decline", "cancel"), Content.Game.ENDINGS) + for (r in Content.Game.ENDINGS) { + val sent = Content.Game("g", "reversi", "second", listOf("d3", "c3"), r) + assertEquals(sent, Content.parse(Content.gameEnd(sent)).game, r) + } + } + + @Test + fun `a start never carries a log or an ending, and a move never carries an ending`() { + val busy = Content.Game("g", "checkers", "second", listOf("11-15"), "resign") + assertEquals("""{"fnc":1,"kind":"game","mid":"m","game":{"id":"g","t":"checkers","mine":"second"}}""", Content.game(busy, "m")) + assertEquals( + """{"fnc":1,"kind":"gmove","ctl":1,"game":{"id":"g","t":"checkers","mine":"second","log":["11-15"]}}""", + Content.gameMove(busy), + ) + } + + @Test + fun `an empty log is written as an empty list, never left out`() { + val wire = Content.gameEnd(Content.Game("g", "tictactoe", "second", ending = "decline")) + assertEquals("""{"fnc":1,"kind":"gend","ctl":1,"game":{"id":"g","t":"tictactoe","mine":"second","log":[],"r":"decline"}}""", wire) + assertEquals(emptyList(), Content.parse(wire).game!!.log) + } + + @Test + fun `an ending outside the list is never sent`() { + assertFailsWith<IllegalArgumentException> { Content.gameEnd(Content.Game("g", "chess", "first", twoPlies)) } + assertFailsWith<IllegalArgumentException> { Content.gameEnd(Content.Game("g", "chess", "first", twoPlies, "surrender")) } + } + + @Test + fun `strings are escaped as the iPhone writes them, slashes left alone`() { + val odd = Content.Game("a/b\"c", "tictactoe", "first", listOf("5")) + val wire = Content.gameMove(odd) + assertEquals("""{"fnc":1,"kind":"gmove","ctl":1,"game":{"id":"a/b\"c","t":"tictactoe","mine":"first","log":["5"]}}""", wire) + assertEquals(odd, Content.parse(wire).game) + } + + // -- Receiving ----------------------------------------------------------------. + + @Test + fun `ctl as true, keys in any order after fnc, and unknown fields are all fine`() { + // `{"fnc":` is how any envelope is told from plain text, so it stays first. + val read = + Content.parse( + """{"fnc":1,"game":{"log":["5"],"x":1,"mine":"first","t":"tictactoe","id":"g"},"ctl":true,"kind":"gmove","later":"y"}""", + ) + assertEquals(Content.Game("g", "tictactoe", "first", listOf("5")), read.game) + assertTrue(read.isControl) + assertFalse(read.isRenderable) + } + + @Test + fun `a move or an ending is never drawn, even sent without ctl`() { + for (kind in listOf("gmove", "gend")) { + val read = Content.parse("""{"fnc":1,"kind":"$kind","game":{"id":"g","t":"chess","mine":"first","log":[],"r":"resign"}}""") + assertFalse(read.isRenderable, "$kind without ctl drew a bubble") + } + } + + @Test + fun `a game in a group is drawn as the unsupported placeholder`() { + val read = + Content.parse( + """{"fnc":1,"kind":"game","mid":"m","game":{"id":"g","t":"chess","mine":"first"},"gid":"g-1","gname":"Saturday"}""", + ) + assertNull(read.game, "§6.33 games are one-to-one only") + assertEquals("[unsupported attachment]", read.text) + assertTrue(read.isRenderable, "somebody meant to send something, so it shows") + } + + @Test + fun `a game id needs only to be there, and a space is there (DESIGN 36)`() { + for (id in listOf(" ", " ")) { + val start = Content.parse("""{"fnc":1,"kind":"game","mid":"m","game":{"id":"$id","t":"chess","mine":"first"}}""") + assertEquals(id, start.game?.id, "refused the id \"$id\"") + val move = Content.parse("""{"fnc":1,"kind":"gmove","ctl":1,"game":{"id":"$id","t":"chess","mine":"first","log":["e2e4"]}}""") + assertEquals(id, move.game?.id) + } + } + + @Test + fun `a move or a start that carries r is still a move or a start (DESIGN 44)`() { + for (r in listOf("\"resign\"", "\"surrender\"", "5", "null", "{}")) { + val move = Content.parse("""{"fnc":1,"kind":"gmove","ctl":1,"game":{"id":"g","t":"chess","mine":"first","log":["e2e4"],"r":$r}}""") + assertEquals(listOf("e2e4"), move.game?.log, "a move carrying r:$r was dropped") + assertNull(move.game?.ending, "a move took r:$r for an ending") + val start = Content.parse("""{"fnc":1,"kind":"game","mid":"m","game":{"id":"g","t":"chess","mine":"first","r":$r}}""") + assertEquals("g", start.game?.id, "a start carrying r:$r was dropped") + } + } + + @Test + fun `a start this build cannot read becomes the placeholder, never nothing`() { + val cases = + listOf( + """"game":"chess"""", + """"game":{"t":"chess","mine":"first"}""", + """"game":{"id":"","t":"chess","mine":"first"}""", + """"game":{"id":"g","t":"go","mine":"first"}""", + """"game":{"id":"g","mine":"first"}""", + """"game":{"id":"g","t":"chess","mine":"third"}""", + """"game":{"id":"g","t":"chess","mine":1}""", + """"game":{"id":"g","t":"chess"}""", + """"nothing":1""", + ) + for (body in cases) { + val read = Content.parse("""{"fnc":1,"kind":"game","mid":"m",$body}""") + assertNull(read.game, "accepted $body") + assertEquals("[unsupported attachment]", read.text, "$body vanished instead of showing a placeholder") + assertTrue(read.isRenderable) + } + } + + @Test + fun `a move or an ending that names nothing usable is dropped, never drawn`() { + val cases = + listOf( + """{"id":"g","t":"chess","mine":"first","log":"e2e4"}""", + """{"id":"g","t":"chess","mine":"first","log":["e2e4",5]}""", + """{"id":"g","t":"chess","mine":"first","log":["e2e4",null]}""", + """{"id":"g","t":"chess","mine":"first","log":{"0":"e2e4"}}""", + """{"t":"chess","mine":"first","log":[]}""", + """{"id":"g","t":"draughts","mine":"first","log":[]}""", + """{"id":"g","t":"chess","mine":"white","log":[]}""", + ) + for (game in cases) { + for (kind in listOf("gmove", "gend")) { + val read = Content.parse("""{"fnc":1,"kind":"$kind","ctl":1,"game":$game}""") + assertNull(read.game, "accepted $kind $game") + assertEquals(kind, read.kind) + assertFalse(read.isRenderable) + } + } + val unknownEnding = + Content.parse("""{"fnc":1,"kind":"gend","ctl":1,"game":{"id":"g","t":"chess","mine":"first","log":[],"r":"surrender"}}""") + assertNull(unknownEnding.game, "an ending this build does not know was taken") + } + + @Test + fun `a move keeps whatever the log says, and the rules decide`() { + // The envelope only checks shapes. Whether "e7e9" is a move, or the log + // is too long, is for the receiving rules, which mark the game out of step. + val read = Content.parse("""{"fnc":1,"kind":"gmove","ctl":1,"game":{"id":"g","t":"chess","mine":"second","log":["e2e4","e7e9"]}}""") + assertEquals(listOf("e2e4", "e7e9"), read.game!!.log) + } + + @Test + fun `a move in a group is read, so the conversation can drop it`() { + val read = + Content.parse("""{"fnc":1,"kind":"gmove","ctl":1,"game":{"id":"g","t":"chess","mine":"first","log":["e2e4"]},"gid":"g-1"}""") + assertEquals("g-1", read.groupId) + assertFalse(read.isRenderable) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/PublishedVectorsTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/PublishedVectorsTest.kt index c088edd..51ea311 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/PublishedVectorsTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/PublishedVectorsTest.kt @@ -30,7 +30,7 @@ class PublishedVectorsTest { file != null, "published vectors not found. Looked in: " + candidates.joinToString(", ") { it.absolutePath } + - ". Clone https://gitlab.com/extant2000/flamenet-e2e beside this repo, " + + ". Clone https://git.extant2000.com/extant2000/flamenet-e2e beside this repo, " + "or set FLAMENET_VECTORS to protocol-v1.json.", ) JSONObject(file!!.readText()) diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StoryControlEnvelopeTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StoryControlEnvelopeTest.kt new file mode 100644 index 0000000..19bea51 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/StoryControlEnvelopeTest.kt @@ -0,0 +1,174 @@ +package io.flamenet.e2e + +import javax.crypto.KeyGenerator +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Taking a story down and changing its words, on the wire (§6.35 and §6.15 + * for stories, DESIGN items 26 and 33). Both apps write these by hand, so the + * bytes are pinned exactly. + */ +class StoryControlEnvelopeTest { + // -- The bytes ----------------------------------------------------------------. + + @Test + fun `one story comes down exactly as DESIGN item 33 writes it`() { + val wire = Content.unpostStory("<story id>") + assertEquals("""{"fnc":1,"kind":"unpost","ctl":1,"st":1,"sid":"<story id>"}""", wire) + val read = Content.parse(wire) + assertEquals(Content.Kind.UNPOST, read.kind) + assertEquals(Content.Unpost(storyId = "<story id>"), read.unpost) + assertTrue(read.isControl) + assertFalse(read.isRenderable) + } + + @Test + fun `a whole set comes down by the set's id`() { + val wire = Content.unpostStorySet("<set.id>") + assertEquals("""{"fnc":1,"kind":"unpost","ctl":1,"st":1,"set":"<set.id>"}""", wire) + assertEquals(Content.Unpost(setId = "<set.id>"), Content.parse(wire).unpost) + } + + @Test + fun `a story's new caption is written exactly as DESIGN item 26 writes it`() { + val wire = Content.storyEdit("<sid>", "<new words>") + assertEquals( + """{"fnc":1,"kind":"edit","st":1,"caption":"<new words>","re":{"mid":"<sid>","author":0,"preview":""}}""", + wire, + ) + val read = Content.parse(wire) + assertEquals(Content.Kind.EDIT, read.kind) + assertEquals("<sid>", read.editOf) + assertTrue(read.editsStory) + assertEquals("<new words>", read.text) + assertFalse(read.isRenderable, "an edit is never a bubble") + } + + @Test + fun `strings are escaped as the iPhone writes them, slashes left alone`() { + assertEquals("""{"fnc":1,"kind":"unpost","ctl":1,"st":1,"sid":"a/b\"c"}""", Content.unpostStory("a/b\"c")) + val edit = Content.storyEdit("x/y", "Home \"at last\" 1/2") + assertEquals( + """{"fnc":1,"kind":"edit","st":1,"caption":"Home \"at last\" 1/2","re":{"mid":"x/y","author":0,"preview":""}}""", + edit, + ) + assertEquals("Home \"at last\" 1/2", Content.parse(edit).text) + } + + @Test + fun `an edit never empties a story, that is a take-down`() { + assertFailsWith<IllegalArgumentException> { Content.storyEdit("s", "") } + assertFailsWith<IllegalArgumentException> { Content.storyEdit("s", " ") } + } + + // -- Reading ---------------------------------------------------------------------. + + @Test + fun `an edit of a message is not an edit of a story`() { + val read = Content.parse(Content.edit("meet at 9", "m-1")) + assertEquals("m-1", read.editOf) + assertFalse(read.editsStory) + } + + @Test + fun `st and ctl are read as a number or a boolean, and keys after fnc in any order`() { + val read = Content.parse("""{"fnc":1,"sid":"s-1","st":true,"kind":"unpost","ctl":true,"later":1}""") + assertEquals(Content.Unpost(storyId = "s-1"), read.unpost) + assertTrue(read.isControl) + val edit = Content.parse("""{"fnc":1,"re":{"preview":"","author":0,"mid":"s-2"},"caption":"new","st":true,"kind":"edit"}""") + assertTrue(edit.editsStory) + assertEquals("s-2", edit.editOf) + } + + @Test + fun `a take-down naming neither a story nor a set, or both, is dropped and never drawn`() { + val cases = + listOf( + """{"fnc":1,"kind":"unpost","ctl":1,"st":1}""", + """{"fnc":1,"kind":"unpost","ctl":1,"st":1,"sid":" "}""", + """{"fnc":1,"kind":"unpost","ctl":1,"st":1,"sid":7}""", + """{"fnc":1,"kind":"unpost","ctl":1,"st":1,"set":""}""", + // DESIGN item 44: one or the other, never both. + """{"fnc":1,"kind":"unpost","ctl":1,"st":1,"sid":"s-1","set":"set-1"}""", + ) + for (wire in cases) { + val read = Content.parse(wire) + assertNull(read.unpost, "took $wire") + assertFalse(read.isRenderable, "$wire drew a bubble") + } + } + + @Test + fun `st, gid and unknown fields never decide a take-down, and without ctl it is still never drawn`() { + // The receiver finds the story by its author and id; `st` and `gid` are not consulted. + val noSt = Content.parse("""{"fnc":1,"kind":"unpost","ctl":1,"sid":"s-1"}""") + assertEquals(Content.Unpost(storyId = "s-1"), noSt.unpost) + val inGroup = Content.parse("""{"fnc":1,"kind":"unpost","ctl":1,"st":1,"set":"set-1","gid":"g-1","later":{"x":1}}""") + assertEquals(Content.Unpost(setId = "set-1"), inGroup.unpost) + val bare = Content.parse("""{"fnc":1,"kind":"unpost","st":1,"sid":"s-1"}""") + assertFalse(bare.isRenderable) + assertEquals(Content.Unpost(storyId = "s-1"), bare.unpost) + } + + // -- What the vault keeps --------------------------------------------------------. + + private class MapStorage : Vault.Storage { + val records = LinkedHashMap<String, ByteArray>() + + override fun read(name: String) = records[name] + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + + override fun names() = records.keys.toList() + } + + @Test + fun `a story keeps when its words changed and who it went to, live and archived`() { + val vault = Vault(MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + val story = + Vault.Story( + id = "s-1", + authorId = 1, + mine = true, + ref = null, + caption = "new words", + postedAt = 1_700_000_000_000, + expiresAt = 4_000_000_000.0, + editedAt = 1_700_000_100_000, + sentTo = listOf(2, 3), + ) + vault.saveStories(listOf(story), nowSeconds = 1_700_000_000.0) + assertEquals(story, vault.loadStories().single()) + vault.saveStoryArchive(listOf(story)) + assertEquals(story, vault.storyArchive().single()) + + // A backup carries when its words changed, and never who it went to. + val shared = BackupPayload.toShared(vault.exportPayload()) + val sharedStory = shared.getJSONObject("stories").getJSONArray("1").getJSONObject(0) + assertEquals(1_700_000_100_000L, sharedStory.getLong("edited")) + assertFalse(sharedStory.has("to"), "who a story went to left the phone") + val restored = Vault(MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + restored.mergeImportedPayload(BackupPayload.fromShared(shared), nowSeconds = 1_700_000_000.0) + val back = restored.loadStories().single() + assertEquals(1_700_000_100_000L, back.editedAt) + assertNull(back.sentTo) + + val plain = story.copy(editedAt = null, sentTo = null) + vault.saveStories(listOf(plain), nowSeconds = 1_700_000_000.0) + assertEquals(plain, vault.loadStories().single(), "a story never edited, or received, has neither") + } +} diff --git a/games/build.gradle.kts b/games/build.gradle.kts new file mode 100644 index 0000000..67168b3 --- /dev/null +++ b/games/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + kotlin("jvm") + jacoco +} + +repositories { mavenCentral() } + +dependencies { + testImplementation(kotlin("test")) + // The vectors are JSON; the same parser `:engine` tests with. + testImplementation("org.json:json:20240303") +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.test { + useJUnitPlatform() + testLogging { + events("passed", "failed", "skipped") + showStandardStreams = true + } + // The deep perft counts run only when FLAMENET_GAMES_DEEP=1. Gradle does not track an + // environment variable by itself, so without this a cached pass would be replayed instead. + inputs.property("deepPerft", providers.environmentVariable("FLAMENET_GAMES_DEEP").orElse("")) +} + +tasks.withType<Test>().configureEach { finalizedBy(tasks.named("jacocoTestReport")) } +tasks.named<JacocoReport>("jacocoTestReport") { + dependsOn(tasks.named("test")) + reports { xml.required.set(true); html.required.set(true) } +} diff --git a/games/src/main/kotlin/io/flamenet/games/Checkers.kt b/games/src/main/kotlin/io/flamenet/games/Checkers.kt new file mode 100644 index 0000000..b6fc2db --- /dev/null +++ b/games/src/main/kotlin/io/flamenet/games/Checkers.kt @@ -0,0 +1,299 @@ +package io.flamenet.games + +import kotlin.math.abs + +/** + * An English draughts position on squares 1-32 in standard PDN numbering. + * + * Keeps the plies since the last capture or man move and the positions in that stretch, for the + * 80-ply draw and threefold repetition. + */ +class CheckersPosition internal constructor( + /** Index 0 is square 1. First's (dark) men are 1 and kings 2; second's are -1 and -2. */ + internal val squares: IntArray, + override val sideToMove: Side, + internal val quietPlies: Int, + earlier: List<Key>, +) : GamePosition() { + internal class Key( + val squares: IntArray, + val side: Side, + ) { + override fun equals(other: Any?): Boolean = + other is Key && side == other.side && squares.contentEquals(other.squares) + + override fun hashCode(): Int = squares.contentHashCode() * 31 + side.hashCode() + } + + /** [path] is the starting square, then every square the piece lands on (indexes, not numbers). */ + internal data class Move( + val path: List<Int>, + val captures: Boolean, + ) + + /** The positions since the last capture or man move, oldest first, ending with this one. */ + internal val history: List<Key> = earlier + Key(squares, sideToMove) + + override val type: GameType get() = GameType.CHECKERS + + private val repetitions: Int + get() { + val current = history.last() + return history.count { it == current } + } + + /** + * Every move the rules allow, without the automatic draws: all capturing sequences when any + * capture exists (not only the longest), otherwise all steps. + */ + private fun movesByTheRules(): List<Move> { + val us = if (sideToMove == Side.FIRST) 1 else -1 + val board = squares.copyOf() + val jumps = ArrayList<Move>() + for (from in 0 until 32) { + val piece = squares[from] + if (piece == 0 || (piece > 0) != (us > 0)) continue + collectJumps(board, from, piece, mutableListOf(from), jumps) + } + if (jumps.isNotEmpty()) return jumps + val steps = ArrayList<Move>() + for (from in 0 until 32) { + val piece = squares[from] + if (piece == 0 || (piece > 0) != (us > 0)) continue + for (heading in headings(piece)) { + val to = NEIGHBOUR[from][heading] + if (to >= 0 && squares[to] == 0) steps += Move(listOf(from, to), false) + } + } + return steps + } + + override fun outcome(): Outcome { + if (movesByTheRules().isEmpty()) return Outcome.Win(sideToMove.opponent, WinReason.NO_MOVES) + if (quietPlies >= 80) return Outcome.Draw(DrawReason.FORTY_MOVES) + if (repetitions >= 3) return Outcome.Draw(DrawReason.REPETITION) + return Outcome.Ongoing + } + + private fun legalMoveList(): List<Move> = + if (quietPlies >= 80 || repetitions >= 3) emptyList() else movesByTheRules() + + private fun notation(move: Move): String = + move.path.joinToString(if (move.captures) "x" else "-") { (it + 1).toString() } + + private fun applying(move: Move): CheckersPosition { + val board = squares.copyOf() + val piece = board[move.path[0]] + board[move.path[0]] = 0 + if (move.captures) { + for (i in 0 until move.path.size - 1) { + val from = move.path[i] + val to = move.path[i + 1] + val heading = (0 until 4).first { LANDING[from][it] == to } + board[NEIGHBOUR[from][heading]] = 0 + } + } + val end = move.path.last() + board[end] = if (crowns(piece, end)) piece * 2 else piece + val reset = move.captures || abs(piece) == 1 + return CheckersPosition( + board, + sideToMove.opponent, + if (reset) 0 else quietPlies + 1, + if (reset) emptyList() else history, + ) + } + + override fun legalNotations(): List<String> = legalMoveList().map(::notation) + + override fun applyNotation(text: String): MoveResult = + applyText(text, isWellFormed(text), { legalMoveList() }, ::notation, ::applying) + + override fun perft(depth: Int): Long = + perftOf(this, depth, CheckersPosition::legalMoveList, CheckersPosition::applying) + + override val board: Board + get() { + val cells = ArrayList<Piece?>(64) + val names = ArrayList<String?>(64) + for (row in 0 until 8) { + for (column in 0 until 8) { + // The first player sees the standard diagram turned round, own men at the bottom. + val square = index(7 - row, 7 - column) + if (square == null) { + cells += null + names += null + continue + } + val piece = squares[square] + cells += + if (piece == 0) { + null + } else { + Piece( + if (piece > 0) Side.FIRST else Side.SECOND, + if (abs(piece) == 2) PieceKind.KING else PieceKind.MAN, + ) + } + names += (square + 1).toString() + } + } + return Board(8, 8, cells, names) + } + + /** PDN FEN with the second (W) list first, as in `B:W21,...,32:B1,...,12`. */ + override val setup: String + get() { + fun list(sign: Int): String = + (0 until 32) + .filter { squares[it] != 0 && (squares[it] > 0) == (sign > 0) } + .joinToString(",") { (if (abs(squares[it]) == 2) "K" else "") + (it + 1) } + return "${if (sideToMove == Side.FIRST) "B" else "W"}:W${list(-1)}:B${list(1)}" + } + + override fun equals(other: Any?): Boolean = + other is CheckersPosition && quietPlies == other.quietPlies && history == other.history + + override fun hashCode(): Int = history.hashCode() * 31 + quietPlies + + override fun toString(): String = "CheckersPosition($setup)" + + companion object { + /** (row, column) steps. Headings 0 and 1 lead toward row 7, where first's men are crowned. */ + private val STEPS = + arrayOf(intArrayOf(1, -1), intArrayOf(1, 1), intArrayOf(-1, -1), intArrayOf(-1, 1)) + private val FIRST_MAN = intArrayOf(0, 1) + private val SECOND_MAN = intArrayOf(2, 3) + private val ANY_WAY = intArrayOf(0, 1, 2, 3) + + /** Rows as in the standard diagram: row 0 holds squares 1-4, row 7 holds 29-32. */ + private fun row(index: Int): Int = index / 4 + + private fun column(index: Int): Int = + if (row(index) % 2 == 0) 2 * (index % 4) + 1 else 2 * (index % 4) + + private fun index( + row: Int, + column: Int, + ): Int? = + if (row in 0..7 && column in 0..7 && (row + column) % 2 == 1) row * 4 + column / 2 else null + + /** The square one step away in each heading, or -1 off the board. */ + private val NEIGHBOUR: Array<IntArray> = + Array(32) { i -> + IntArray(4) { h -> index(row(i) + STEPS[h][0], column(i) + STEPS[h][1]) ?: -1 } + } + + /** Where a jump in each heading lands, or -1 off the board. */ + private val LANDING: Array<IntArray> = + Array(32) { i -> + IntArray(4) { h -> + val next = NEIGHBOUR[i][h] + if (next < 0) -1 else NEIGHBOUR[next][h] + } + } + + private fun headings(piece: Int): IntArray = + when (piece) { + 1 -> FIRST_MAN + -1 -> SECOND_MAN + else -> ANY_WAY + } + + /** Whether a man that ends on [index] is crowned there. */ + private fun crowns( + piece: Int, + index: Int, + ): Boolean = (piece == 1 && row(index) == 7) || (piece == -1 && row(index) == 0) + + /** A square number 1-32 written without leading zeros. */ + private fun number(text: String): Int? { + if (text.length !in 1..2 || text[0] == '0' || text.any { it !in '0'..'9' }) return null + return text.toInt().takeIf { it in 1..32 } + } + + /** + * `11-15` for a step, `15x24` or `15x24x31` for captures, squares 1-32 without leading zeros. + */ + internal fun isWellFormed(text: String): Boolean { + val step = '-' in text + if (step == ('x' in text)) return false + val parts = text.split(if (step) '-' else 'x') + if (if (step) parts.size != 2 else parts.size < 2) return false + return parts.all { number(it) != null } + } + + /** + * Adds every complete capturing sequence for the piece on [at]. A sequence goes on while + * the piece can jump again, and stops where a man is crowned. Captured pieces come off as + * they are jumped; with one-square kings that is the same as lifting them at the end. + */ + private fun collectJumps( + board: IntArray, + at: Int, + piece: Int, + path: MutableList<Int>, + found: MutableList<Move>, + ) { + var jumped = false + for (heading in headings(piece)) { + val over = NEIGHBOUR[at][heading] + val to = LANDING[at][heading] + if (to < 0 || board[to] != 0) continue + if (board[over] == 0 || (board[over] > 0) == (piece > 0)) continue + jumped = true + val taken = board[over] + board[over] = 0 + board[at] = 0 + board[to] = piece + path += to + if (crowns(piece, to)) { + found += Move(path.toList(), true) + } else { + collectJumps(board, to, piece, path, found) + } + path.removeAt(path.size - 1) + board[to] = 0 + board[at] = piece + board[over] = taken + } + if (!jumped && path.size > 1) found += Move(path.toList(), true) + } + + /** + * Reads PDN FEN: the side to move (B first, W second), then both piece lists in either + * order, K marking a king. A man on the row where it would have been crowned is refused. + */ + internal fun fromSetup(setup: String): CheckersPosition? { + val fields = setup.split(':') + if (fields.size != 3) return null + val side = + when (fields[0]) { + "B" -> Side.FIRST + "W" -> Side.SECOND + else -> return null + } + val board = IntArray(32) + val listed = HashSet<Char>() + for (field in fields.subList(1, 3)) { + val colour = field.firstOrNull() ?: return null + if ((colour != 'B' && colour != 'W') || !listed.add(colour)) return null + val sign = if (colour == 'B') 1 else -1 + val items = field.substring(1) + if (items.isEmpty()) continue + for (item in items.split(',')) { + val crowned = item.startsWith("K") + val number = number(if (crowned) item.substring(1) else item) ?: return null + if (board[number - 1] != 0) return null + val piece = if (crowned) sign * 2 else sign + if (crowns(piece, number - 1)) return null + board[number - 1] = piece + } + } + return CheckersPosition(board, side, 0, emptyList()) + } + + internal val START: CheckersPosition = + fromSetup("B:W21,22,23,24,25,26,27,28,29,30,31,32:B1,2,3,4,5,6,7,8,9,10,11,12")!! + } +} diff --git a/games/src/main/kotlin/io/flamenet/games/Chess.kt b/games/src/main/kotlin/io/flamenet/games/Chess.kt new file mode 100644 index 0000000..7b5ed0e --- /dev/null +++ b/games/src/main/kotlin/io/flamenet/games/Chess.kt @@ -0,0 +1,616 @@ +package io.flamenet.games + +import kotlin.math.abs + +/** + * A chess position. + * + * Besides the placement it keeps what the rules need: castling rights, the en passant square + * (only while an en passant capture is legal, which is also what repetition compares), the + * clocks, and the positions since the last capture or pawn move, for threefold repetition. + */ +class ChessPosition internal constructor( + /** Square 0 is a1, 7 is h1, 63 is h8. First's (white) pieces are positive, second's negative. */ + internal val squares: IntArray, + override val sideToMove: Side, + internal val castling: Int, + /** -1 when no en passant capture is legal. */ + internal val enPassant: Int, + internal val halfmoveClock: Int, + internal val fullmoveNumber: Int, + earlier: List<Key>, +) : GamePosition() { + /** What makes two positions the same for repetition. */ + internal class Key( + val squares: IntArray, + val side: Side, + val castling: Int, + val enPassant: Int, + ) { + override fun equals(other: Any?): Boolean = + other is Key && side == other.side && castling == other.castling && + enPassant == other.enPassant && squares.contentEquals(other.squares) + + override fun hashCode(): Int = + ((squares.contentHashCode() * 31 + side.hashCode()) * 31 + castling) * 31 + enPassant + } + + /** [promotion] is 0, or the code of the piece a pawn becomes. */ + internal data class Move( + val from: Int, + val to: Int, + val promotion: Int, + ) + + /** The positions since the last capture or pawn move, oldest first, ending with this one. */ + internal val history: List<Key> = earlier + Key(squares, sideToMove, castling, enPassant) + + override val type: GameType get() = GameType.CHESS + + private val sign: Int get() = if (sideToMove == Side.FIRST) 1 else -1 + + /** Whether the side to move is in check. */ + val inCheck: Boolean get() = isAttacked(squares, kingSquare(sign), -sign) + + // Every position has exactly one king a side: setup refuses anything else. + private fun kingSquare(sign: Int): Int = squares.indexOf(sign * KING) + + private val repetitions: Int + get() { + val current = history.last() + return history.count { it == current } + } + + /** K v K, K+B v K, K+N v K, and K+B v K+B with both bishops on one colour. Nothing else. */ + private val insufficientMaterial: Boolean + get() { + val pieces = ArrayList<Int>(2) + val places = ArrayList<Int>(2) + for (square in 0 until 64) { + val piece = squares[square] + if (piece == 0 || abs(piece) == KING) continue + pieces += piece + places += square + if (pieces.size > 2) return false + } + return when (pieces.size) { + 0 -> true + 1 -> abs(pieces[0]) == BISHOP || abs(pieces[0]) == KNIGHT + else -> + abs(pieces[0]) == BISHOP && abs(pieces[1]) == BISHOP && + (pieces[0] > 0) != (pieces[1] > 0) && colour(places[0]) == colour(places[1]) + } + } + + private val drawnAutomatically: Boolean + get() = insufficientMaterial || halfmoveClock >= 100 || repetitions >= 3 + + /** Every move the rules of movement allow, without the automatic draws. */ + private fun movesByTheRules(): List<Move> { + val us = sign + val king = kingSquare(us) + val board = squares.copyOf() + val legal = ArrayList<Move>() + for (move in pseudoLegalMoves()) { + val moving = board[move.from] + val taken = board[move.to] + board[move.to] = if (move.promotion != 0) us * move.promotion else moving + board[move.from] = 0 + var victim = -1 + if (abs(moving) == PAWN && move.to == enPassant && taken == 0 && + move.to % 8 != move.from % 8 + ) { + victim = move.to - 8 * us + board[victim] = 0 + } + val guarded = if (abs(moving) == KING) move.to else king + if (!isAttacked(board, guarded, -us)) legal += move + board[move.from] = moving + board[move.to] = taken + if (victim >= 0) board[victim] = -us * PAWN + } + return legal + } + + /** Moves by the pieces' rules of movement, before checking the mover's king is safe. */ + private fun pseudoLegalMoves(): List<Move> { + val moves = ArrayList<Move>(64) + for (from in 0 until 64) { + val piece = squares[from] + if (piece == 0 || (piece > 0) != (sign > 0)) continue + when (abs(piece)) { + PAWN -> addPawnMoves(moves, from) + KNIGHT -> addSteps(moves, from, KNIGHT_STEPS) + BISHOP -> addSlides(moves, from, DIAGONAL) + ROOK -> addSlides(moves, from, STRAIGHT) + QUEEN -> { + addSlides(moves, from, STRAIGHT) + addSlides(moves, from, DIAGONAL) + } + else -> { + addSteps(moves, from, KING_STEPS) + addCastling(moves, from) + } + } + } + return moves + } + + private fun addPawnMoves( + moves: MutableList<Move>, + from: Int, + ) { + val us = sign + val file = from % 8 + val rank = from / 8 + // A pawn never stands on its last rank, so the rank ahead is always on the board. + val ahead = rank + us + val promotes = ahead == (if (us > 0) 7 else 0) + + fun add(to: Int) { + if (promotes) { + for (piece in PROMOTIONS) moves += Move(from, to, piece) + } else { + moves += Move(from, to, 0) + } + } + val one = ahead * 8 + file + if (squares[one] == 0) { + add(one) + val two = one + 8 * us + if (rank == (if (us > 0) 1 else 6) && squares[two] == 0) moves += Move(from, two, 0) + } + for (step in intArrayOf(-1, 1)) { + val target = file + step + if (target !in 0..7) continue + val to = ahead * 8 + target + val there = squares[to] + if (there != 0 && (there > 0) != (us > 0)) { + add(to) + } else if (there == 0 && to == enPassant) { + moves += Move(from, to, 0) + } + } + } + + private fun addSteps( + moves: MutableList<Move>, + from: Int, + steps: Array<IntArray>, + ) { + val file = from % 8 + val rank = from / 8 + for (step in steps) { + val f = file + step[0] + val r = rank + step[1] + if (f !in 0..7 || r !in 0..7) continue + val there = squares[r * 8 + f] + if (there == 0 || (there > 0) != (sign > 0)) moves += Move(from, r * 8 + f, 0) + } + } + + private fun addSlides( + moves: MutableList<Move>, + from: Int, + directions: Array<IntArray>, + ) { + val file = from % 8 + val rank = from / 8 + for (direction in directions) { + var f = file + direction[0] + var r = rank + direction[1] + while (f in 0..7 && r in 0..7) { + val there = squares[r * 8 + f] + if (there == 0 || (there > 0) != (sign > 0)) moves += Move(from, r * 8 + f, 0) + if (there != 0) break + f += direction[0] + r += direction[1] + } + } + } + + private fun addCastling( + moves: MutableList<Move>, + home: Int, + ) { + val us = sign + if (home != (if (us > 0) 4 else 60)) return + val kingside = if (us > 0) WHITE_KINGSIDE else BLACK_KINGSIDE + val queenside = if (us > 0) WHITE_QUEENSIDE else BLACK_QUEENSIDE + val rook = us * ROOK + + fun attacked(square: Int) = isAttacked(squares, square, -us) + // Not out of, through or into check. On the queen's side b1 (b8) only has to be empty. + if ((castling and kingside) != 0 && squares[home + 1] == 0 && squares[home + 2] == 0 && + squares[home + 3] == rook && !attacked(home) && !attacked(home + 1) && + !attacked(home + 2) + ) { + moves += Move(home, home + 2, 0) + } + if ((castling and queenside) != 0 && squares[home - 1] == 0 && squares[home - 2] == 0 && + squares[home - 3] == 0 && squares[home - 4] == rook && !attacked(home) && + !attacked(home - 1) && !attacked(home - 2) + ) { + moves += Move(home, home - 2, 0) + } + } + + override fun outcome(): Outcome { + if (movesByTheRules().isEmpty()) { + return if (inCheck) { + Outcome.Win(sideToMove.opponent, WinReason.CHECKMATE) + } else { + Outcome.Draw(DrawReason.STALEMATE) + } + } + if (insufficientMaterial) return Outcome.Draw(DrawReason.INSUFFICIENT_MATERIAL) + if (halfmoveClock >= 100) return Outcome.Draw(DrawReason.FIFTY_MOVES) + if (repetitions >= 3) return Outcome.Draw(DrawReason.REPETITION) + return Outcome.Ongoing + } + + private fun legalMoveList(): List<Move> = if (drawnAutomatically) emptyList() else movesByTheRules() + + private fun notation(move: Move): String { + val promotion = if (move.promotion != 0) LETTERS[move.promotion - 1].toString() else "" + return name(move.from) + name(move.to) + promotion + } + + private fun applying(move: Move): ChessPosition { + val us = sign + val board = squares.copyOf() + val moving = board[move.from] + val kind = abs(moving) + var captured = board[move.to] != 0 + board[move.to] = if (move.promotion != 0) us * move.promotion else moving + board[move.from] = 0 + if (kind == PAWN && move.to == enPassant && !captured && move.to % 8 != move.from % 8) { + board[move.to - 8 * us] = 0 + captured = true + } + if (kind == KING && abs(move.to - move.from) == 2) { + // Castling: the rook lands on the square the king crossed. + val rookFrom = if (move.to > move.from) move.from + 3 else move.from - 4 + val rookTo = if (move.to > move.from) move.from + 1 else move.from - 1 + board[rookTo] = board[rookFrom] + board[rookFrom] = 0 + } + var rights = castling + if (kind == KING) { + val both = + if (us > 0) WHITE_KINGSIDE or WHITE_QUEENSIDE else BLACK_KINGSIDE or BLACK_QUEENSIDE + rights = rights and both.inv() + } + rights = rights and rightTiedTo(move.from).inv() and rightTiedTo(move.to).inv() + var target = -1 + if (kind == PAWN && abs(move.to - move.from) == 16) { + val skipped = (move.from + move.to) / 2 + if (enPassantIsLegal(board, skipped, -us)) target = skipped + } + val reset = captured || kind == PAWN + return ChessPosition( + board, + sideToMove.opponent, + rights, + target, + if (reset) 0 else halfmoveClock + 1, + fullmoveNumber + if (sideToMove == Side.SECOND) 1 else 0, + if (reset) emptyList() else history, + ) + } + + override fun legalNotations(): List<String> = legalMoveList().map(::notation) + + override fun applyNotation(text: String): MoveResult = + applyText(text, isWellFormed(text), { legalMoveList() }, ::notation, ::applying) + + override fun perft(depth: Int): Long = + perftOf(this, depth, ChessPosition::legalMoveList, ChessPosition::applying) + + override val board: Board + get() { + val cells = ArrayList<Piece?>(64) + val names = ArrayList<String?>(64) + for (row in 0 until 8) { + for (column in 0 until 8) { + val square = (7 - row) * 8 + column + val piece = squares[square] + cells += + if (piece == 0) { + null + } else { + Piece(if (piece > 0) Side.FIRST else Side.SECOND, KINDS[abs(piece) - 1]) + } + names += name(square) + } + } + return Board(8, 8, cells, names) + } + + /** FEN, with the en passant square shown only while the capture is legal. */ + override val setup: String + get() { + val text = StringBuilder() + for (rank in 7 downTo 0) { + var empty = 0 + for (file in 0 until 8) { + val piece = squares[rank * 8 + file] + if (piece == 0) { + empty++ + continue + } + if (empty > 0) text.append(empty) + empty = 0 + val letter = LETTERS[abs(piece) - 1] + text.append(if (piece > 0) letter.uppercaseChar() else letter) + } + if (empty > 0) text.append(empty) + if (rank > 0) text.append('/') + } + val rights = + buildString { + if ((castling and WHITE_KINGSIDE) != 0) append('K') + if ((castling and WHITE_QUEENSIDE) != 0) append('Q') + if ((castling and BLACK_KINGSIDE) != 0) append('k') + if ((castling and BLACK_QUEENSIDE) != 0) append('q') + } + val side = if (sideToMove == Side.FIRST) "w" else "b" + val target = if (enPassant >= 0) name(enPassant) else "-" + return "$text $side ${rights.ifEmpty { "-" }} $target $halfmoveClock $fullmoveNumber" + } + + override fun equals(other: Any?): Boolean = + other is ChessPosition && halfmoveClock == other.halfmoveClock && + fullmoveNumber == other.fullmoveNumber && history == other.history + + override fun hashCode(): Int = (history.hashCode() * 31 + halfmoveClock) * 31 + fullmoveNumber + + override fun toString(): String = "ChessPosition($setup)" + + companion object { + private const val PAWN = 1 + private const val KNIGHT = 2 + private const val BISHOP = 3 + private const val ROOK = 4 + private const val QUEEN = 5 + private const val KING = 6 + + private const val WHITE_KINGSIDE = 1 + private const val WHITE_QUEENSIDE = 2 + private const val BLACK_KINGSIDE = 4 + private const val BLACK_QUEENSIDE = 8 + + /** Indexed by piece code minus one. */ + private val LETTERS = charArrayOf('p', 'n', 'b', 'r', 'q', 'k') + private val KINDS = + arrayOf( + PieceKind.PAWN, + PieceKind.KNIGHT, + PieceKind.BISHOP, + PieceKind.ROOK, + PieceKind.QUEEN, + PieceKind.KING, + ) + + /** The order promotions are listed in. */ + private val PROMOTIONS = intArrayOf(QUEEN, ROOK, BISHOP, KNIGHT) + + /** (file, rank) steps. */ + private val KNIGHT_STEPS = steps(1, 2, 2, 1, 2, -1, 1, -2, -1, -2, -2, -1, -2, 1, -1, 2) + private val KING_STEPS = steps(0, 1, 1, 1, 1, 0, 1, -1, 0, -1, -1, -1, -1, 0, -1, 1) + private val STRAIGHT = steps(0, 1, 0, -1, 1, 0, -1, 0) + private val DIAGONAL = steps(1, 1, -1, 1, 1, -1, -1, -1) + + private fun steps(vararg pairs: Int): Array<IntArray> = + Array(pairs.size / 2) { intArrayOf(pairs[2 * it], pairs[2 * it + 1]) } + + private fun name(square: Int): String = "${'a' + square % 8}${'1' + square / 8}" + + private fun squareNamed(text: String): Int? { + if (text.length != 2 || text[0] !in 'a'..'h' || text[1] !in '1'..'8') return null + return (text[1] - '1') * 8 + (text[0] - 'a') + } + + private fun code(character: Char): Int? { + if (character.code > 127) return null + val index = LETTERS.indexOf(character.lowercaseChar()) + if (index < 0) return null + return if (character.isUpperCase()) index + 1 else -(index + 1) + } + + /** 0 for a dark square, 1 for a light one. */ + private fun colour(square: Int): Int = (square % 8 + square / 8) % 2 + + /** The castling right lost when a piece leaves or is taken on this corner. */ + private fun rightTiedTo(square: Int): Int = + when (square) { + 0 -> WHITE_QUEENSIDE + 7 -> WHITE_KINGSIDE + 56 -> BLACK_QUEENSIDE + 63 -> BLACK_KINGSIDE + else -> 0 + } + + internal fun isWellFormed(text: String): Boolean { + if (text.length != 4 && text.length != 5) return false + if (text[0] !in 'a'..'h' || text[1] !in '1'..'8' || text[2] !in 'a'..'h' || + text[3] !in '1'..'8' + ) { + return false + } + return text.length == 4 || text[4] in "qrbn" + } + + /** Whether a piece of the side with [sign] (1 white, -1 black) attacks [square]. */ + private fun isAttacked( + board: IntArray, + square: Int, + sign: Int, + ): Boolean { + val file = square % 8 + val rank = square / 8 + // A pawn attacks the rank ahead of it, so a white attacker stands one rank lower. + val pawnRank = rank - sign + if (pawnRank in 0..7) { + for (step in intArrayOf(-1, 1)) { + val f = file + step + if (f in 0..7 && board[pawnRank * 8 + f] == sign * PAWN) return true + } + } + if (reaches(board, file, rank, KNIGHT_STEPS, sign * KNIGHT) || + reaches(board, file, rank, KING_STEPS, sign * KING) + ) { + return true + } + return slides(board, file, rank, STRAIGHT, sign * ROOK, sign * QUEEN) || + slides(board, file, rank, DIAGONAL, sign * BISHOP, sign * QUEEN) + } + + private fun reaches( + board: IntArray, + file: Int, + rank: Int, + steps: Array<IntArray>, + piece: Int, + ): Boolean { + for (step in steps) { + val f = file + step[0] + val r = rank + step[1] + if (f in 0..7 && r in 0..7 && board[r * 8 + f] == piece) return true + } + return false + } + + /** Whether the first piece met along any direction is [slider] or [queen]. */ + private fun slides( + board: IntArray, + file: Int, + rank: Int, + directions: Array<IntArray>, + slider: Int, + queen: Int, + ): Boolean { + for (direction in directions) { + var f = file + direction[0] + var r = rank + direction[1] + while (f in 0..7 && r in 0..7) { + val piece = board[r * 8 + f] + if (piece != 0) { + if (piece == slider || piece == queen) return true + break + } + f += direction[0] + r += direction[1] + } + } + return false + } + + /** Whether the side with [sign] can legally capture en passant onto [target]. */ + private fun enPassantIsLegal( + board: IntArray, + target: Int, + sign: Int, + ): Boolean { + val victim = target - 8 * sign + val king = board.indexOf(sign * KING) + if (king < 0) return false + for (step in intArrayOf(-1, 1)) { + val file = victim % 8 + step + if (file !in 0..7) continue + val from = victim - victim % 8 + file + if (board[from] != sign * PAWN) continue + val after = board.copyOf() + after[target] = sign * PAWN + after[from] = 0 + after[victim] = 0 + if (!isAttacked(after, king, -sign)) return true + } + return false + } + + /** + * Reads FEN (four or six fields). Refuses positions the rules cannot reach: a side without + * exactly one king, a pawn on the first or last rank, the side not to move in check, or an + * en passant square with no pawn that just made the double step. + */ + internal fun fromSetup(setup: String): ChessPosition? { + val fields = setup.split(' ') + if (fields.size != 6 && fields.size != 4) return null + val rows = fields[0].split('/') + if (rows.size != 8) return null + val board = IntArray(64) + for ((index, row) in rows.withIndex()) { + val rank = 7 - index + var file = 0 + for (character in row) { + if (character in '1'..'8') { + file += character - '0' + } else { + val code = code(character) ?: return null + if (file >= 8) return null + board[rank * 8 + file] = code + file++ + } + if (file > 8) return null + } + if (file != 8) return null + } + val side = + when (fields[1]) { + "w" -> Side.FIRST + "b" -> Side.SECOND + else -> return null + } + var rights = 0 + if (fields[2] != "-") { + if (fields[2].isEmpty()) return null + for (character in fields[2]) { + val bit = + when (character) { + 'K' -> WHITE_KINGSIDE + 'Q' -> WHITE_QUEENSIDE + 'k' -> BLACK_KINGSIDE + 'q' -> BLACK_QUEENSIDE + else -> return null + } + if ((rights and bit) != 0) return null + rights = rights or bit + } + } + // A right without its king and rook at home could never be used; drop it. + if (board[4] != KING || board[7] != ROOK) rights = rights and WHITE_KINGSIDE.inv() + if (board[4] != KING || board[0] != ROOK) rights = rights and WHITE_QUEENSIDE.inv() + if (board[60] != -KING || board[63] != -ROOK) rights = rights and BLACK_KINGSIDE.inv() + if (board[60] != -KING || board[56] != -ROOK) rights = rights and BLACK_QUEENSIDE.inv() + + if (board.count { it == KING } != 1 || board.count { it == -KING } != 1) return null + for (file in 0 until 8) { + if (abs(board[file]) == PAWN || abs(board[56 + file]) == PAWN) return null + } + val us = if (side == Side.FIRST) 1 else -1 + if (isAttacked(board, board.indexOf(-us * KING), us)) return null + + var target = -1 + if (fields[3] != "-") { + val square = squareNamed(fields[3]) ?: return null + if (square / 8 != (if (us > 0) 5 else 2)) return null + // The pawn that stepped two squares stands in front of the target, its start is empty. + val pushed = square - 8 * us + val origin = square + 8 * us + if (board[pushed] != -us * PAWN || board[square] != 0 || board[origin] != 0) return null + if (enPassantIsLegal(board, square, us)) target = square + } + var clock = 0 + var fullmove = 1 + if (fields.size == 6) { + clock = fields[4].toIntOrNull()?.takeIf { it >= 0 } ?: return null + fullmove = fields[5].toIntOrNull()?.takeIf { it >= 1 } ?: return null + } + return ChessPosition(board, side, rights, target, clock, fullmove, emptyList()) + } + + internal val START: ChessPosition = + fromSetup("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")!! + } +} diff --git a/games/src/main/kotlin/io/flamenet/games/Games.kt b/games/src/main/kotlin/io/flamenet/games/Games.kt new file mode 100644 index 0000000..efe2586 --- /dev/null +++ b/games/src/main/kotlin/io/flamenet/games/Games.kt @@ -0,0 +1,292 @@ +package io.flamenet.games + +/** The games played inside chats, by their wire names (DESIGN.md section 2). */ +enum class GameType( + val wire: String, + /** The longest log accepted for this game. A longer one is refused before any of it is played. */ + val maxPlies: Int, +) { + CHESS("chess", 600), + CHECKERS("checkers", 400), + REVERSI("reversi", 130), + TICTACTOE("tictactoe", 9), + ; + + /** + * The side that played entry [index] (zero-based) of a log played from the start. + * + * Every entry, a reversi `pass` included, is one ply by the side to move, so the sides + * alternate and entry 0 is always [Side.FIRST]'s. A receiver checks each new entry in a + * peer's log against the peer's side with this. + */ + fun sideOfPly(index: Int): Side = if (index % 2 == 0) Side.FIRST else Side.SECOND + + companion object { + fun fromWire(wire: String): GameType? = entries.firstOrNull { it.wire == wire } + } +} + +/** Who plays. FIRST is chess white, checkers dark, reversi black and tic-tac-toe X. */ +enum class Side( + val wire: String, +) { + FIRST("first"), + SECOND("second"), + ; + + val opponent: Side get() = if (this == FIRST) SECOND else FIRST + + companion object { + fun fromWire(wire: String): Side? = entries.firstOrNull { it.wire == wire } + } +} + +/** Why a game was won. [wire] is the name used in `games-v1.json`. */ +enum class WinReason( + val wire: String, +) { + CHECKMATE("checkmate"), + + /** Checkers: the side to move has no legal move, which includes having no pieces left. */ + NO_MOVES("no-moves"), + + /** Reversi: neither side can move and the winner has more discs. */ + MORE_DISCS("more-discs"), + THREE_IN_A_ROW("three-in-a-row"), +} + +/** Why a game was drawn. [wire] is the name used in `games-v1.json`. */ +enum class DrawReason( + val wire: String, +) { + STALEMATE("stalemate"), + INSUFFICIENT_MATERIAL("insufficient-material"), + + /** Chess: 100 plies without a capture or a pawn move. */ + FIFTY_MOVES("fifty-moves"), + + /** Checkers: 80 plies without a capture or a man move. */ + FORTY_MOVES("forty-moves"), + + /** The same position for the third time. */ + REPETITION("repetition"), + + /** Reversi: neither side can move and the discs are level. */ + EQUAL_DISCS("equal-discs"), + FULL_BOARD("full-board"), +} + +/** + * How a position stands. Resigning and agreed draws travel on the wire; only the natural ends + * that both sides compute from the log are here. + */ +sealed interface Outcome { + val isOver: Boolean get() = this != Ongoing + + data object Ongoing : Outcome + + data class Win( + val side: Side, + val reason: WinReason, + ) : Outcome + + data class Draw( + val reason: DrawReason, + ) : Outcome +} + +/** Why a move was refused. */ +enum class MoveError( + val wire: String, +) { + /** Not written in the game's notation, for example `E2E4` or `11 - 15`. */ + MALFORMED("malformed"), + + /** Written correctly but not legal here. Every move is illegal once the game is over. */ + ILLEGAL("illegal"), +} + +/** The result of [Games.apply]. */ +sealed interface MoveResult { + data class Applied( + val position: GamePosition, + ) : MoveResult + + data class Rejected( + val error: MoveError, + ) : MoveResult +} + +/** The result of [Games.replay]. */ +sealed interface ReplayResult { + data class Replayed( + val position: GamePosition, + ) : ReplayResult + + /** The log is longer than [GameType.maxPlies] allows. */ + data class TooLong( + val plies: Int, + val max: Int, + ) : ReplayResult + + /** The entry at zero-based index [ply] of the log was refused. */ + data class Failed( + val ply: Int, + val error: MoveError, + ) : ReplayResult +} + +/** What a piece is. Checkers uses MAN and KING, reversi DISC, tic-tac-toe MARK. */ +enum class PieceKind { PAWN, KNIGHT, BISHOP, ROOK, QUEEN, KING, MAN, DISC, MARK } + +/** One piece on the board. */ +data class Piece( + val side: Side, + val kind: PieceKind, +) + +/** + * A board for drawing. Row 0 is the top row as the first player sees the board: chess has rank + * 8 at the top, checkers has squares 29-32 there (dark's men start at the bottom), reversi has + * rank 1 there, tic-tac-toe has cells 1-3. Turn it round to show the second player's view. + */ +data class Board( + val rows: Int, + val columns: Int, + /** Row-major from the top-left. */ + val cells: List<Piece?>, + /** The wire name of each cell (`e4`, `15`, `d3`, `5`), null where nothing can stand. */ + val squares: List<String?>, +) { + fun pieceAt( + row: Int, + column: Int, + ): Piece? = cells[row * columns + column] + + fun squareAt( + row: Int, + column: Int, + ): String? = squares[row * columns + column] +} + +/** A position in one of the four games. Values are immutable; applying a move makes a new one. */ +sealed class GamePosition { + abstract val type: GameType + abstract val sideToMove: Side + abstract val board: Board + + /** This position in its game's setup notation; see [Games.position]. */ + abstract val setup: String + + internal abstract fun outcome(): Outcome + + internal abstract fun legalNotations(): List<String> + + internal abstract fun applyNotation(text: String): MoveResult + + internal abstract fun perft(depth: Int): Long +} + +/** + * The rules engine for the chat games. + * + * Moves are strings in the wire notation of DESIGN.md section 2. [apply] accepts exactly the + * strings [legalMoves] returns, so a log that one app wrote replays identically on the other. + */ +object Games { + fun start(type: GameType): GamePosition = + when (type) { + GameType.CHESS -> ChessPosition.START + GameType.CHECKERS -> CheckersPosition.START + GameType.REVERSI -> ReversiPosition.START + GameType.TICTACTOE -> TicTacToePosition.START + } + + /** + * A position from a setup string, for tests and tools; the wire never carries one. + * + * Chess takes FEN. Checkers takes PDN FEN such as `B:W21,22:BK1,9` (B is the first, dark + * side; K marks a king). Reversi takes eight rows of `x` (first), `o` and `-` from rank 1 + * to rank 8 split by `/`, a space and the side to move, `x` or `o`; tic-tac-toe the same + * with three rows of three. Returns null for anything malformed or impossible. + */ + fun position( + type: GameType, + setup: String, + ): GamePosition? = + when (type) { + GameType.CHESS -> ChessPosition.fromSetup(setup) + GameType.CHECKERS -> CheckersPosition.fromSetup(setup) + GameType.REVERSI -> ReversiPosition.fromSetup(setup) + GameType.TICTACTOE -> TicTacToePosition.fromSetup(setup) + } + + /** Every legal move, empty once the game is over. */ + fun legalMoves(position: GamePosition): List<String> = position.legalNotations() + + fun apply( + position: GamePosition, + move: String, + ): MoveResult = position.applyNotation(move) + + fun outcome(position: GamePosition): Outcome = position.outcome() + + /** The position after a whole log played from the start. */ + fun replay( + type: GameType, + log: List<String>, + ): ReplayResult { + if (log.size > type.maxPlies) return ReplayResult.TooLong(log.size, type.maxPlies) + return replay(start(type), log) + } + + /** The position after [moves] played from [from]. A failure names the index in [moves]. */ + fun replay( + from: GamePosition, + moves: List<String>, + ): ReplayResult { + var current = from + for ((index, move) in moves.withIndex()) { + when (val result = apply(current, move)) { + is MoveResult.Applied -> current = result.position + is MoveResult.Rejected -> return ReplayResult.Failed(index, result.error) + } + } + return ReplayResult.Replayed(current) + } + + /** The number of move sequences of length [depth], for checking move generation. */ + fun perft( + position: GamePosition, + depth: Int, + ): Long = position.perft(depth) +} + +/** Applies [text] if it is well formed and names one of the legal [moves]. */ +internal inline fun <M> applyText( + text: String, + wellFormed: Boolean, + moves: () -> List<M>, + notation: (M) -> String, + applying: (M) -> GamePosition, +): MoveResult { + if (!wellFormed) return MoveResult.Rejected(MoveError.MALFORMED) + for (move in moves()) { + if (notation(move) == text) return MoveResult.Applied(applying(move)) + } + return MoveResult.Rejected(MoveError.ILLEGAL) +} + +internal fun <M, P> perftOf( + position: P, + depth: Int, + moves: (P) -> List<M>, + applying: (P, M) -> P, +): Long { + if (depth <= 0) return 1 + val list = moves(position) + if (depth == 1) return list.size.toLong() + var total = 0L + for (move in list) total += perftOf(applying(position, move), depth - 1, moves, applying) + return total +} diff --git a/games/src/main/kotlin/io/flamenet/games/Reversi.kt b/games/src/main/kotlin/io/flamenet/games/Reversi.kt new file mode 100644 index 0000000..b182f9f --- /dev/null +++ b/games/src/main/kotlin/io/flamenet/games/Reversi.kt @@ -0,0 +1,166 @@ +package io.flamenet.games + +/** + * A reversi position. Files a-h run left to right and ranks 1-8 top to bottom as the first + * player draws the board. + */ +class ReversiPosition internal constructor( + /** + * Index is row * 8 + column: row 0 is rank 1, column 0 is file a. First's (black) discs are + * 1, second's (white) -1. + */ + internal val squares: IntArray, + override val sideToMove: Side, +) : GamePosition() { + override val type: GameType get() = GameType.REVERSI + + private val sign: Int get() = if (sideToMove == Side.FIRST) 1 else -1 + + override fun outcome(): Outcome { + if (legalMoveList().isNotEmpty()) return Outcome.Ongoing + val first = squares.count { it == 1 } + val second = squares.count { it == -1 } + if (first == second) return Outcome.Draw(DrawReason.EQUAL_DISCS) + return Outcome.Win(if (first > second) Side.FIRST else Side.SECOND, WinReason.MORE_DISCS) + } + + /** + * Placements, or [PASS] only when there are none and the opponent has one. Empty when neither + * side can place, which ends the game. + */ + private fun legalMoveList(): List<Int> { + val mine = placements(squares, sign) + if (mine.isNotEmpty()) return mine + return if (placements(squares, -sign).isEmpty()) emptyList() else listOf(PASS) + } + + private fun notation(move: Int): String = if (move == PASS) "pass" else name(move) + + private fun applying(move: Int): ReversiPosition { + if (move == PASS) return ReversiPosition(squares, sideToMove.opponent) + val board = squares.copyOf() + board[move] = sign + for (flipped in flips(squares, move, sign)) board[flipped] = sign + return ReversiPosition(board, sideToMove.opponent) + } + + override fun legalNotations(): List<String> = legalMoveList().map(::notation) + + override fun applyNotation(text: String): MoveResult = + applyText(text, isWellFormed(text), { legalMoveList() }, ::notation, ::applying) + + override fun perft(depth: Int): Long = + perftOf(this, depth, ReversiPosition::legalMoveList, ReversiPosition::applying) + + override val board: Board + get() = + Board( + 8, + 8, + squares.map { + if (it == 0) null else Piece(if (it > 0) Side.FIRST else Side.SECOND, PieceKind.DISC) + }, + (0 until 64).map(::name), + ) + + override val setup: String + get() = + (0 until 8).joinToString("/") { row -> + (0 until 8).joinToString("") { column -> + when (squares[row * 8 + column]) { + 1 -> "x" + -1 -> "o" + else -> "-" + } + } + } + if (sideToMove == Side.FIRST) " x" else " o" + + override fun equals(other: Any?): Boolean = + other is ReversiPosition && sideToMove == other.sideToMove && + squares.contentEquals(other.squares) + + override fun hashCode(): Int = squares.contentHashCode() * 31 + sideToMove.hashCode() + + override fun toString(): String = "ReversiPosition($setup)" + + companion object { + private const val PASS = -1 + + /** (row, column) steps. */ + private val STEPS = + arrayOf( + intArrayOf(-1, -1), + intArrayOf(-1, 0), + intArrayOf(-1, 1), + intArrayOf(0, -1), + intArrayOf(0, 1), + intArrayOf(1, -1), + intArrayOf(1, 0), + intArrayOf(1, 1), + ) + + private fun name(index: Int): String = "${'a' + index % 8}${index / 8 + 1}" + + internal fun isWellFormed(text: String): Boolean = + text == "pass" || (text.length == 2 && text[0] in 'a'..'h' && text[1] in '1'..'8') + + /** The discs a placement on [index] by the side with [sign] would turn over. */ + private fun flips( + board: IntArray, + index: Int, + sign: Int, + ): List<Int> { + if (board[index] != 0) return emptyList() + val turned = ArrayList<Int>() + for (step in STEPS) { + var row = index / 8 + step[0] + var column = index % 8 + step[1] + val line = ArrayList<Int>() + while (row in 0..7 && column in 0..7 && board[row * 8 + column] == -sign) { + line += row * 8 + column + row += step[0] + column += step[1] + } + val closed = row in 0..7 && column in 0..7 && board[row * 8 + column] == sign + if (line.isNotEmpty() && closed) { + turned += line + } + } + return turned + } + + private fun placements( + board: IntArray, + sign: Int, + ): List<Int> = (0 until 64).filter { flips(board, it, sign).isNotEmpty() } + + /** + * Reads eight rows of `x`, `o` and `-` from rank 1 to rank 8 split by `/`, a space, and + * the side to move. + */ + internal fun fromSetup(setup: String): ReversiPosition? { + val fields = setup.split(' ') + if (fields.size != 2) return null + val rows = fields[0].split('/') + if (rows.size != 8 || rows.any { it.length != 8 }) return null + val board = IntArray(64) + for ((index, character) in rows.joinToString("").withIndex()) { + board[index] = + when (character) { + 'x' -> 1 + 'o' -> -1 + '-' -> 0 + else -> return null + } + } + return when (fields[1]) { + "x" -> ReversiPosition(board, Side.FIRST) + "o" -> ReversiPosition(board, Side.SECOND) + else -> null + } + } + + internal val START: ReversiPosition = + fromSetup("--------/--------/--------/---ox---/---xo---/--------/--------/-------- x")!! + } +} diff --git a/games/src/main/kotlin/io/flamenet/games/TicTacToe.kt b/games/src/main/kotlin/io/flamenet/games/TicTacToe.kt new file mode 100644 index 0000000..38afcfd --- /dev/null +++ b/games/src/main/kotlin/io/flamenet/games/TicTacToe.kt @@ -0,0 +1,128 @@ +package io.flamenet.games + +/** A tic-tac-toe position. Cells are named 1-9 row by row from the top-left; X moves first. */ +class TicTacToePosition internal constructor( + /** Cell 0 is `1`. X (first) is 1, O (second) is -1. */ + internal val cells: IntArray, + override val sideToMove: Side, +) : GamePosition() { + override val type: GameType get() = GameType.TICTACTOE + + private val winner: Side? + get() { + for (line in LINES) { + val mark = cells[line[0]] + if (mark != 0 && mark == cells[line[1]] && mark == cells[line[2]]) { + return if (mark > 0) Side.FIRST else Side.SECOND + } + } + return null + } + + override fun outcome(): Outcome { + val side = this.winner + if (side != null) return Outcome.Win(side, WinReason.THREE_IN_A_ROW) + return if (cells.contains(0)) Outcome.Ongoing else Outcome.Draw(DrawReason.FULL_BOARD) + } + + private fun legalMoveList(): List<Int> = + if (winner != null) emptyList() else (0 until 9).filter { cells[it] == 0 } + + private fun notation(move: Int): String = (move + 1).toString() + + private fun applying(move: Int): TicTacToePosition { + val next = cells.copyOf() + next[move] = if (sideToMove == Side.FIRST) 1 else -1 + return TicTacToePosition(next, sideToMove.opponent) + } + + override fun legalNotations(): List<String> = legalMoveList().map(::notation) + + override fun applyNotation(text: String): MoveResult = + applyText(text, isWellFormed(text), { legalMoveList() }, ::notation, ::applying) + + override fun perft(depth: Int): Long = + perftOf(this, depth, TicTacToePosition::legalMoveList, TicTacToePosition::applying) + + override val board: Board + get() = + Board( + 3, + 3, + cells.map { + if (it == 0) null else Piece(if (it > 0) Side.FIRST else Side.SECOND, PieceKind.MARK) + }, + (1..9).map { it.toString() }, + ) + + override val setup: String + get() = + (0 until 3).joinToString("/") { row -> + (0 until 3).joinToString("") { column -> + when (cells[row * 3 + column]) { + 1 -> "x" + -1 -> "o" + else -> "-" + } + } + } + if (sideToMove == Side.FIRST) " x" else " o" + + override fun equals(other: Any?): Boolean = + other is TicTacToePosition && sideToMove == other.sideToMove && cells.contentEquals(other.cells) + + override fun hashCode(): Int = cells.contentHashCode() * 31 + sideToMove.hashCode() + + override fun toString(): String = "TicTacToePosition($setup)" + + companion object { + private val LINES = + arrayOf( + intArrayOf(0, 1, 2), + intArrayOf(3, 4, 5), + intArrayOf(6, 7, 8), + intArrayOf(0, 3, 6), + intArrayOf(1, 4, 7), + intArrayOf(2, 5, 8), + intArrayOf(0, 4, 8), + intArrayOf(2, 4, 6), + ) + + internal fun isWellFormed(text: String): Boolean = text.length == 1 && text[0] in '1'..'9' + + /** + * Reads three rows of `x`, `o` and `-` split by `/`, a space, and the side to move, which + * must be the one the counts say; a board where both sides have a line is refused. + */ + internal fun fromSetup(setup: String): TicTacToePosition? { + val fields = setup.split(' ') + if (fields.size != 2) return null + val rows = fields[0].split('/') + if (rows.size != 3 || rows.any { it.length != 3 }) return null + val cells = IntArray(9) + for ((index, character) in rows.joinToString("").withIndex()) { + cells[index] = + when (character) { + 'x' -> 1 + 'o' -> -1 + '-' -> 0 + else -> return null + } + } + val difference = cells.count { it == 1 } - cells.count { it == -1 } + val side = + when { + fields[1] == "x" && difference == 0 -> Side.FIRST + fields[1] == "o" && difference == 1 -> Side.SECOND + else -> return null + } + val lines = + LINES.filter { + cells[it[0]] != 0 && cells[it[0]] == cells[it[1]] && cells[it[1]] == cells[it[2]] + } + if (lines.map { cells[it[0]] }.toSet().size > 1) return null + return TicTacToePosition(cells, side) + } + + internal val START = TicTacToePosition(IntArray(9), Side.FIRST) + } +} diff --git a/games/src/test/kotlin/io/flamenet/games/GamesTest.kt b/games/src/test/kotlin/io/flamenet/games/GamesTest.kt new file mode 100644 index 0000000..0f96f78 --- /dev/null +++ b/games/src/test/kotlin/io/flamenet/games/GamesTest.kt @@ -0,0 +1,126 @@ +package io.flamenet.games + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The parts of the API the vectors do not reach: names, caps, board orientation, setup refusals. */ +class GamesTest { + @Test + fun wireNames() { + assertEquals( + listOf("chess", "checkers", "reversi", "tictactoe"), + GameType.entries.map { it.wire }, + ) + assertEquals(listOf("first", "second"), Side.entries.map { it.wire }) + assertEquals(listOf(600, 400, 130, 9), GameType.entries.map { it.maxPlies }) + } + + @Test + fun pliesAlternateFromFirst() { + for (type in GameType.entries) { + assertEquals( + listOf(Side.FIRST, Side.SECOND, Side.FIRST, Side.SECOND), + (0 until 4).map(type::sideOfPly), + ) + } + } + + @Test + fun theCapIsCheckedBeforeAnyMove() { + assertEquals(ReplayResult.TooLong(10, 9), Games.replay(GameType.TICTACTOE, List(10) { "x" })) + } + + @Test + fun applyReportsWhichKindOfRefusal() { + val start = Games.start(GameType.CHESS) + assertEquals(MoveResult.Rejected(MoveError.ILLEGAL), Games.apply(start, "e2e5")) + assertEquals(MoveResult.Rejected(MoveError.MALFORMED), Games.apply(start, "E2E4")) + val moved = assertIs<MoveResult.Applied>(Games.apply(start, "e2e4")).position + assertEquals(Games.start(GameType.CHESS), start, "applying a move left the old position alone") + assertEquals(ReplayResult.Replayed(moved), Games.replay(GameType.CHESS, listOf("e2e4"))) + assertEquals(Side.SECOND, moved.sideToMove) + } + + @Test + fun chessBoardHasRankEightOnTop() { + val board = Games.start(GameType.CHESS).board + assertEquals(8, board.rows) + assertEquals("a8", board.squareAt(0, 0)) + assertEquals(Piece(Side.SECOND, PieceKind.ROOK), board.pieceAt(0, 0)) + assertEquals("e1", board.squareAt(7, 4)) + assertEquals(Piece(Side.FIRST, PieceKind.KING), board.pieceAt(7, 4)) + assertEquals(32, board.cells.count { it != null }) + } + + @Test + fun chessReportsCheck() { + val log = listOf("f2f3", "e7e5", "g2g4", "d8h4") + val mated = assertIs<ReplayResult.Replayed>(Games.replay(GameType.CHESS, log)) + assertTrue(assertIs<ChessPosition>(mated.position).inCheck) + } + + @Test + fun checkersBoardHasTheFirstPlayersMenAtTheBottom() { + val board = Games.start(GameType.CHECKERS).board + val bottom = (0 until 8).map { board.squareAt(7, it) } + val top = (0 until 8).map { board.squareAt(0, it) } + assertEquals(listOf("4", null, "3", null, "2", null, "1", null), bottom) + assertEquals(listOf(null, "32", null, "31", null, "30", null, "29"), top) + assertEquals(Piece(Side.FIRST, PieceKind.MAN), board.pieceAt(7, 0)) + assertEquals(Piece(Side.SECOND, PieceKind.MAN), board.pieceAt(0, 1)) + assertNull(board.pieceAt(4, 3)) + } + + @Test + fun reversiBoardHasRankOneOnTop() { + val board = Games.start(GameType.REVERSI).board + assertEquals("a1", board.squareAt(0, 0)) + assertEquals("d4", board.squareAt(3, 3)) + assertEquals(Piece(Side.SECOND, PieceKind.DISC), board.pieceAt(3, 3)) + assertEquals(Piece(Side.FIRST, PieceKind.DISC), board.pieceAt(3, 4)) + assertEquals(Piece(Side.FIRST, PieceKind.DISC), board.pieceAt(4, 3)) + assertEquals(Piece(Side.SECOND, PieceKind.DISC), board.pieceAt(4, 4)) + } + + @Test + fun ticTacToeBoardIsNumberedRowByRow() { + val played = assertIs<ReplayResult.Replayed>(Games.replay(GameType.TICTACTOE, listOf("5", "1"))) + val board = played.position.board + assertEquals((1..9).map { it.toString() }, board.squares) + assertEquals(Piece(Side.FIRST, PieceKind.MARK), board.pieceAt(1, 1)) + assertEquals(Piece(Side.SECOND, PieceKind.MARK), board.pieceAt(0, 0)) + } + + @Test + fun impossibleSetupsAreRefused() { + val refused = + listOf( + GameType.CHESS to "4k3/8/8/8/8/8/8/4K2K w - - 0 1", // two white kings + GameType.CHESS to "4k2P/8/8/8/8/8/8/4K3 w - - 0 1", // a pawn on the last rank + GameType.CHESS to "4k3/8/8/8/8/8/8/4K2R w - - 0 1 extra", + GameType.CHESS to "4k3/4R3/8/8/8/8/8/4K3 w - - 0 1", // black in check with white to move + GameType.CHESS to "4k3/8/8/8/8/8/8/4K3 w - e6 0 1", // en passant with no pawn that stepped + GameType.CHESS to "4k3/8/8/8/8/8/8/4K3 w KK - 0 1", + GameType.CHECKERS to "B:W21:B29", // a dark man where it would have been crowned + GameType.CHECKERS to "B:W21,21:B1", + GameType.CHECKERS to "B:W33:B1", + GameType.CHECKERS to "B:W21:W1", + GameType.REVERSI to "--------/--------/--------/---ox---/---xo---/--------/--------/------- x", + GameType.REVERSI to "--------/--------/--------/---ox---/---xo---/--------/--------/-------- b", + GameType.TICTACTOE to "x--/---/--- x", // X has moved, so O is to move + GameType.TICTACTOE to "xxx/ooo/--- x", + ) + for ((type, setup) in refused) assertNull(Games.position(type, setup), setup) + } + + @Test + fun setupReadsBackTheStart() { + for (type in GameType.entries) { + val start = Games.start(type) + assertEquals(start, Games.position(type, start.setup), type.wire) + } + } +} diff --git a/games/src/test/kotlin/io/flamenet/games/VectorTest.kt b/games/src/test/kotlin/io/flamenet/games/VectorTest.kt new file mode 100644 index 0000000..c6dbd03 --- /dev/null +++ b/games/src/test/kotlin/io/flamenet/games/VectorTest.kt @@ -0,0 +1,241 @@ +package io.flamenet.games + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.jupiter.api.Assumptions.assumeTrue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Runs `games-v1.json`. The iOS FlamenetGames package runs the same file, byte for byte, so the + * two engines agree on every vector or one of the suites is red. + * + * Failures are collected and reported together, so one run shows every vector that disagrees. + */ +class VectorTest { + private val vectors: JSONObject by lazy { + val stream = + requireNotNull(javaClass.getResourceAsStream("/games-v1.json")) { + "games-v1.json is not on the test classpath" + } + JSONObject(stream.bufferedReader().readText()) + } + + @Test + fun perftMatchesTheLiterature() { + val failures = mutableListOf<String>() + forEachPerft { name, position, counts, _ -> + for (index in 0 until counts.length()) { + checkPerft(name, position, index + 1, counts.getLong(index), failures) + } + } + assertTrue(failures.isEmpty(), failures.joinToString("\n")) + } + + /** The deeper published counts take seconds to minutes, so they run only on request. */ + @Test + fun deepPerft() { + assumeTrue( + System.getenv("FLAMENET_GAMES_DEEP") == "1", + "set FLAMENET_GAMES_DEEP=1 to run the deep perft counts", + ) + val failures = mutableListOf<String>() + forEachPerft { name, position, _, deep -> + for (index in 0 until (deep?.length() ?: 0)) { + val pair = deep!!.getJSONArray(index) + checkPerft(name, position, pair.getInt(0), pair.getLong(1), failures) + } + } + assertTrue(failures.isEmpty(), failures.joinToString("\n")) + } + + @Test + fun everyCase() { + val failures = mutableListOf<String>() + val cases = vectors.getJSONArray("cases") + for (index in 0 until cases.length()) check(cases.getJSONObject(index), failures) + println("games-v1.json: ${cases.length()} cases checked") + assertTrue(failures.isEmpty(), "${failures.size} failures:\n" + failures.joinToString("\n")) + } + + /** Guards against a vectors file that lost entries and still parses. */ + @Test + fun theVectorsCoverEveryGame() { + assertEquals(1, vectors.getInt("v")) + val cases = vectors.getJSONArray("cases") + assertTrue(cases.length() >= 40) + val perft = vectors.getJSONArray("perft") + for (type in GameType.entries) { + val count = (0 until cases.length()).count { gameOf(cases, it) == type.wire } + assertTrue(count >= 10, "${type.wire} has $count cases") + assertTrue((0 until perft.length()).any { gameOf(perft, it) == type.wire }, type.wire) + } + } + + private fun gameOf( + array: JSONArray, + index: Int, + ): String = array.getJSONObject(index).getString("t") + + private fun forEachPerft(body: (String, GamePosition, JSONArray, JSONArray?) -> Unit) { + val entries = vectors.getJSONArray("perft") + for (index in 0 until entries.length()) { + val entry = entries.getJSONObject(index) + val setup = entry.optString("setup").ifEmpty { null } + val position = startingPosition(entry.getString("t"), setup) + body(entry.getString("name"), position, entry.getJSONArray("counts"), entry.optJSONArray("deep")) + } + } + + private fun checkPerft( + name: String, + position: GamePosition, + depth: Int, + expected: Long, + failures: MutableList<String>, + ) { + val started = System.nanoTime() + val count = Games.perft(position, depth) + println("perft $name depth $depth: $count in ${(System.nanoTime() - started) / 1_000_000} ms") + if (count != expected) failures += "perft $name depth $depth: $count, expected $expected" + } + + private fun startingPosition( + name: String, + setup: String?, + ): GamePosition { + val type = requireNotNull(GameType.fromWire(name)) { "no game called $name" } + if (setup == null) return Games.start(type) + return requireNotNull(Games.position(type, setup)) { "setup refused: $setup" } + } + + private fun check( + vector: JSONObject, + failures: MutableList<String>, + ) { + val name = vector.getString("name") + val type = GameType.fromWire(vector.getString("t")) + if (type == null) { + failures += "$name: no such game" + return + } + val setup = vector.optString("setup").ifEmpty { null } + val start = startingPosition(type.wire, setup) + val log = strings(vector.getJSONArray("log")) + val expect = vector.getJSONObject("expect") + fun fail(message: String) { + failures += "$name: $message" + } + + val result = if (setup == null) Games.replay(type, log) else Games.replay(start, log) + when (result) { + is ReplayResult.Replayed -> { + if (expect.has("error")) fail("played through, expected ${expectedError(expect)}") + val position = result.position + if (expect.has("outcome")) { + val outcome = describe(Games.outcome(position)) + val wanted = expect.getString("outcome") + if (outcome != wanted) fail("outcome $outcome, expected $wanted") + } + if (expect.has("toMove") && position.sideToMove.wire != expect.getString("toMove")) { + fail("${position.sideToMove.wire} to move, expected ${expect.getString("toMove")}") + } + val legal = Games.legalMoves(position) + if (expect.has("legal")) { + val exact = strings(expect.getJSONArray("legal")) + if (legal.toSet() != exact.toSet() || legal.size != exact.size) { + fail("legal $legal, expected $exact") + } + } + for (move in strings(expect.optJSONArray("includes") ?: JSONArray())) { + if (move !in legal) fail("$move should be legal, legal is $legal") + } + for (move in strings(expect.optJSONArray("excludes") ?: JSONArray())) { + if (move in legal) fail("$move should not be legal") + } + if (expect.has("setup") && position.setup != expect.getString("setup")) { + fail("setup ${position.setup}, expected ${expect.getString("setup")}") + } + } + else -> { + val got = describe(result) + if (got != expectedError(expect)) fail("$got, expected ${expectedError(expect)}") + } + } + val trace = expect.optJSONArray("trace")?.let { array -> (0 until array.length()).map(array::getInt) } + walk(name, start, log, trace, fromStart = setup == null, failures) + } + + /** + * Plays the log one move at a time and checks what must hold at every step: the game is over + * exactly when there are no legal moves, `apply` accepts exactly the listed moves, the side to + * move is the one `sideOfPly` names, the setup string reads back unchanged, and the number of + * legal moves matches [trace] when the vector has one. + */ + private fun walk( + name: String, + start: GamePosition, + log: List<String>, + trace: List<Int>?, + fromStart: Boolean, + failures: MutableList<String>, + ) { + if (trace != null && trace.size != log.size + 1) failures += "$name: trace length ${trace.size}" + var position = start + for ((index, move) in log.withIndex()) { + val legal = Games.legalMoves(position) + if (trace != null && index < trace.size && legal.size != trace[index]) { + failures += "$name: ${legal.size} legal moves before ply $index, expected ${trace[index]}" + } + if (Games.outcome(position).isOver != legal.isEmpty()) { + failures += "$name: over and legal disagree before ply $index" + } + if (fromStart && position.sideToMove != position.type.sideOfPly(index)) { + failures += "$name: wrong side before ply $index" + } + if (Games.position(position.type, position.setup)?.setup != position.setup) { + failures += "$name: setup round trip before ply $index" + } + when (val result = Games.apply(position, move)) { + is MoveResult.Applied -> { + if (move !in legal) failures += "$name: applied $move, not listed" + position = result.position + } + is MoveResult.Rejected -> { + if (move in legal) failures += "$name: refused $move, but listed" + return + } + } + } + val last = Games.legalMoves(position) + if (Games.outcome(position).isOver != last.isEmpty()) { + failures += "$name: over and legal disagree at the end" + } + if (trace != null && trace.isNotEmpty() && last.size != trace.last()) { + failures += "$name: ${last.size} legal moves at the end, expected ${trace.last()}" + } + } + + private fun strings(array: JSONArray): List<String> = (0 until array.length()).map(array::getString) + + private fun describe(outcome: Outcome): String = + when (outcome) { + Outcome.Ongoing -> "ongoing" + is Outcome.Win -> "win ${outcome.side.wire} ${outcome.reason.wire}" + is Outcome.Draw -> "draw ${outcome.reason.wire}" + } + + private fun describe(result: ReplayResult): String = + when (result) { + is ReplayResult.TooLong -> "too-long" + is ReplayResult.Failed -> "${result.error.wire} at ${result.ply}" + is ReplayResult.Replayed -> "no error" + } + + private fun expectedError(expect: JSONObject): String { + if (!expect.has("error")) return "no error" + val error = expect.getString("error") + return if (error == "too-long") error else "$error at ${expect.optInt("ply", -1)}" + } +} diff --git a/games/src/test/resources/games-v1.json b/games/src/test/resources/games-v1.json new file mode 100644 index 0000000..bd3e87f --- /dev/null +++ b/games/src/test/resources/games-v1.json @@ -0,0 +1,1426 @@ +{ + "v": 1, + "about": "Rules vectors for the chat games of messenger 0.32 (DESIGN.md section 2). The iOS FlamenetGames package and the Android :games module must both pass every entry.", + "conventions": ["t: chess, checkers, reversi or tictactoe.", "setup: the starting position when present, else the game's start. Chess: FEN. Checkers: PDN FEN, B = first (dark), W = second (light), K = king, e.g. B:W21,22:BK1,9. Reversi: eight rows of x (first, black), o (second, white) and - from rank 1 to rank 8 split by /, then a space and the side to move, x or o. Tic-tac-toe: three rows of three from the top, then the side to move.", "log: the moves played from setup, in wire notation.", "expect.error: malformed or illegal, with ply = the zero-based index in log of the refused move; or too-long, for a log from the start longer than the game's cap (chess 600, checkers 400, reversi 130, tictactoe 9), refused before any move is played.", "expect.outcome: ongoing, win <first|second> <reason>, or draw <reason>. Win reasons: checkmate, no-moves, more-discs, three-in-a-row. Draw reasons: stalemate, insufficient-material, fifty-moves, forty-moves (checkers, 80 plies), repetition, equal-discs, full-board.", "expect.toMove: the side to move after log.", "expect.legal: the exact set of legal moves after log, in any order. expect.includes and expect.excludes: moves that must be, or must not be, among them.", "expect.setup: the position after log in setup notation. Chess FEN shows an en passant square only while an en passant capture is legal.", "expect.trace: the number of legal moves before each ply of log and after the last one.", "perft: counts[i] is the number of move sequences of length i + 1 from setup. A game that is over has no moves; a forced reversi pass counts as a move. deep lists [depth, count] pairs that the suites run only when FLAMENET_GAMES_DEEP=1."], + "perft": [ + { + "name": "chess start", + "t": "chess", + "counts": [20, 400, 8902, 197281], + "deep": [[5, 4865609]] + }, + { + "name": "chess kiwipete", + "t": "chess", + "setup": "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1", + "counts": [48, 2039, 97862], + "deep": [[4, 4085603]] + }, + { + "name": "chess position 3", + "t": "chess", + "setup": "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1", + "counts": [14, 191, 2812, 43238], + "deep": [[5, 674624], [6, 11030083]] + }, + { + "name": "chess position 4", + "t": "chess", + "setup": "r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1", + "counts": [6, 264, 9467], + "deep": [[4, 422333], [5, 15833292]] + }, + { + "name": "chess position 5", + "t": "chess", + "setup": "rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8", + "counts": [44, 1486, 62379], + "deep": [[4, 2103487]] + }, + { + "name": "reversi start", + "t": "reversi", + "counts": [4, 12, 56, 244, 1396, 8200, 55092], + "deep": [[8, 390216], [9, 3005288]] + }, + { + "name": "checkers start", + "t": "checkers", + "counts": [7, 49, 302, 1469, 7361, 36768], + "deep": [[7, 179740], [8, 845931], [9, 3963680], [10, 18391564]] + }, + { + "name": "tictactoe start", + "t": "tictactoe", + "counts": [9, 72, 504, 3024, 15120, 54720, 148176, 200448, 127872] + } + ], + "cases": [ + { + "name": "chess: opening moves change squares and sides", + "t": "chess", + "log": ["e2e4", "e7e5", "g1f3", "b8c6", "f1b5"], + "expect": { + "outcome": "ongoing", + "toMove": "second", + "setup": "r1bqkbnr/pppp1ppp/2n5/1B2p3/4P3/5N2/PPPP1PPP/RNBQK2R b KQkq - 3 3" + } + }, + { + "name": "chess: a bishop cannot move straight", + "t": "chess", + "log": ["e2e4", "e7e5", "f1f3"], + "expect": { + "error": "illegal", + "ply": 2 + } + }, + { + "name": "chess: white cannot move twice", + "t": "chess", + "log": ["e2e4", "d2d4"], + "expect": { + "error": "illegal", + "ply": 1 + } + }, + { + "name": "chess: nothing moves from an empty square", + "t": "chess", + "log": ["e3e4"], + "expect": { + "error": "illegal", + "ply": 0 + } + }, + { + "name": "chess: a pawn move that is no promotion takes no piece letter", + "t": "chess", + "log": ["e2e4q"], + "expect": { + "error": "illegal", + "ply": 0 + } + }, + { + "name": "chess: \"E2E4\" is not UCI", + "t": "chess", + "log": ["E2E4"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "chess: \"e2-e4\" is not UCI", + "t": "chess", + "log": ["e2-e4"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "chess: \"e2e9\" is not UCI", + "t": "chess", + "log": ["e2e9"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "chess: \"O-O\" is not UCI", + "t": "chess", + "log": ["O-O"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "chess: \"e2e4 \" is not UCI", + "t": "chess", + "log": ["e2e4 "], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "chess: \"\" is not UCI", + "t": "chess", + "log": [""], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "chess: \"e7e8k\" is not UCI", + "t": "chess", + "log": ["e7e8k"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "chess: \"e7e8Q\" is not UCI", + "t": "chess", + "log": ["e7e8Q"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "chess: \"e2\" is not UCI", + "t": "chess", + "log": ["e2"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "chess: castling short moves the rook too", + "t": "chess", + "log": ["e2e4", "e7e5", "g1f3", "b8c6", "f1c4", "g8f6", "e1g1"], + "expect": { + "outcome": "ongoing", + "setup": "r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/5N2/PPPP1PPP/RNBQ1RK1 b kq - 5 4" + } + }, + { + "name": "chess: no castling through an attacked square", + "t": "chess", + "setup": "4k3/8/b7/8/8/8/8/R3K2R w KQ - 0 1", + "log": [], + "expect": { + "excludes": ["e1g1"], + "includes": ["e1c1"] + } + }, + { + "name": "chess: castling through an attacked square is refused when sent", + "t": "chess", + "setup": "4k3/8/b7/8/8/8/8/R3K2R w KQ - 0 1", + "log": ["e1g1"], + "expect": { + "error": "illegal", + "ply": 0 + } + }, + { + "name": "chess: no castling out of check", + "t": "chess", + "setup": "4k3/4r3/8/8/8/8/8/R3K2R w KQ - 0 1", + "log": [], + "expect": { + "legal": ["e1d1", "e1d2", "e1f1", "e1f2"] + } + }, + { + "name": "chess: no castling into check", + "t": "chess", + "setup": "4k3/8/8/8/8/8/6r1/R3K2R w KQ - 0 1", + "log": [], + "expect": { + "excludes": ["e1g1"], + "includes": ["e1c1"] + } + }, + { + "name": "chess: castling long is allowed when only b1 is attacked", + "t": "chess", + "setup": "1r2k3/8/8/8/8/8/8/R3K2R w KQ - 0 1", + "log": [], + "expect": { + "includes": ["e1c1", "e1g1"] + } + }, + { + "name": "chess: a rook that moved and came back has lost its right", + "t": "chess", + "setup": "4k3/8/8/8/8/8/8/R3K2R w KQ - 0 1", + "log": ["h1h2", "e8d8", "h2h1", "d8e8"], + "expect": { + "excludes": ["e1g1"], + "includes": ["e1c1"], + "setup": "4k3/8/8/8/8/8/8/R3K2R w Q - 4 3" + } + }, + { + "name": "chess: taking a rook on its corner takes that castling right", + "t": "chess", + "setup": "r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1", + "log": ["a1a8"], + "expect": { + "setup": "R3k2r/8/8/8/8/8/8/4K2R b Kk - 0 1", + "excludes": ["e8g8", "e8c8"] + } + }, + { + "name": "chess: a double step next to an enemy pawn offers en passant", + "t": "chess", + "log": ["e2e4", "a7a6", "e4e5", "d7d5"], + "expect": { + "includes": ["e5d6"], + "setup": "rnbqkbnr/1pp1pppp/p7/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3" + } + }, + { + "name": "chess: en passant removes the pawn that stepped past", + "t": "chess", + "log": ["e2e4", "a7a6", "e4e5", "d7d5", "e5d6"], + "expect": { + "setup": "rnbqkbnr/1pp1pppp/p2P4/8/8/8/PPPP1PPP/RNBQKBNR b KQkq - 0 3" + } + }, + { + "name": "chess: en passant is only possible at once", + "t": "chess", + "log": ["e2e4", "a7a6", "e4e5", "d7d5", "a2a3", "a6a5", "e5d6"], + "expect": { + "error": "illegal", + "ply": 6 + } + }, + { + "name": "chess: en passant that would expose the king is not offered", + "t": "chess", + "setup": "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1", + "log": ["e2e3", "c7c5"], + "expect": { + "excludes": ["b5c6"], + "setup": "8/8/3p4/KPp4r/1R3p1k/4P3/6P1/8 w - - 0 2" + } + }, + { + "name": "chess: en passant that would expose the king is refused when sent", + "t": "chess", + "setup": "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1", + "log": ["e2e3", "c7c5", "b5c6"], + "expect": { + "error": "illegal", + "ply": 2 + } + }, + { + "name": "chess: en passant with nothing pinned is offered", + "t": "chess", + "setup": "8/2p5/3p3r/KP6/1R3p1k/8/4P1P1/8 w - - 0 1", + "log": ["e2e3", "c7c5"], + "expect": { + "includes": ["b5c6"], + "setup": "8/8/3p3r/KPp5/1R3p1k/4P3/6P1/8 w - c6 0 2" + } + }, + { + "name": "chess: repetition, the position after a double step counts when en passant was not legal", + "t": "chess", + "setup": "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1", + "log": ["e2e3", "c7c5", "b4b3", "h4g5", "b3b4", "g5h4", "b4b3", "h4g5", "b3b4", "g5h4"], + "expect": { + "outcome": "draw repetition", + "legal": [] + } + }, + { + "name": "chess: repetition, one ply short of the third time is still ongoing", + "t": "chess", + "setup": "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1", + "log": ["e2e3", "c7c5", "b4b3", "h4g5", "b3b4", "g5h4", "b4b3", "h4g5", "b3b4"], + "expect": { + "outcome": "ongoing" + } + }, + { + "name": "chess: repetition, the position after a double step differs when en passant was legal", + "t": "chess", + "setup": "8/2p5/3p3r/KP6/1R3p1k/8/4P1P1/8 w - - 0 1", + "log": ["e2e3", "c7c5", "b4b3", "h4g5", "b3b4", "g5h4", "b4b3", "h4g5", "b3b4", "g5h4"], + "expect": { + "outcome": "ongoing" + } + }, + { + "name": "chess: repetition, with en passant legal the draw comes a ply later, after b4b3 a third time", + "t": "chess", + "setup": "8/2p5/3p3r/KP6/1R3p1k/8/4P1P1/8 w - - 0 1", + "log": ["e2e3", "c7c5", "b4b3", "h4g5", "b3b4", "g5h4", "b4b3", "h4g5", "b3b4", "g5h4", "b4b3"], + "expect": { + "outcome": "draw repetition" + } + }, + { + "name": "chess: threefold repetition from the start", + "t": "chess", + "log": ["g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8"], + "expect": { + "outcome": "draw repetition", + "legal": [] + } + }, + { + "name": "chess: twice is not yet a repetition", + "t": "chess", + "log": ["g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1"], + "expect": { + "outcome": "ongoing" + } + }, + { + "name": "chess: no move after a repetition draw", + "t": "chess", + "log": ["g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "e2e4"], + "expect": { + "error": "illegal", + "ply": 8 + } + }, + { + "name": "chess: promotion needs a piece letter", + "t": "chess", + "setup": "8/4P3/8/8/8/8/k7/7K w - - 0 1", + "log": [], + "expect": { + "legal": ["e7e8q", "e7e8r", "e7e8b", "e7e8n", "h1g1", "h1g2", "h1h2"] + } + }, + { + "name": "chess: a promotion without a letter is refused", + "t": "chess", + "setup": "8/4P3/8/8/8/8/k7/7K w - - 0 1", + "log": ["e7e8"], + "expect": { + "error": "illegal", + "ply": 0 + } + }, + { + "name": "chess: promotion to a queen", + "t": "chess", + "setup": "8/4P3/8/8/8/8/k7/7K w - - 0 1", + "log": ["e7e8q"], + "expect": { + "outcome": "ongoing", + "setup": "4Q3/8/8/8/8/8/k7/7K b - - 0 1" + } + }, + { + "name": "chess: promotion to a knight leaves K+N v K, a draw", + "t": "chess", + "setup": "8/4P3/8/8/8/8/k7/7K w - - 0 1", + "log": ["e7e8n"], + "expect": { + "outcome": "draw insufficient-material", + "legal": [], + "setup": "4N3/8/8/8/8/8/k7/7K b - - 0 1" + } + }, + { + "name": "chess: promotion by capture", + "t": "chess", + "setup": "3r4/4P3/8/8/8/8/k7/7K w - - 0 1", + "log": ["e7d8q"], + "expect": { + "setup": "3Q4/8/8/8/8/8/k7/7K b - - 0 1" + } + }, + { + "name": "chess: fool's mate", + "t": "chess", + "log": ["f2f3", "e7e5", "g2g4", "d8h4"], + "expect": { + "outcome": "win second checkmate", + "legal": [] + } + }, + { + "name": "chess: no move after checkmate", + "t": "chess", + "log": ["f2f3", "e7e5", "g2g4", "d8h4", "e2e4"], + "expect": { + "error": "illegal", + "ply": 4 + } + }, + { + "name": "chess: scholar's mate", + "t": "chess", + "log": ["e2e4", "e7e5", "f1c4", "b8c6", "d1h5", "g8f6", "h5f7"], + "expect": { + "outcome": "win first checkmate" + } + }, + { + "name": "chess: Morphy's opera game, castling long and mate", + "t": "chess", + "log": ["e2e4", "e7e5", "g1f3", "d7d6", "d2d4", "c8g4", "d4e5", "g4f3", "d1f3", "d6e5", "f1c4", "g8f6", "f3b3", "d8e7", "b1c3", "c7c6", "c1g5", "b7b5", "c3b5", "c6b5", "c4b5", "b8d7", "e1c1", "a8d8", "d1d7", "d8d7", "h1d1", "e7e6", "b5d7", "f6d7", "b3b8", "d7b8", "d1d8"], + "expect": { + "outcome": "win first checkmate", + "setup": "1n1Rkb1r/p4ppp/4q3/4p1B1/4P3/8/PPP2PPP/2K5 b k - 1 17" + } + }, + { + "name": "chess: stalemate", + "t": "chess", + "setup": "7k/8/6K1/8/8/8/8/5Q2 w - - 0 1", + "log": ["f1f7"], + "expect": { + "outcome": "draw stalemate", + "legal": [] + } + }, + { + "name": "chess: Loyd's ten-move stalemate", + "t": "chess", + "log": ["e2e3", "a7a5", "d1h5", "a8a6", "h5a5", "h7h5", "h2h4", "a6h6", "a5c7", "f7f6", "c7d7", "e8f7", "d7b7", "d8d3", "b7b8", "d3h7", "b8c8", "f7g6", "c8e6"], + "expect": { + "outcome": "draw stalemate", + "legal": [] + } + }, + { + "name": "chess: a king may not step next to the other king", + "t": "chess", + "setup": "8/8/8/3k4/8/3K4/7P/8 w - - 0 1", + "log": [], + "expect": { + "legal": ["d3c2", "d3d2", "d3e2", "d3c3", "d3e3", "h2h3", "h2h4"] + } + }, + { + "name": "chess: a pinned knight cannot move", + "t": "chess", + "setup": "4k3/4r3/8/8/8/8/4N3/4K3 w - - 0 1", + "log": [], + "expect": { + "legal": ["e1d1", "e1d2", "e1f1", "e1f2"] + } + }, + { + "name": "chess: insufficient material, K v K", + "t": "chess", + "setup": "8/8/8/4k3/3P4/8/8/4K3 b - - 0 1", + "log": ["e5d4"], + "expect": { + "outcome": "draw insufficient-material", + "legal": [] + } + }, + { + "name": "chess: insufficient material, K+B v K", + "t": "chess", + "setup": "8/8/8/4k3/3P4/8/8/2B1K3 b - - 0 1", + "log": ["e5d4"], + "expect": { + "outcome": "draw insufficient-material" + } + }, + { + "name": "chess: insufficient material, K+N v K", + "t": "chess", + "setup": "8/8/8/4k3/3P4/8/8/1N2K3 b - - 0 1", + "log": ["e5d4"], + "expect": { + "outcome": "draw insufficient-material" + } + }, + { + "name": "chess: insufficient material, K+B v K+B on the same colour", + "t": "chess", + "setup": "8/8/8/4k3/3P4/b7/8/2B1K3 b - - 0 1", + "log": ["e5d4"], + "expect": { + "outcome": "draw insufficient-material" + } + }, + { + "name": "chess: K+B v K+B on opposite colours plays on", + "t": "chess", + "setup": "8/8/8/4k3/3P4/1b6/8/2B1K3 b - - 0 1", + "log": ["e5d4"], + "expect": { + "outcome": "ongoing" + } + }, + { + "name": "chess: K+N v K+N plays on", + "t": "chess", + "setup": "8/8/8/4k3/3P4/8/n7/1N2K3 b - - 0 1", + "log": ["e5d4"], + "expect": { + "outcome": "ongoing" + } + }, + { + "name": "chess: two bishops on one colour against a king are not in the set, play on", + "t": "chess", + "setup": "8/8/8/B3k3/3P4/8/8/2B1K3 b - - 0 1", + "log": ["e5d4"], + "expect": { + "outcome": "ongoing" + } + }, + { + "name": "chess: K+P v K plays on", + "t": "chess", + "setup": "8/8/8/4k3/8/8/3P4/4K3 w - - 0 1", + "log": [], + "expect": { + "outcome": "ongoing" + } + }, + { + "name": "chess: bare kings are drawn before any move", + "t": "chess", + "setup": "8/8/8/4k3/8/8/8/4K3 w - - 0 1", + "log": [], + "expect": { + "outcome": "draw insufficient-material", + "legal": [] + } + }, + { + "name": "chess: the hundredth quiet ply draws", + "t": "chess", + "setup": "8/8/8/4k3/8/8/4P3/4K2R w - - 99 80", + "log": ["h1h2"], + "expect": { + "outcome": "draw fifty-moves", + "legal": [] + } + }, + { + "name": "chess: a pawn move resets the fifty-move count", + "t": "chess", + "setup": "8/8/8/4k3/8/8/4P3/4K2R w - - 99 80", + "log": ["e2e4"], + "expect": { + "outcome": "ongoing", + "setup": "8/8/8/4k3/4P3/8/8/4K2R b - - 0 80" + } + }, + { + "name": "chess: a capture resets the fifty-move count", + "t": "chess", + "setup": "8/8/8/4k3/8/8/4p3/4K2R w - - 99 80", + "log": ["e1e2"], + "expect": { + "outcome": "ongoing", + "setup": "8/8/8/4k3/8/8/4K3/7R b - - 0 80" + } + }, + { + "name": "chess: mate on the hundredth quiet ply is mate", + "t": "chess", + "setup": "7k/8/6K1/8/8/8/8/R7 w - - 99 80", + "log": ["a1a8"], + "expect": { + "outcome": "win first checkmate" + } + }, + { + "name": "chess: a replay error names its ply", + "t": "chess", + "log": ["e2e4", "e7e5", "g1f3", "b8c6", "f1b5", "a7a6", "b5a4", "g8f6", "e1g1", "f8e7", "e1g1"], + "expect": { + "error": "illegal", + "ply": 10 + } + }, + { + "name": "chess: a log longer than 600 is refused before it is played", + "t": "chess", + "log": ["e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4", "e2e4"], + "expect": { + "error": "too-long" + } + }, + { + "name": "chess: a log of exactly 600 is played, here into a repetition at ply 8", + "t": "chess", + "log": ["g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8"], + "expect": { + "error": "illegal", + "ply": 8 + } + }, + { + "name": "checkers: the seven opening moves", + "t": "checkers", + "log": [], + "expect": { + "legal": ["9-13", "9-14", "10-14", "10-15", "11-15", "11-16", "12-16"], + "toMove": "first", + "outcome": "ongoing" + } + }, + { + "name": "checkers: an ordinary opening", + "t": "checkers", + "log": ["11-15", "23-19", "8-11", "22-17"], + "expect": { + "outcome": "ongoing", + "toMove": "first", + "setup": "B:W17,19,21,24,25,26,27,28,29,30,31,32:B1,2,3,4,5,6,7,9,10,11,12,15" + } + }, + { + "name": "checkers: men do not move backward", + "t": "checkers", + "log": ["11-15", "21-17", "15-11"], + "expect": { + "error": "illegal", + "ply": 2 + } + }, + { + "name": "checkers: a step goes one square", + "t": "checkers", + "log": ["9-18"], + "expect": { + "error": "illegal", + "ply": 0 + } + }, + { + "name": "checkers: a step written as a capture is refused", + "t": "checkers", + "log": ["11x15"], + "expect": { + "error": "illegal", + "ply": 0 + } + }, + { + "name": "checkers: \"11 - 15\" is not a move", + "t": "checkers", + "log": ["11 - 15"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "checkers: \"11\u201315\" is not a move", + "t": "checkers", + "log": ["11\u201315"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "checkers: \"0-4\" is not a move", + "t": "checkers", + "log": ["0-4"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "checkers: \"33-29\" is not a move", + "t": "checkers", + "log": ["33-29"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "checkers: \"011-15\" is not a move", + "t": "checkers", + "log": ["011-15"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "checkers: \"11-15-19\" is not a move", + "t": "checkers", + "log": ["11-15-19"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "checkers: \"11X15\" is not a move", + "t": "checkers", + "log": ["11X15"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "checkers: \"11-x15\" is not a move", + "t": "checkers", + "log": ["11-x15"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "checkers: \"x11\" is not a move", + "t": "checkers", + "log": ["x11"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "checkers: \"11x\" is not a move", + "t": "checkers", + "log": ["11x"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "checkers: \"11\" is not a move", + "t": "checkers", + "log": ["11"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "checkers: a capture is compulsory", + "t": "checkers", + "log": ["11-15", "22-18"], + "expect": { + "legal": ["15x22"] + } + }, + { + "name": "checkers: a step is refused while a capture exists", + "t": "checkers", + "log": ["11-15", "22-18", "9-13"], + "expect": { + "error": "illegal", + "ply": 2 + } + }, + { + "name": "checkers: the capture is answered by either of two captures", + "t": "checkers", + "log": ["11-15", "22-18", "15x22"], + "expect": { + "legal": ["25x18", "26x17"], + "setup": "W:W21,23,24,25,26,27,28,29,30,31,32:B1,2,3,4,5,6,7,8,9,10,12,22" + } + }, + { + "name": "checkers: a jump goes on while it can", + "t": "checkers", + "setup": "B:W14,23,32:B1,9", + "log": [], + "expect": { + "legal": ["9x18x27"] + } + }, + { + "name": "checkers: stopping a jump early is refused", + "t": "checkers", + "setup": "B:W14,23,32:B1,9", + "log": ["9x18"], + "expect": { + "error": "illegal", + "ply": 0 + } + }, + { + "name": "checkers: a double jump lists every landing square", + "t": "checkers", + "setup": "B:W14,23,32:B1,9", + "log": ["9x18x27"], + "expect": { + "toMove": "second", + "setup": "W:W32:B1,27" + } + }, + { + "name": "checkers: either continuation of a jump may be taken", + "t": "checkers", + "setup": "B:W14,22,23:B9", + "log": [], + "expect": { + "legal": ["9x18x25", "9x18x27"] + } + }, + { + "name": "checkers: any capture may be chosen, not only the longest", + "t": "checkers", + "setup": "B:W14,16,23,26:B9,12", + "log": [], + "expect": { + "legal": ["9x18x27", "12x19"] + } + }, + { + "name": "checkers: the shorter capture is accepted", + "t": "checkers", + "setup": "B:W14,16,23,26:B9,12", + "log": ["12x19"], + "expect": { + "setup": "W:W14,23,26:B9,19" + } + }, + { + "name": "checkers: a man crowned by a jump stops there", + "t": "checkers", + "setup": "B:W26,27:B22", + "log": [], + "expect": { + "legal": ["22x31"] + } + }, + { + "name": "checkers: the crowned man is a king and the move ended", + "t": "checkers", + "setup": "B:W26,27:B22", + "log": ["22x31"], + "expect": { + "toMove": "second", + "setup": "W:W27:BK31", + "outcome": "ongoing" + } + }, + { + "name": "checkers: going on after crowning is refused", + "t": "checkers", + "setup": "B:W26,27:B22", + "log": ["22x31x24"], + "expect": { + "error": "illegal", + "ply": 0 + } + }, + { + "name": "checkers: a king already crowned jumps on", + "t": "checkers", + "setup": "B:W26,27:BK22", + "log": [], + "expect": { + "legal": ["22x31x24"] + } + }, + { + "name": "checkers: a king steps one square in all four directions", + "t": "checkers", + "setup": "B:W32:BK18", + "log": [], + "expect": { + "legal": ["18-14", "18-15", "18-22", "18-23"] + } + }, + { + "name": "checkers: kings do not fly", + "t": "checkers", + "setup": "B:W32:BK18", + "log": ["18-11"], + "expect": { + "error": "illegal", + "ply": 0 + } + }, + { + "name": "checkers: a light man is crowned on 1-4", + "t": "checkers", + "setup": "W:W6:BK32", + "log": ["6-1"], + "expect": { + "setup": "B:WK1:BK32" + } + }, + { + "name": "checkers: the side to move with no move loses", + "t": "checkers", + "setup": "B:W8,11:B4", + "log": [], + "expect": { + "outcome": "win second no-moves", + "legal": [] + } + }, + { + "name": "checkers: losing the last piece loses", + "t": "checkers", + "setup": "B:W14:B9", + "log": ["9x18"], + "expect": { + "outcome": "win first no-moves", + "legal": [] + } + }, + { + "name": "checkers: threefold repetition", + "t": "checkers", + "setup": "B:WK29:BK4", + "log": ["4-8", "29-25", "8-4", "25-29", "4-8", "29-25", "8-4", "25-29"], + "expect": { + "outcome": "draw repetition", + "legal": [] + } + }, + { + "name": "checkers: one ply short of a repetition", + "t": "checkers", + "setup": "B:WK29:BK4", + "log": ["4-8", "29-25", "8-4", "25-29", "4-8", "29-25", "8-4"], + "expect": { + "outcome": "ongoing" + } + }, + { + "name": "checkers: 79 quiet plies are still ongoing", + "t": "checkers", + "setup": "B:WK22:BK1,K3", + "log": ["1-6", "22-26", "3-8", "26-31", "6-9", "31-27", "8-11", "27-23", "9-5", "23-18", "11-7", "18-22", "5-1", "22-26", "7-3", "26-31", "1-6", "31-27", "3-8", "27-23", "6-9", "23-18", "8-11", "18-22", "9-5", "22-26", "11-7", "26-31", "5-1", "31-27", "7-3", "27-23", "1-6", "23-18", "3-8", "18-22", "6-9", "22-26", "8-11", "26-31", "9-5", "31-27", "11-7", "27-23", "5-1", "23-18", "7-3", "18-22", "1-6", "22-26", "3-8", "26-31", "6-9", "31-27", "8-11", "27-23", "9-5", "23-18", "11-7", "18-22", "5-1", "22-26", "7-3", "26-31", "1-6", "31-27", "3-8", "27-23", "6-9", "23-18", "8-11", "18-22", "9-5", "22-26", "11-7", "26-31", "5-1", "31-27", "7-3"], + "expect": { + "outcome": "ongoing" + } + }, + { + "name": "checkers: the eightieth ply without a capture or man move draws", + "t": "checkers", + "setup": "B:WK22:BK1,K3", + "log": ["1-6", "22-26", "3-8", "26-31", "6-9", "31-27", "8-11", "27-23", "9-5", "23-18", "11-7", "18-22", "5-1", "22-26", "7-3", "26-31", "1-6", "31-27", "3-8", "27-23", "6-9", "23-18", "8-11", "18-22", "9-5", "22-26", "11-7", "26-31", "5-1", "31-27", "7-3", "27-23", "1-6", "23-18", "3-8", "18-22", "6-9", "22-26", "8-11", "26-31", "9-5", "31-27", "11-7", "27-23", "5-1", "23-18", "7-3", "18-22", "1-6", "22-26", "3-8", "26-31", "6-9", "31-27", "8-11", "27-23", "9-5", "23-18", "11-7", "18-22", "5-1", "22-26", "7-3", "26-31", "1-6", "31-27", "3-8", "27-23", "6-9", "23-18", "8-11", "18-22", "9-5", "22-26", "11-7", "26-31", "5-1", "31-27", "7-3", "27-23"], + "expect": { + "outcome": "draw forty-moves", + "legal": [] + } + }, + { + "name": "checkers: no move after the 80-ply draw", + "t": "checkers", + "setup": "B:WK22:BK1,K3", + "log": ["1-6", "22-26", "3-8", "26-31", "6-9", "31-27", "8-11", "27-23", "9-5", "23-18", "11-7", "18-22", "5-1", "22-26", "7-3", "26-31", "1-6", "31-27", "3-8", "27-23", "6-9", "23-18", "8-11", "18-22", "9-5", "22-26", "11-7", "26-31", "5-1", "31-27", "7-3", "27-23", "1-6", "23-18", "3-8", "18-22", "6-9", "22-26", "8-11", "26-31", "9-5", "31-27", "11-7", "27-23", "5-1", "23-18", "7-3", "18-22", "1-6", "22-26", "3-8", "26-31", "6-9", "31-27", "8-11", "27-23", "9-5", "23-18", "11-7", "18-22", "5-1", "22-26", "7-3", "26-31", "1-6", "31-27", "3-8", "27-23", "6-9", "23-18", "8-11", "18-22", "9-5", "22-26", "11-7", "26-31", "5-1", "31-27", "7-3", "27-23", "1-6"], + "expect": { + "error": "illegal", + "ply": 80 + } + }, + { + "name": "checkers: a replay error names its ply", + "t": "checkers", + "log": ["11-15", "23-19", "8-11", "22-17", "15-19"], + "expect": { + "error": "illegal", + "ply": 4 + } + }, + { + "name": "checkers: a log longer than 400 is refused before it is played", + "t": "checkers", + "log": ["11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15", "11-15"], + "expect": { + "error": "too-long" + } + }, + { + "name": "reversi: the four opening moves", + "t": "reversi", + "log": [], + "expect": { + "legal": ["d3", "c4", "f5", "e6"], + "toMove": "first", + "outcome": "ongoing" + } + }, + { + "name": "reversi: a placement turns the bracketed disc", + "t": "reversi", + "log": ["d3"], + "expect": { + "legal": ["c3", "e3", "c5"], + "toMove": "second", + "setup": "--------/--------/---x----/---xx---/---xo---/--------/--------/-------- o" + } + }, + { + "name": "reversi: a placement must turn something", + "t": "reversi", + "log": ["a1"], + "expect": { + "error": "illegal", + "ply": 0 + } + }, + { + "name": "reversi: a placement must be on an empty square", + "t": "reversi", + "log": ["d4"], + "expect": { + "error": "illegal", + "ply": 0 + } + }, + { + "name": "reversi: no pass while a placement exists", + "t": "reversi", + "log": ["pass"], + "expect": { + "error": "illegal", + "ply": 0 + } + }, + { + "name": "reversi: \"D3\" is not a move", + "t": "reversi", + "log": ["D3"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "reversi: \"d9\" is not a move", + "t": "reversi", + "log": ["d9"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "reversi: \"i1\" is not a move", + "t": "reversi", + "log": ["i1"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "reversi: \"Pass\" is not a move", + "t": "reversi", + "log": ["Pass"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "reversi: \"d\" is not a move", + "t": "reversi", + "log": ["d"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "reversi: \" d3\" is not a move", + "t": "reversi", + "log": [" d3"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "reversi: \"\" is not a move", + "t": "reversi", + "log": [""], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "reversi: \"d3d4\" is not a move", + "t": "reversi", + "log": ["d3d4"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "reversi: one placement turns lines in three directions", + "t": "reversi", + "setup": "--------/--------/---ox---/--oo----/--x-x---/--------/--------/-------- x", + "log": ["c3"], + "expect": { + "outcome": "win first more-discs", + "legal": [], + "setup": "--------/--------/--xxx---/--xx----/--x-x---/--------/--------/-------- o" + } + }, + { + "name": "reversi: with no placement and the opponent able to move, pass is the only move", + "t": "reversi", + "setup": "ox------/--------/--------/--------/--------/--------/--------/-------- x", + "log": [], + "expect": { + "legal": ["pass"], + "outcome": "ongoing" + } + }, + { + "name": "reversi: a forced pass hands over the move", + "t": "reversi", + "setup": "ox------/--------/--------/--------/--------/--------/--------/-------- x", + "log": ["pass"], + "expect": { + "legal": ["c1"], + "toMove": "second", + "setup": "ox------/--------/--------/--------/--------/--------/--------/-------- o" + } + }, + { + "name": "reversi: a side that must pass cannot place", + "t": "reversi", + "setup": "ox------/--------/--------/--------/--------/--------/--------/-------- x", + "log": ["c1"], + "expect": { + "error": "illegal", + "ply": 0 + } + }, + { + "name": "reversi: the game ends when neither side can move", + "t": "reversi", + "setup": "ox------/--------/--------/--------/--------/--------/--------/-------- x", + "log": ["pass", "c1"], + "expect": { + "outcome": "win second more-discs", + "legal": [] + } + }, + { + "name": "reversi: no second pass, the game is over and level", + "t": "reversi", + "setup": "x-------/--------/--------/--------/--------/--------/--------/-------o x", + "log": [], + "expect": { + "outcome": "draw equal-discs", + "legal": [] + } + }, + { + "name": "reversi: pass is refused when neither side can move", + "t": "reversi", + "setup": "x-------/--------/--------/--------/--------/--------/--------/-------o x", + "log": ["pass"], + "expect": { + "error": "illegal", + "ply": 0 + } + }, + { + "name": "reversi: more discs wins when neither side can move", + "t": "reversi", + "setup": "xx------/--------/--------/--------/--------/--------/--------/-------o o", + "log": [], + "expect": { + "outcome": "win first more-discs", + "legal": [] + } + }, + { + "name": "reversi: a wipeout after nine plies ends the game", + "t": "reversi", + "log": ["d3", "c3", "b3", "d2", "e1", "d6", "d7", "e3", "f4"], + "expect": { + "outcome": "win first more-discs", + "legal": [], + "setup": "----x---/---x----/-xxxx---/---xxx--/---xx---/---x----/---x----/-------- o" + } + }, + { + "name": "reversi: nothing may follow the wipeout, not even a pass", + "t": "reversi", + "log": ["d3", "c3", "b3", "d2", "e1", "d6", "d7", "e3", "f4", "pass"], + "expect": { + "error": "illegal", + "ply": 9 + } + }, + { + "name": "reversi: a whole seeded game with three forced passes, to a full board", + "t": "reversi", + "log": ["e6", "f4", "e3", "d2", "c3", "c5", "g3", "f5", "c1", "e2", "f6", "g4", "f2", "e7", "d7", "g5", "d6", "c4", "f8", "e1", "g6", "h6", "d3", "f7", "h5", "c6", "g7", "b4", "a4", "h2", "b3", "a2", "a3", "h3", "b6", "g1", "d1", "d8", "h4", "a7", "a1", "a5", "h1", "b1", "b5", "b2", "f3", "g8", "f1", "g2", "h8", "b7", "h7", "e8", "b8", "c7", "c2", "pass", "c8", "pass", "a8", "pass", "a6"], + "expect": { + "outcome": "win first more-discs", + "legal": [], + "setup": "xxxxxxxx/xxxxxxxx/xxxxxoox/xxxoxxox/xxxoxxox/xxxoxxxx/xxxxooxx/xxxxxxxx o", + "trace": [4, 3, 5, 4, 6, 5, 7, 8, 9, 10, 6, 8, 11, 12, 11, 13, 10, 9, 16, 11, 14, 17, 9, 13, 10, 16, 10, 14, 13, 15, 13, 14, 13, 11, 12, 16, 8, 15, 10, 12, 11, 12, 12, 11, 11, 9, 9, 6, 8, 7, 8, 5, 8, 1, 6, 3, 4, 1, 3, 1, 2, 1, 1, 0] + } + }, + { + "name": "checkers: a whole seeded game with multi-jumps and a king, to a win", + "t": "checkers", + "log": ["9-14", "22-17", "6-9", "23-19", "9-13", "19-16", "13x22", "25x18x9", "5x14", "24-20", "12x19", "29-25", "19-24", "28x19", "11-15", "26-22", "15x24", "32-28", "14-17", "21x14", "10x17x26", "30x23", "7-10", "28x19", "2-7", "19-15", "10x19x26", "31x22", "7-11", "27-23", "11-16", "20x11", "8x15", "22-17", "15-19", "23x16", "1-6", "25-21", "3-8", "16-12", "8-11", "17-13", "4-8", "12x3", "6-10", "13-9", "10-14", "3-8", "14-18", "8x15x22"], + "expect": { + "outcome": "win second no-moves", + "legal": [], + "setup": "B:W9,21,K22:B", + "trace": [7, 7, 7, 7, 8, 7, 3, 2, 3, 8, 1, 9, 11, 1, 9, 9, 1, 8, 9, 3, 1, 3, 7, 1, 9, 8, 1, 1, 6, 6, 6, 1, 1, 5, 7, 1, 5, 6, 5, 4, 3, 3, 5, 1, 4, 4, 4, 5, 4, 1, 0] + } + }, + { + "name": "reversi: a replay error names its ply", + "t": "reversi", + "log": ["d3", "c3", "c4", "pass"], + "expect": { + "error": "illegal", + "ply": 3 + } + }, + { + "name": "reversi: a log longer than 130 is refused before it is played", + "t": "reversi", + "log": ["d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3", "d3"], + "expect": { + "error": "too-long" + } + }, + { + "name": "tictactoe: X moves first, anywhere", + "t": "tictactoe", + "log": [], + "expect": { + "legal": ["1", "2", "3", "4", "5", "6", "7", "8", "9"], + "toMove": "first" + } + }, + { + "name": "tictactoe: a position mid-game", + "t": "tictactoe", + "log": ["5", "1", "9"], + "expect": { + "legal": ["2", "3", "4", "6", "7", "8"], + "toMove": "second", + "setup": "o--/-x-/--x o" + } + }, + { + "name": "tictactoe: three in a row wins", + "t": "tictactoe", + "log": ["1", "4", "2", "5", "3"], + "expect": { + "outcome": "win first three-in-a-row", + "legal": [] + } + }, + { + "name": "tictactoe: O wins on a diagonal", + "t": "tictactoe", + "log": ["1", "5", "2", "3", "4", "7"], + "expect": { + "outcome": "win second three-in-a-row", + "legal": [] + } + }, + { + "name": "tictactoe: a full board with no line is a draw", + "t": "tictactoe", + "log": ["1", "2", "3", "5", "4", "6", "8", "7", "9"], + "expect": { + "outcome": "draw full-board", + "legal": [] + } + }, + { + "name": "tictactoe: a line made by the ninth move is a win, not a draw", + "t": "tictactoe", + "log": ["1", "4", "2", "5", "6", "8", "7", "9", "3"], + "expect": { + "outcome": "win first three-in-a-row" + } + }, + { + "name": "tictactoe: no move after a win", + "t": "tictactoe", + "log": ["1", "4", "2", "5", "3", "6"], + "expect": { + "error": "illegal", + "ply": 5 + } + }, + { + "name": "tictactoe: a taken cell is refused", + "t": "tictactoe", + "log": ["5", "5"], + "expect": { + "error": "illegal", + "ply": 1 + } + }, + { + "name": "tictactoe: \"0\" is not a cell", + "t": "tictactoe", + "log": ["0"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "tictactoe: \"10\" is not a cell", + "t": "tictactoe", + "log": ["10"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "tictactoe: \"a\" is not a cell", + "t": "tictactoe", + "log": ["a"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "tictactoe: \"05\" is not a cell", + "t": "tictactoe", + "log": ["05"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "tictactoe: \" 5\" is not a cell", + "t": "tictactoe", + "log": [" 5"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "tictactoe: \"x\" is not a cell", + "t": "tictactoe", + "log": ["x"], + "expect": { + "error": "malformed", + "ply": 0 + } + }, + { + "name": "tictactoe: a log longer than 9 is refused before it is played", + "t": "tictactoe", + "log": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "1"], + "expect": { + "error": "too-long" + } + } + ] +} diff --git a/interop/build.gradle.kts b/interop/build.gradle.kts index 61e4866..5b4f731 100644 --- a/interop/build.gradle.kts +++ b/interop/build.gradle.kts @@ -14,6 +14,8 @@ repositories { mavenCentral() } dependencies { implementation(project(":engine")) implementation(project(":client")) + // The shell peer replays game logs with the app's own rules engine. + implementation(project(":games")) implementation("org.json:json:20240303") } diff --git a/interop/src/main/kotlin/io/flamenet/interop/Peer.kt b/interop/src/main/kotlin/io/flamenet/interop/Peer.kt index e4628c1..49d497d 100644 --- a/interop/src/main/kotlin/io/flamenet/interop/Peer.kt +++ b/interop/src/main/kotlin/io/flamenet/interop/Peer.kt @@ -44,7 +44,7 @@ object Peer { @JvmStatic fun main(args: Array<String>) { if (args.size < 2) { - System.err.println("usage: peer <state.json> <create|add|send|poll|whoami> [...]") + System.err.println("usage: peer <state.json> <create|add|send|poll|whoami|claim|inspect|do|bot> [...]") kotlin.system.exitProcess(2) } val stateFile = File(args[0]) @@ -70,8 +70,31 @@ object Peer { val conversations = Conversations(identity, vault, relay) .also { it.myUserId = state.getLong("userId") } + // The 0.32 kinds, and the long-running correspondent (test harness). + val kinds = PeerKinds(stateFile, state, relay, api, conversations) when (command) { + "claim" -> { + println(kinds.claim()) + } + + /** One 0.32 command, as JSON: `peer state.json do '{"cmd":"card",...}'`. */ + "do" -> { + println(kinds.run(JSONObject(args.drop(2).joinToString(" ")))) + } + + /** Poll, describing every envelope in full. */ + "inspect" -> { + val out = org.json.JSONArray() + conversations.poll().forEach { out.put(kinds.describe(it)) } + println(JSONObject().put("messages", out)) + } + + /** `peer state.json bot <command-folder> [seconds]` */ + "bot" -> { + kinds.bot(File(args[2]), args.getOrNull(3)?.toIntOrNull() ?: 3600) + } + "whoami" -> { println( JSONObject() diff --git a/interop/src/main/kotlin/io/flamenet/interop/PeerKinds.kt b/interop/src/main/kotlin/io/flamenet/interop/PeerKinds.kt new file mode 100644 index 0000000..f2fae29 --- /dev/null +++ b/interop/src/main/kotlin/io/flamenet/interop/PeerKinds.kt @@ -0,0 +1,514 @@ +package io.flamenet.interop + +import io.flamenet.client.AccountIdentity +import io.flamenet.client.Conversations +import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayClient +import io.flamenet.e2e.Content +import io.flamenet.games.GameType +import io.flamenet.games.Games +import io.flamenet.games.Outcome +import io.flamenet.games.ReplayResult +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.security.SecureRandom +import java.util.UUID + +/** + * The 0.32 kinds for the shell peer: cards, events and answers, games, photo + * stories with their edits and take-downs, stickers and directory shares. + * + * Test harness only. Every envelope is built by the engine's own encoders + * (`Content.card`, `Content.event`, ...), so what the other app receives is + * the Android app's bytes, not a copy of them written here. + * + * `bot` is a long-running correspondent: it polls, writes one line per + * envelope it opens, and carries out commands dropped into a folder as + * `<name>.cmd.json`, answering each with `<name>.done.json`. That lets a UI + * test on the other platform say "send the sealed card now" without a second + * device. + */ +internal class PeerKinds( + private val stateFile: File, + private val state: JSONObject, + private val relay: RelayClient, + private val api: MessengerApi, + private val conversations: Conversations, +) { + private val myUserId = state.getLong("userId") + + /** What this peer knows about each game: its type, the side it plays, the log. */ + private data class Track( + val type: String, + val mine: String, + var log: List<String>, + var ending: String? = null, + var endedBy: String? = null, + ) + + private val games = HashMap<String, Track>() + + /** Events this peer organised, so a revision can carry the whole event. */ + private val events = HashMap<String, Content.Event>() + + /** The last `n` sent for each event (DESIGN 7). */ + private val answered = HashMap<String, Long>() + + private val handles = HashMap<String, Long>() + + /** Every envelope opened by `bot`, in order, as `describe` wrote it. */ + private val received = mutableListOf<JSONObject>() + + /** Where the last `mark` left the received list. */ + private var lastMark = 0 + + /** The newest event, game and story ids seen, for `"latest"`. */ + private var lastEventId: String? = null + private var lastGameId: String? = null + private var lastPostedStory: String? = null + + // ---- Who ------------------------------------------------------------------ + + private fun userIdFor(to: String): Long { + to.toLongOrNull()?.let { return it } + handles[to]?.let { return it } + val found = + api.buddies().firstOrNull { it.handle == to || it.displayName == to }?.userId + ?: api.lookup(to)?.let { me -> me.userId } + ?: error("nobody called '$to' on this relay") + handles[to] = found + return found + } + + /** Claim an account address (fnid) with a fresh seed, as the app does at sign-in. */ + fun claim(): JSONObject { + state.optString("fnid").takeIf { it.isNotEmpty() }?.let { fnid -> + return JSONObject().put("fnid", fnid).put("address", address(fnid)) + } + val seed = ByteArray(32).also { SecureRandom().nextBytes(it) } + val fnid = AccountIdentity.claim(relay, seed, myUserId) + state.put("fnid", fnid) + state.put("accountSeed", java.util.Base64.getEncoder().encodeToString(seed)) + stateFile.writeText(state.toString(2)) + return JSONObject().put("fnid", fnid).put("address", address(fnid)) + } + + private fun address(fnid: String): String { + val host = java.net.URI(state.getString("relay")).host + return "$fnid@$host" + } + + // ---- Reading -------------------------------------------------------------- + + /** One opened envelope, as plainly as it can be written down. */ + fun describe(r: Conversations.Received): JSONObject { + val c = r.content + val o = + JSONObject() + .put("at", System.currentTimeMillis()) + .put("id", r.id) + .put("from_user", r.fromUser) + .put("from_device", r.fromDevice.take(8)) + .put("sealed", r.sealed) + .put("proven", r.senderProven) + .put("kind", c.kind) + .put("text", c.text) + c.messageId?.let { o.put("mid", it) } + c.groupId?.let { o.put("gid", it) } + c.raw?.let { o.put("raw", it) } + c.sticker?.let { o.put("sticker", JSONObject().put("pack", it.pack).put("id", it.id)) } + c.link?.let { o.put("link", it.toJson()) } + c.card?.let { card -> + o.put( + "card", + JSONObject(card.toJson()) + .put("sealedNow", card.isSealed(false, System.currentTimeMillis(), System.currentTimeMillis())), + ) + } + c.event?.let { + o.put("event", JSONObject(it.toJson())) + lastEventId = it.id + } + c.rsvp?.let { o.put("rsvp", JSONObject(it.toJson())) } + c.game?.let { g -> + lastGameId = g.id + o.put("game", JSONObject(g.toJson())) + o.put("gameAfter", gameView(g)) + } + c.unpost?.let { u -> + o.put("unpost", JSONObject().put("sid", u.storyId ?: JSONObject.NULL).put("set", u.setId ?: JSONObject.NULL)) + } + c.editOf?.let { o.put("editOf", it).put("editsStory", c.editsStory) } + c.attachment?.let { a -> + o.put( + "attachment", + JSONObject() + .put("kind", a.kind) + .put("id", a.id) + .put("mime", a.mime) + .put("w", a.width) + .put("h", a.height) + .put("bytes", a.bytes), + ) + } + c.storySet?.let { o.put("storySet", it.toJson()) } + o.put("controlKind", c.kind in setOf(Content.Kind.GAME_MOVE, Content.Kind.GAME_END, Content.Kind.RSVP, Content.Kind.UNPOST)) + return o + } + + /** Fold a game envelope from the other side into what this peer knows. */ + private fun gameView(g: Content.Game): JSONObject { + val theirs = g.mine + val mineSide = if (theirs == Content.Game.FIRST) Content.Game.SECOND else Content.Game.FIRST + val track = games.getOrPut(g.id) { Track(g.type, mineSide, emptyList()) } + val incoming = g.log + val note = + when { + incoming == null -> "start" + incoming.size > track.log.size && incoming.subList(0, track.log.size) == track.log -> { + track.log = incoming + "advanced" + } + incoming == track.log -> "same" + else -> "OUT OF STEP: known ${track.log} got $incoming" + } + if (g.ending != null) { + track.ending = g.ending + track.endedBy = theirs + } + return outcome(g.id).put("fold", note) + } + + /** The engine's verdict on what this peer holds for one game. */ + private fun outcome(gameId: String): JSONObject { + val track = games[gameId] ?: return JSONObject().put("error", "unknown game") + val type = GameType.fromWire(track.type) ?: return JSONObject().put("error", "unknown type") + val out = + JSONObject() + .put("game", gameId) + .put("t", track.type) + .put("peerPlays", track.mine) + .put("log", JSONArray(track.log)) + track.ending?.let { out.put("r", it).put("rBy", track.endedBy) } + when (val replay = Games.replay(type, track.log)) { + is ReplayResult.Replayed -> { + when (val result = Games.outcome(replay.position)) { + is Outcome.Ongoing -> out.put("outcome", "ongoing") + is Outcome.Win -> out.put("outcome", "win:${result.side.wire}:${result.reason.wire}") + is Outcome.Draw -> out.put("outcome", "draw:${result.reason.wire}") + } + out.put("legalNext", JSONArray(Games.legalMoves(replay.position))) + } + is ReplayResult.TooLong -> out.put("outcome", "too-long") + is ReplayResult.Failed -> out.put("outcome", "replay-failed-at-${replay.ply}:${replay.error.wire}") + } + return out + } + + // ---- Sending -------------------------------------------------------------- + + private fun send( + to: Long, + body: String, + ): JSONObject { + val sent = conversations.send(to, body) + return JSONObject() + .put("to", to) + .put("body", body) + .put("delivered", sent.delivered.size) + .put("failed", JSONObject(sent.failed as Map<*, *>)) + .put("sealed", sent.sealed) + } + + private fun mid() = UUID.randomUUID().toString() + + private fun now() = System.currentTimeMillis() / 1000 + + fun run(cmd: JSONObject): JSONObject { + val to = cmd.optString("to").takeIf { it.isNotEmpty() }?.let(::userIdFor) + fun target() = to ?: error("'${cmd.optString("cmd")}' needs \"to\"") + return when (val name = cmd.getString("cmd")) { + "add" -> { + api.addBuddy(cmd.getString("handle")) + JSONObject().put("added", cmd.getString("handle")) + } + + "claim" -> claim() + + "text" -> send(target(), Content.text(cmd.getString("body"), messageId = mid())) + + "rawtext" -> send(target(), cmd.getString("body")) + + "sticker" -> send(target(), Content.sticker(cmd.getString("pack"), cmd.getString("id"))) + + "card" -> { + val open = + when { + cmd.has("open") -> cmd.getLong("open") + cmd.has("openIn") -> now() + cmd.getLong("openIn") + else -> null + } + val card = + Content.Card( + design = cmd.optString("d", "hello"), + msg = cmd.optString("msg"), + to = cmd.optString("toLine").takeIf { it.isNotEmpty() }, + from = cmd.optString("from").takeIf { it.isNotEmpty() }, + open = open, + ) + send(target(), Content.card(card, mid())).put("open", open ?: JSONObject.NULL) + } + + "event" -> { + val id = cmd.optString("id").ifEmpty { UUID.randomUUID().toString() } + val before = events[id] + val event = + Content.Event( + id = id, + rev = cmd.optInt("rev", (before?.rev ?: 0) + 1), + title = cmd.optString("title").ifEmpty { before?.title ?: error("an event needs a title") }, + start = if (cmd.has("start")) cmd.getLong("start") else if (cmd.has("startIn")) now() + cmd.getLong("startIn") else before?.start, + end = if (cmd.has("end")) cmd.getLong("end") else if (cmd.has("endIn")) now() + cmd.getLong("endIn") else before?.end, + day = cmd.optString("day").takeIf { it.isNotEmpty() } ?: before?.day, + days = if (cmd.has("days")) cmd.getInt("days") else before?.days, + tz = cmd.optString("tz").takeIf { it.isNotEmpty() } ?: before?.tz, + place = cmd.optString("place").takeIf { it.isNotEmpty() } ?: before?.place, + note = cmd.optString("note").takeIf { it.isNotEmpty() } ?: before?.note, + cancelled = cmd.optBoolean("cancel", false), + ) + events[id] = event + send(target(), Content.event(event, mid())).put("eventId", id).put("rev", event.rev) + } + + "rsvp" -> { + val eid = cmd.getString("eid").let { if (it == "latest") lastEventId ?: error("no event yet") else it } + val n = maxOf(now(), (answered[eid] ?: 0L) + 1) + answered[eid] = n + send(target(), Content.rsvp(Content.Rsvp(eid, cmd.getString("r"), n))).put("n", n) + } + + "game" -> { + val id = UUID.randomUUID().toString() + val type = cmd.getString("t") + val mine = cmd.getString("mine") + games[id] = Track(type, mine, emptyList()) + lastGameId = id + send(target(), Content.game(Content.Game(id, type, mine), mid())).put("game", id) + } + + "gmove" -> { + val id = gameId(cmd) + val track = games[id] ?: error("no game $id here") + val next = track.log + cmd.getString("move") + val type = GameType.fromWire(track.type) ?: error("unknown type ${track.type}") + val replay = Games.replay(type, next) + require(replay is ReplayResult.Replayed) { "the engine refuses $next: $replay" } + require(type.sideOfPly(next.size - 1).wire == track.mine) { "not this peer's move: ${track.log}" } + val body = Content.gameMove(Content.Game(id, track.type, track.mine, next)) + val sent = send(target(), body) + track.log = next + sent.put("after", outcome(id)) + } + + "gend" -> { + val id = gameId(cmd) + val track = games[id] ?: error("no game $id here") + val body = Content.gameEnd(Content.Game(id, track.type, track.mine, track.log, cmd.getString("r"))) + val sent = send(target(), body) + track.ending = cmd.getString("r") + track.endedBy = track.mine + sent.put("after", outcome(id)) + } + + "gstate" -> { + outcome(gameId(cmd)) + } + + "games" -> { + val out = JSONObject() + games.keys.forEach { out.put(it, outcome(it)) } + out + } + + "story" -> { + val bytes = File(cmd.getString("path")).readBytes() + val posted = + conversations.postStory( + data = bytes, + mime = cmd.optString("mime", "image/jpeg"), + width = cmd.getInt("w"), + height = cmd.getInt("h"), + caption = cmd.optString("caption").takeIf { it.isNotEmpty() }, + toUserIds = listOf(target()), + ) + lastPostedStory = posted.ref.id + JSONObject() + .put("story", posted.ref.id) + .put("reached", posted.reached) + .put("missed", posted.missed) + } + + "storyedit" -> send(target(), Content.storyEdit(storyId(cmd), cmd.getString("caption"))) + + "unpost" -> send(target(), Content.unpostStory(storyId(cmd))) + + "mark" -> { + lastMark = received.size + JSONObject().put("seq", lastMark) + } + + "received" -> { + val from = cmd.optInt("after", 0) + JSONObject().put("messages", JSONArray(received.drop(from))) + } + + "dirshare" -> { + val url = cmd.getString("url") + val preview = Content.LinkPreview.of(url, cmd.getString("title"), "") + send(target(), Content.text(url, messageId = mid(), link = preview)) + } + + "whoami" -> { + JSONObject() + .put("handle", state.getString("username")) + .put("user_id", myUserId) + .put("fnid", state.optString("fnid")) + } + + else -> error("unknown command '$name'") + } + } + + private fun gameId(cmd: JSONObject): String = + cmd.optString("game").let { if (it.isEmpty() || it == "latest") lastGameId ?: error("no game yet") else it } + + private fun storyId(cmd: JSONObject): String = + cmd.optString("sid").let { if (it.isEmpty() || it == "latest") lastPostedStory ?: error("no story posted yet") else it } + + /** A value at a dotted path such as `event.rev`, as text, or null. */ + private fun at( + o: JSONObject, + path: String, + ): String? { + var node: Any? = o + for (part in path.split('.')) { + node = (node as? JSONObject)?.opt(part) ?: return null + } + return node?.toString() + } + + /** The first envelope from index [after] on that has [kind] and every value in [where]. */ + private fun match( + kind: String, + where: JSONObject?, + after: Int, + ): JSONObject? = + received.drop(after).firstOrNull { o -> + o.optString("kind") == kind && + (where == null || where.keys().asSequence().all { key -> at(o, key) == where.get(key).toString() }) + } + + private data class Pending( + val name: String, + val cmd: JSONObject, + val after: Int, + val deadline: Long, + ) + + // ---- The long-running correspondent --------------------------------------- + + fun bot( + commands: File, + seconds: Int, + ) { + commands.mkdirs() + val deadline = System.currentTimeMillis() + seconds * 1000L + log("START", JSONObject().put("user_id", myUserId).put("handle", state.getString("username"))) + var stop = false + val pending = mutableListOf<Pending>() + while (!stop && System.currentTimeMillis() < deadline) { + val opened = + try { + conversations.poll() + } catch (e: Exception) { + log("POLL-ERROR", JSONObject().put("error", e.toString())) + emptyList() + } + for (r in opened) { + val o = runCatching { describe(r) }.getOrElse { JSONObject().put("describeError", it.toString()) } + o.put("seq", received.size) + received += o + log("RECV", o) + } + val settled = mutableListOf<Pending>() + for (p in pending) { + val hit = match(p.cmd.getString("kind"), p.cmd.optJSONObject("where"), p.after) + val result = + when { + hit != null -> JSONObject().put("ok", true).put("message", hit) + System.currentTimeMillis() > p.deadline -> + JSONObject().put("ok", false).put("error", "nothing matched ${p.cmd} after #${p.after}") + else -> null + } ?: continue + settled += p + finish(commands, p.name, p.cmd, result) + } + pending.removeAll(settled) + val waiting = + commands + .listFiles { f -> f.name.endsWith(".cmd.json") } + .orEmpty() + .sortedBy { it.name } + for (file in waiting) { + val name = file.name.removeSuffix(".cmd.json") + val taken = File(commands, "$name.taken") + if (!file.renameTo(taken)) continue + val cmd = runCatching { JSONObject(taken.readText()) }.getOrNull() + if (cmd?.optString("cmd") == "expect") { + val after = if (cmd.has("after")) cmd.getInt("after") else lastMark + val seconds = cmd.optInt("timeout", 60) + pending += Pending(name, cmd, after, System.currentTimeMillis() + seconds * 1000L) + log("EXPECT $name", cmd) + continue + } + val result = + if (cmd == null) { + JSONObject().put("ok", false).put("error", "not JSON") + } else if (cmd.optString("cmd") == "stop") { + stop = true + JSONObject().put("ok", true) + } else { + try { + run(cmd).put("ok", true) + } catch (e: Exception) { + JSONObject().put("ok", false).put("error", e.toString()) + } + } + finish(commands, name, cmd, result) + } + Thread.sleep(1200) + } + log("STOP", JSONObject()) + } + + private fun finish( + commands: File, + name: String, + cmd: JSONObject?, + result: JSONObject, + ) { + log("DONE $name", JSONObject().put("cmd", cmd ?: JSONObject.NULL).put("result", result)) + val done = File(commands, "$name.done.json.part") + done.writeText(result.toString()) + done.renameTo(File(commands, "$name.done.json")) + } + + private fun log( + what: String, + o: JSONObject, + ) { + println("${java.time.Instant.now()} $what $o") + System.out.flush() + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index d43784e..fe1f075 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -39,3 +39,7 @@ include(":obfs4") // The cross-engine interop driver. Not shipped; see `engine/test/interop-kotlin.mjs`. include(":interop") + +// Rules for the games played inside chats. Plain JVM like `:engine`; its vectors are shared +// byte for byte with the iOS FlamenetGames package. +include(":games")